ha-mcp-dev 7.4.1.dev442__tar.gz → 7.4.1.dev444__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 (107) hide show
  1. {ha_mcp_dev-7.4.1.dev442/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev444}/PKG-INFO +3 -1
  2. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/README.md +2 -0
  3. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/pyproject.toml +1 -1
  4. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_entry_flow.py +324 -16
  5. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_helpers.py +1144 -148
  6. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444/src/ha_mcp_dev.egg-info}/PKG-INFO +3 -1
  7. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/LICENSE +0 -0
  8. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/MANIFEST.in +0 -0
  9. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/setup.cfg +0 -0
  10. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/__init__.py +0 -0
  11. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/__main__.py +0 -0
  12. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/_pypi_marker +0 -0
  13. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/_version.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/consent_form.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/provider.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/__init__.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/rest_client.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/websocket_client.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/websocket_listener.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/config.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/errors.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/py.typed +0 -0
  24. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  25. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  27. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  28. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  32. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  35. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  41. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/server.py +0 -0
  45. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/settings_ui.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/smoke_test.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/__init__.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/backup.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/device_control.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/enhanced.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/helpers.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/reference_validator.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/registry.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/smart_search.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_addons.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_areas.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_calendar.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_camera.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_categories.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_energy.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_entities.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_groups.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_hacs.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_history.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_labels.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_registry.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_resources.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_search.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/data_paths.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/domain_handlers.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/operation_manager.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/python_sandbox.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/usage_logger.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/tests/__init__.py +0 -0
  106. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/tests/test_constants.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev442 → ha_mcp_dev-7.4.1.dev444}/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.4.1.dev442
3
+ Version: 7.4.1.dev444
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
@@ -341,6 +341,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
341
341
  - **[@gcormier](https://github.com/gcormier)** — Windows installer improvements: removed unused variable and fixed terminal closing after install.
342
342
  - **[@ekobres](https://github.com/ekobres)** — Feature flags for `HAMCP_ENABLE_FILESYSTEM_TOOLS` and `HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION` in the add-on config, with beta tagging in source and docs.
343
343
  - **[@w3z315](https://github.com/w3z315)** — Financial support via [GitHub Sponsors](https://github.com/sponsors/julienld). Thank you! ☕
344
+ - **[@griffinmartin](https://github.com/griffinmartin)** — Added OpenCode (by Anomaly) as a selectable AI client in the setup wizard, with both stdio and streamable HTTP support.
345
+ - **[@hhopke](https://github.com/hhopke)** — Fixed addon API calls to route through HA Core ingress proxy instead of direct container connections, fixing `ha_manage_addon` proxy mode on addon installs.
344
346
 
345
347
  ---
346
348
 
@@ -312,6 +312,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
312
312
  - **[@gcormier](https://github.com/gcormier)** — Windows installer improvements: removed unused variable and fixed terminal closing after install.
313
313
  - **[@ekobres](https://github.com/ekobres)** — Feature flags for `HAMCP_ENABLE_FILESYSTEM_TOOLS` and `HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION` in the add-on config, with beta tagging in source and docs.
314
314
  - **[@w3z315](https://github.com/w3z315)** — Financial support via [GitHub Sponsors](https://github.com/sponsors/julienld). Thank you! ☕
315
+ - **[@griffinmartin](https://github.com/griffinmartin)** — Added OpenCode (by Anomaly) as a selectable AI client in the setup wizard, with both stdio and streamable HTTP support.
316
+ - **[@hhopke](https://github.com/hhopke)** — Fixed addon API calls to route through HA Core ingress proxy instead of direct container connections, fixing `ha_manage_addon` proxy mode on addon installs.
315
317
 
316
318
  ---
317
319
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.4.1.dev442"
7
+ version = "7.4.1.dev444"
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"
@@ -19,6 +19,7 @@ from fastmcp.exceptions import ToolError
19
19
  from fastmcp.tools import tool
20
20
  from pydantic import Field
21
21
 
22
+ from ..client.rest_client import HomeAssistantAPIError
22
23
  from ..errors import ErrorCode, create_error_response
23
24
  from .helpers import (
24
25
  exception_to_structured_error,
@@ -126,6 +127,25 @@ def _handle_menu_step(
126
127
  return str(menu_choice)
127
128
 
128
129
 
130
+ def _extract_schema_field_names(data_schema: Any) -> set[str] | None:
131
+ """Extract the set of field names declared by a step's data_schema.
132
+
133
+ HA returns data_schema as a list of {name, selector, required, ...} dicts.
134
+ Returns ``None`` when the schema is absent or not a list (signalling
135
+ the caller to fall back to legacy submit-all behaviour). Returns a
136
+ (possibly empty) set when the schema is present and parseable.
137
+ """
138
+ if not isinstance(data_schema, list):
139
+ return None
140
+ names: set[str] = set()
141
+ for field in data_schema:
142
+ if isinstance(field, dict):
143
+ name = field.get("name")
144
+ if isinstance(name, str):
145
+ names.add(name)
146
+ return names
147
+
148
+
129
149
  def _handle_form_step(
130
150
  flow_id: str,
131
151
  current_step: dict[str, Any],
@@ -133,8 +153,16 @@ def _handle_form_step(
133
153
  ) -> dict[str, Any]:
134
154
  """Validate a form step and return form data to submit.
135
155
 
136
- Raises ToolError on validation errors. Returns the filtered form data
137
- (menu selection keys stripped).
156
+ When the step's ``data_schema`` is provided, pops ONLY the keys declared
157
+ in that schema from ``remaining_config`` (mutating it) so any unconsumed
158
+ keys remain available for subsequent steps. Menu selection keys are never
159
+ submitted.
160
+
161
+ When ``data_schema`` is absent (HA didn't tell us field names), falls
162
+ back to legacy behaviour: submit all non-menu keys and clear them. This
163
+ keeps single-step flows working when HA omits the schema.
164
+
165
+ Raises ToolError on validation errors.
138
166
  """
139
167
  if current_step.get("errors"):
140
168
  raise_tool_error(create_error_response(
@@ -149,19 +177,220 @@ def _handle_form_step(
149
177
  },
150
178
  ))
151
179
 
180
+ schema_fields = _extract_schema_field_names(current_step.get("data_schema"))
181
+
182
+ form_data: dict[str, Any] = {}
183
+ if schema_fields is None:
184
+ # Legacy fallback: no schema info — dump every non-menu key and
185
+ # consume them all so a follow-up step (rare without schema) won't
186
+ # re-submit the same data.
187
+ for key in list(remaining_config.keys()):
188
+ if key in _MENU_SELECTION_KEYS:
189
+ continue
190
+ form_data[key] = remaining_config.pop(key)
191
+ else:
192
+ for key in list(remaining_config.keys()):
193
+ if key in _MENU_SELECTION_KEYS:
194
+ continue
195
+ if key in schema_fields:
196
+ form_data[key] = remaining_config.pop(key)
197
+
198
+ return form_data
199
+
200
+
201
+ def _parse_flow_api_error(
202
+ api_error: HomeAssistantAPIError,
203
+ ) -> dict[str, Any]:
204
+ """Extract structured field-level info from an HA flow 4xx response.
205
+
206
+ Home Assistant returns voluptuous validation failures during flow
207
+ submission as either:
208
+
209
+ - ``{"message": "User input malformed: extra keys not allowed @ data['name']"}``
210
+ (raised before form validation, e.g. unknown field in payload)
211
+ - ``{"errors": {"base": "..."}, "description_placeholders": {...}}``
212
+ (per-field errors after voluptuous validation succeeds)
213
+ - Free-form text (when the body isn't JSON).
214
+
215
+ Returns a dict with at least:
216
+ - ``message``: the most informative human-readable string we found.
217
+ - ``field_errors``: dict of field-name -> error code/message, when
218
+ the body contained an ``errors`` map. Empty dict otherwise.
219
+ - ``raw``: the response_data dict (or ``None``) for diagnostics.
220
+ """
221
+ body = api_error.response_data or {}
222
+ field_errors: dict[str, Any] = {}
223
+ message_parts: list[str] = []
224
+
225
+ if isinstance(body, dict):
226
+ errors_field = body.get("errors")
227
+ if isinstance(errors_field, dict):
228
+ field_errors = {
229
+ key: val
230
+ for key, val in errors_field.items()
231
+ if isinstance(key, str)
232
+ }
233
+
234
+ # HA's stock 400 carries a `message` key with the voluptuous detail.
235
+ msg = body.get("message")
236
+ if isinstance(msg, str) and msg.strip():
237
+ message_parts.append(msg.strip())
238
+
239
+ # description_placeholders sometimes carry the human-readable error.
240
+ placeholders = body.get("description_placeholders")
241
+ if isinstance(placeholders, dict):
242
+ for key, val in placeholders.items():
243
+ if isinstance(val, str) and val.strip():
244
+ message_parts.append(f"{key}: {val.strip()}")
245
+
246
+ if not message_parts:
247
+ # Fall back to the wrapper exception message ("API error: 400 - ...").
248
+ message_parts.append(str(api_error))
249
+
152
250
  return {
153
- k: v
154
- for k, v in remaining_config.items()
155
- if k not in _MENU_SELECTION_KEYS
251
+ "message": " | ".join(dict.fromkeys(message_parts)), # de-dupe, preserve order
252
+ "field_errors": field_errors,
253
+ "raw": body if isinstance(body, dict) else None,
156
254
  }
157
255
 
158
256
 
257
+ async def _fetch_data_schema_for_error_context(
258
+ client: Any,
259
+ helper_type: str | None,
260
+ menu_choice: str | None,
261
+ ) -> list[Any] | None:
262
+ """Best-effort fetch of the helper's data_schema for error context.
263
+
264
+ Starts a fresh introspection flow (always aborted), and returns the
265
+ user step's ``data_schema`` so the LLM has something concrete to react
266
+ to when HA's error body is unstructured. Returns ``None`` on any
267
+ failure or when the helper is menu-based without a chosen branch.
268
+ """
269
+ if not helper_type or client is None:
270
+ return None
271
+ intro_flow_id: str | None = None
272
+ try:
273
+ flow_result = await client.start_config_flow(helper_type)
274
+ intro_flow_id = flow_result.get("flow_id")
275
+ flow_type = flow_result.get("type")
276
+
277
+ if flow_type == _FlowType.FORM:
278
+ schema = flow_result.get("data_schema")
279
+ return schema if isinstance(schema, list) else None
280
+
281
+ if flow_type == _FlowType.MENU and menu_choice and intro_flow_id:
282
+ try:
283
+ step = await asyncio.wait_for(
284
+ client.submit_config_flow_step(
285
+ intro_flow_id, {"next_step_id": menu_choice}
286
+ ),
287
+ timeout=10.0,
288
+ )
289
+ except Exception:
290
+ return None
291
+ if step.get("type") == _FlowType.FORM:
292
+ schema = step.get("data_schema")
293
+ return schema if isinstance(schema, list) else None
294
+ return None
295
+ except Exception:
296
+ return None
297
+ finally:
298
+ if intro_flow_id:
299
+ try:
300
+ await asyncio.wait_for(
301
+ client.abort_config_flow(intro_flow_id), timeout=5.0
302
+ )
303
+ except Exception as abort_err:
304
+ logger.debug(
305
+ f"Failed to abort introspection flow {intro_flow_id}: {abort_err}"
306
+ )
307
+
308
+
309
+ async def _raise_flow_api_error(
310
+ api_error: HomeAssistantAPIError,
311
+ *,
312
+ client: Any,
313
+ flow_id: str,
314
+ helper_type: str | None,
315
+ menu_choice: str | None,
316
+ current_step: dict[str, Any] | None,
317
+ submitted: dict[str, Any] | None,
318
+ ) -> None:
319
+ """Translate an HA 4xx during a flow submit into a structured ToolError.
320
+
321
+ For 400/422 responses, parses ``response_data`` for field-level info
322
+ via ``_parse_flow_api_error``. When the body is unstructured (no
323
+ ``errors`` map), attaches the helper's ``data_schema`` (if it can be
324
+ fetched) so the caller has actionable information.
325
+
326
+ Always raises ``ToolError`` — never returns.
327
+ """
328
+ parsed = _parse_flow_api_error(api_error)
329
+ field_errors = parsed["field_errors"]
330
+ status_code = api_error.status_code or 0
331
+
332
+ context: dict[str, Any] = {
333
+ "flow_id": flow_id,
334
+ "status_code": status_code,
335
+ }
336
+ if helper_type:
337
+ context["helper_type"] = helper_type
338
+ if menu_choice:
339
+ context["menu_choice"] = menu_choice
340
+ if current_step is not None:
341
+ context["step_id"] = current_step.get("step_id")
342
+ if submitted is not None:
343
+ context["submitted_keys"] = sorted(submitted.keys())
344
+ if parsed["raw"] is not None:
345
+ context["response_body"] = parsed["raw"]
346
+
347
+ suggestions: list[str] = []
348
+ message: str
349
+
350
+ if field_errors:
351
+ # Structured field errors — tell the caller which fields failed.
352
+ context["field_errors"] = field_errors
353
+ readable = ", ".join(f"{k}: {v}" for k, v in field_errors.items())
354
+ message = f"Helper validation failed — {readable}"
355
+ suggestions.append(
356
+ "Fix the field(s) listed in 'field_errors' and retry the call."
357
+ )
358
+ else:
359
+ # Unstructured — attach the data_schema so the LLM has something to use.
360
+ message = (
361
+ f"Home Assistant rejected the {helper_type or 'flow'} request "
362
+ f"({status_code}): {parsed['message']}"
363
+ )
364
+ schema = await _fetch_data_schema_for_error_context(
365
+ client, helper_type, menu_choice
366
+ )
367
+ if schema is not None:
368
+ context["data_schema"] = schema
369
+ suggestions.append(
370
+ "Inspect 'data_schema' in this error to see the fields HA expects, "
371
+ "then retry with a corrected config."
372
+ )
373
+ suggestions.append(
374
+ f"Call ha_get_helper_schema(helper_type='{helper_type}') for the "
375
+ f"full field list and selectors." if helper_type else
376
+ "Call ha_get_helper_schema for this helper to see required fields."
377
+ )
378
+
379
+ raise_tool_error(create_error_response(
380
+ ErrorCode.SERVICE_CALL_FAILED,
381
+ message,
382
+ suggestions=suggestions,
383
+ context=context,
384
+ ))
385
+
386
+
159
387
  async def _handle_flow_steps(
160
388
  client: Any,
161
389
  flow_id: str,
162
390
  initial_step: dict[str, Any],
163
391
  config: dict[str, Any],
164
392
  submit_fn: Any = None,
393
+ helper_type: str | None = None,
165
394
  ) -> dict[str, Any]:
166
395
  """Walk a multi-step config flow handling menu and form steps (max 10 steps).
167
396
 
@@ -181,6 +410,9 @@ async def _handle_flow_steps(
181
410
  submit_fn: Async function to submit a step. Defaults to
182
411
  client.submit_config_flow_step (create). Pass
183
412
  client.submit_options_flow_step for options (update) flows.
413
+ helper_type: Optional helper type (e.g. ``"statistics"``). When
414
+ provided, surfaces the helper's data_schema in error context
415
+ for unstructured HA 4xx responses so the caller can react.
184
416
 
185
417
  Returns:
186
418
  ``{"success": True, "entry": result}`` on success.
@@ -190,6 +422,7 @@ async def _handle_flow_steps(
190
422
  submit_fn = client.submit_config_flow_step
191
423
  remaining_config = dict(config)
192
424
  current_step = initial_step
425
+ last_menu_choice: str | None = None
193
426
  max_steps = 10
194
427
 
195
428
  for step_num in range(max_steps):
@@ -207,27 +440,57 @@ async def _handle_flow_steps(
207
440
 
208
441
  if result_type == _FlowType.MENU:
209
442
  menu_choice = _handle_menu_step(flow_id, current_step, remaining_config)
443
+ last_menu_choice = menu_choice
210
444
  logger.debug(
211
445
  f"Flow step {step_num}: menu '{menu_choice}' "
212
446
  f"(step_id={current_step.get('step_id')})"
213
447
  )
214
- current_step = await asyncio.wait_for(
215
- submit_fn(flow_id, {"next_step_id": menu_choice}),
216
- timeout=20.0,
217
- )
448
+ menu_payload = {"next_step_id": menu_choice}
449
+ try:
450
+ current_step = await asyncio.wait_for(
451
+ submit_fn(flow_id, menu_payload),
452
+ timeout=20.0,
453
+ )
454
+ except HomeAssistantAPIError as api_err:
455
+ if api_err.status_code in (400, 422):
456
+ await _raise_flow_api_error(
457
+ api_err,
458
+ client=client,
459
+ flow_id=flow_id,
460
+ helper_type=helper_type,
461
+ menu_choice=last_menu_choice,
462
+ current_step=current_step,
463
+ submitted=menu_payload,
464
+ )
465
+ raise
218
466
 
219
467
  elif result_type == _FlowType.FORM:
468
+ # _handle_form_step pops only the keys declared in the current
469
+ # step's data_schema, leaving any other keys in remaining_config
470
+ # for subsequent steps (HA can present multi-step forms, e.g.
471
+ # statistics: user step then pick-characteristic step).
220
472
  form_data = _handle_form_step(flow_id, current_step, remaining_config)
221
473
  logger.debug(
222
474
  f"Flow step {step_num}: form submit "
223
475
  f"(step_id={current_step.get('step_id')}, keys={list(form_data.keys())})"
224
476
  )
225
- current_step = await asyncio.wait_for(
226
- submit_fn(flow_id, form_data),
227
- timeout=20.0,
228
- )
229
- # Clear so subsequent steps don't re-submit the same data.
230
- remaining_config = {}
477
+ try:
478
+ current_step = await asyncio.wait_for(
479
+ submit_fn(flow_id, form_data),
480
+ timeout=20.0,
481
+ )
482
+ except HomeAssistantAPIError as api_err:
483
+ if api_err.status_code in (400, 422):
484
+ await _raise_flow_api_error(
485
+ api_err,
486
+ client=client,
487
+ flow_id=flow_id,
488
+ helper_type=helper_type,
489
+ menu_choice=last_menu_choice,
490
+ current_step=current_step,
491
+ submitted=form_data,
492
+ )
493
+ raise
231
494
 
232
495
  else:
233
496
  raise_tool_error(create_error_response(
@@ -243,6 +506,47 @@ async def _handle_flow_steps(
243
506
  ))
244
507
 
245
508
 
509
+ async def get_user_step_field_names(
510
+ client: Any, helper_type: str
511
+ ) -> set[str] | None:
512
+ """Return field names in the user-step form schema for ``helper_type``.
513
+
514
+ Starts a config flow, peeks at the initial step's ``data_schema``,
515
+ and immediately aborts the flow. Used to decide whether to fold the
516
+ top-level ``name`` parameter into the form payload — some helpers
517
+ (e.g. ``switch_as_x``) take their entity name from the source switch
518
+ and reject ``name`` as an extra key.
519
+
520
+ Returns:
521
+ A set of field names if the initial step is a form. ``None`` if
522
+ the flow type is not introspectable from the top step (menu or
523
+ unexpected) — callers should fall back to the legacy behaviour
524
+ in that case to avoid regressing menu helpers (template, group).
525
+ Also returns ``None`` if the introspection itself fails; the
526
+ subsequent real flow will surface the error in context.
527
+ """
528
+ flow_id = None
529
+ try:
530
+ flow_result = await client.start_config_flow(helper_type)
531
+ flow_id = flow_result.get("flow_id")
532
+ if flow_result.get("type") != _FlowType.FORM:
533
+ return None
534
+ return _extract_schema_field_names(flow_result.get("data_schema"))
535
+ except Exception as e:
536
+ logger.debug(f"Schema introspection failed for {helper_type}: {e}")
537
+ return None
538
+ finally:
539
+ if flow_id:
540
+ try:
541
+ await asyncio.wait_for(
542
+ client.abort_config_flow(flow_id), timeout=5.0
543
+ )
544
+ except Exception as abort_err:
545
+ logger.warning(
546
+ f"Failed to abort introspection flow {flow_id}: {abort_err}"
547
+ )
548
+
549
+
246
550
  async def update_flow_helper(
247
551
  client: Any,
248
552
  helper_type: str,
@@ -281,6 +585,7 @@ async def update_flow_helper(
281
585
  result = await _handle_flow_steps(
282
586
  client, flow_id, flow_result, config_dict,
283
587
  submit_fn=client.submit_options_flow_step,
588
+ helper_type=helper_type,
284
589
  )
285
590
  except Exception:
286
591
  try:
@@ -322,7 +627,10 @@ async def create_flow_helper(
322
627
  ))
323
628
 
324
629
  try:
325
- result = await _handle_flow_steps(client, flow_id, flow_result, config_dict)
630
+ result = await _handle_flow_steps(
631
+ client, flow_id, flow_result, config_dict,
632
+ helper_type=helper_type,
633
+ )
326
634
  except Exception:
327
635
  try:
328
636
  await asyncio.wait_for(client.abort_config_flow(flow_id), timeout=5.0)