ha-mcp-dev 7.4.1.dev443__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.
- {ha_mcp_dev-7.4.1.dev443/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev444}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_entry_flow.py +324 -16
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_helpers.py +1144 -148
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.4.1.
|
|
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"
|
{ha_mcp_dev-7.4.1.dev443 → ha_mcp_dev-7.4.1.dev444}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
@@ -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
|
-
|
|
137
|
-
(
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
if
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
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)
|