ha-mcp-dev 7.13.0.dev1987__tar.gz → 7.13.0.dev1990__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.13.0.dev1987/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.13.0.dev1990}/PKG-INFO +1 -1
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/pyproject.toml +1 -1
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/rest_client.py +18 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/websocket_client.py +14 -1
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/device_control.py +493 -13
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_service.py +376 -11
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/util_helpers.py +20 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/operation_manager.py +27 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/LICENSE +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/README.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/setup.cfg +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_i18n.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_theme.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/README.md +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/de.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/en.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/strict_bps.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_api.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_config_reads.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_devices.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_registries.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/persistence.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/resolver.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/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.13.0.
|
|
7
|
+
version = "7.13.0.dev1990"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
# Upper bound widened to <3.15 so the package installs on Home Assistant's
|
|
@@ -48,6 +48,24 @@ class HomeAssistantConnectionError(HomeAssistantError):
|
|
|
48
48
|
"""Connection error to Home Assistant."""
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class HomeAssistantCommandNotSent(HomeAssistantConnectionError):
|
|
52
|
+
"""A WS command that provably never left the process.
|
|
53
|
+
|
|
54
|
+
Raised by ``HomeAssistantWebSocketClient.send_command`` ONLY at its single
|
|
55
|
+
provably-never-sent site: the entry-guard reject (socket not authenticated), where
|
|
56
|
+
nothing is transmitted. Subclass of ``HomeAssistantConnectionError`` so every
|
|
57
|
+
existing broad handler is unaffected, yet a write consumer can catch this type
|
|
58
|
+
FIRST to fall back to the legacy path safely — the write provably never happened,
|
|
59
|
+
so a legacy first fire cannot double-apply. A ``send_json_message`` failure is NOT
|
|
60
|
+
this type: ``websocket.send()`` raising does not prove the frame was untransmitted
|
|
61
|
+
(bytes may already be on the socket when the close surfaces), so send_command
|
|
62
|
+
re-raises the original exception and the consumer treats it as ambiguous. A
|
|
63
|
+
post-send socket close (mid-await) likewise raises a plain
|
|
64
|
+
``HomeAssistantConnectionError`` (the close handler sets it on the pending future),
|
|
65
|
+
so type alone distinguishes never-sent from sent-then-dropped/ambiguous.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
51
69
|
class HomeAssistantAuthError(HomeAssistantError):
|
|
52
70
|
"""Authentication error with Home Assistant.
|
|
53
71
|
|
{ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/websocket_client.py
RENAMED
|
@@ -23,6 +23,7 @@ import websockets
|
|
|
23
23
|
from ..config import get_global_settings
|
|
24
24
|
from .rest_client import (
|
|
25
25
|
HomeAssistantCommandError,
|
|
26
|
+
HomeAssistantCommandNotSent,
|
|
26
27
|
HomeAssistantCommandTimeout,
|
|
27
28
|
HomeAssistantConnectionError,
|
|
28
29
|
_is_ssl_error,
|
|
@@ -616,7 +617,12 @@ class HomeAssistantWebSocketClient:
|
|
|
616
617
|
Response from Home Assistant
|
|
617
618
|
"""
|
|
618
619
|
if not self._state.is_ready:
|
|
619
|
-
|
|
620
|
+
# PRE-SEND and the ONLY provably-never-sent site: nothing is transmitted at
|
|
621
|
+
# this entry guard. Raise the never-sent subtype so an at-most-once write
|
|
622
|
+
# consumer can fall back to legacy safely (a subclass of
|
|
623
|
+
# HomeAssistantConnectionError, so every existing broad handler is
|
|
624
|
+
# unaffected). A later send() failure is NOT never-sent (see below).
|
|
625
|
+
raise HomeAssistantCommandNotSent("WebSocket not authenticated")
|
|
620
626
|
|
|
621
627
|
# Pull the wait timeout out of kwargs rather than making it a positional
|
|
622
628
|
# parameter: callers unpack a ``dict[str, object]`` via
|
|
@@ -635,6 +641,13 @@ class HomeAssistantWebSocketClient:
|
|
|
635
641
|
try:
|
|
636
642
|
await self.send_json_message(message)
|
|
637
643
|
except Exception:
|
|
644
|
+
# AMBIGUOUS, not never-sent: websocket.send() raising (e.g. a
|
|
645
|
+
# ConnectionClosed detected mid-write) does NOT prove the frame was not
|
|
646
|
+
# transmitted — bytes may already be on the socket when the close surfaces.
|
|
647
|
+
# Re-raise the ORIGINAL exception unchanged so an at-most-once write
|
|
648
|
+
# consumer treats it like a post-send drop (ambiguous -> partial, never
|
|
649
|
+
# re-fired), NOT as never-sent; only the readiness guard above is provably
|
|
650
|
+
# never-sent. Still cancel the pending future so it cannot leak.
|
|
638
651
|
self.cancel_pending_response(message_id)
|
|
639
652
|
raise
|
|
640
653
|
|
|
@@ -14,21 +14,45 @@ from typing import Any, ClassVar
|
|
|
14
14
|
from fastmcp import Context
|
|
15
15
|
from fastmcp.exceptions import ToolError
|
|
16
16
|
|
|
17
|
-
from ..client.rest_client import
|
|
17
|
+
from ..client.rest_client import (
|
|
18
|
+
HomeAssistantClient,
|
|
19
|
+
HomeAssistantCommandError,
|
|
20
|
+
HomeAssistantCommandNotSent,
|
|
21
|
+
)
|
|
22
|
+
from ..client.websocket_client import get_websocket_client
|
|
18
23
|
from ..client.websocket_listener import start_websocket_listener
|
|
19
24
|
from ..config import get_global_settings
|
|
20
25
|
from ..errors import ErrorCode, create_error_response
|
|
21
26
|
from ..utils.domain_handlers import get_domain_handler
|
|
22
|
-
from ..utils.operation_manager import
|
|
27
|
+
from ..utils.operation_manager import (
|
|
28
|
+
fail_pending_operation,
|
|
29
|
+
get_operation_from_memory,
|
|
30
|
+
store_pending_operation,
|
|
31
|
+
)
|
|
32
|
+
from .component_api import (
|
|
33
|
+
component_supports,
|
|
34
|
+
get_component_caps,
|
|
35
|
+
invalidate_caps,
|
|
36
|
+
is_unknown_command,
|
|
37
|
+
)
|
|
23
38
|
from .helpers import (
|
|
24
39
|
exception_to_structured_error,
|
|
25
40
|
raise_tool_error,
|
|
26
41
|
safe_info,
|
|
27
42
|
safe_progress,
|
|
28
43
|
)
|
|
44
|
+
from .util_helpers import _SERVICE_TO_STATE
|
|
29
45
|
|
|
30
46
|
logger = logging.getLogger(__name__)
|
|
31
47
|
|
|
48
|
+
# The ha_mcp_tools/bulk_call_service WS command (Phase 3, D5a): the BATCH write
|
|
49
|
+
# capability. When the component advertises ``bulk_call_service`` the consumer
|
|
50
|
+
# resolves every op's domain/service server-side (D6), sends one register-before-fire
|
|
51
|
+
# batch frame, and maps the inline-confirmed per-op transitions back into the legacy
|
|
52
|
+
# bulk response shape — no operation-id polling needed. Named once so the routing
|
|
53
|
+
# helper and its tests agree on the wire string.
|
|
54
|
+
WS_BULK_CALL_SERVICE = "ha_mcp_tools/bulk_call_service"
|
|
55
|
+
|
|
32
56
|
|
|
33
57
|
class DeviceControlTools:
|
|
34
58
|
"""Smart device control tools with async verification."""
|
|
@@ -139,6 +163,22 @@ class DeviceControlTools:
|
|
|
139
163
|
current_state if validate_first else None, action, parameters, domain
|
|
140
164
|
)
|
|
141
165
|
|
|
166
|
+
# Register the pending operation BEFORE dispatching the service so a
|
|
167
|
+
# fast entity's state_changed event can't arrive (and be dropped for
|
|
168
|
+
# having no matching op) in the window between the call returning and
|
|
169
|
+
# the operation being stored. On a dispatch failure the operation is
|
|
170
|
+
# flipped to FAILED so it can't be spuriously completed by a later,
|
|
171
|
+
# unrelated event for the same entity.
|
|
172
|
+
operation_id = store_pending_operation(
|
|
173
|
+
entity_id=entity_id,
|
|
174
|
+
action=action,
|
|
175
|
+
service_domain=service_call["domain"],
|
|
176
|
+
service_name=service_call["service"],
|
|
177
|
+
service_data=service_call["data"],
|
|
178
|
+
expected_state=expected_state,
|
|
179
|
+
timeout_ms=timeout_seconds * 1000,
|
|
180
|
+
)
|
|
181
|
+
|
|
142
182
|
# Execute service call
|
|
143
183
|
try:
|
|
144
184
|
await self.client.call_service(
|
|
@@ -147,17 +187,6 @@ class DeviceControlTools:
|
|
|
147
187
|
service_call["data"],
|
|
148
188
|
)
|
|
149
189
|
|
|
150
|
-
# Store operation for async verification
|
|
151
|
-
operation_id = store_pending_operation(
|
|
152
|
-
entity_id=entity_id,
|
|
153
|
-
action=action,
|
|
154
|
-
service_domain=service_call["domain"],
|
|
155
|
-
service_name=service_call["service"],
|
|
156
|
-
service_data=service_call["data"],
|
|
157
|
-
expected_state=expected_state,
|
|
158
|
-
timeout_ms=timeout_seconds * 1000,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
190
|
return {
|
|
162
191
|
"entity_id": entity_id,
|
|
163
192
|
"action": action,
|
|
@@ -179,8 +208,10 @@ class DeviceControlTools:
|
|
|
179
208
|
}
|
|
180
209
|
|
|
181
210
|
except ToolError:
|
|
211
|
+
fail_pending_operation(operation_id, "Service dispatch failed")
|
|
182
212
|
raise
|
|
183
213
|
except Exception as e:
|
|
214
|
+
fail_pending_operation(operation_id, f"Service dispatch failed: {e}")
|
|
184
215
|
exception_to_structured_error(
|
|
185
216
|
e,
|
|
186
217
|
context={"entity_id": entity_id, "action": action},
|
|
@@ -624,6 +655,35 @@ class DeviceControlTools:
|
|
|
624
655
|
f"{len(skipped_operations)} skipped, "
|
|
625
656
|
f"mode={'parallel' if parallel else 'sequential'}",
|
|
626
657
|
)
|
|
658
|
+
|
|
659
|
+
# Route through the component's bulk_call_service capability when
|
|
660
|
+
# advertised (D5a): one register-before-fire batch frame confirms every
|
|
661
|
+
# op inline, so ops return already-verified (no operation-id polling). A
|
|
662
|
+
# None return means nothing dispatched (capability miss / unresolvable op
|
|
663
|
+
# / transport failure), so the legacy path below is a safe first fire
|
|
664
|
+
# (D9 at-most-once).
|
|
665
|
+
component_response = await self._bulk_via_component(
|
|
666
|
+
operations, valid_operations, skipped_operations, parallel
|
|
667
|
+
)
|
|
668
|
+
if component_response is not None:
|
|
669
|
+
# M-progress-msg: the ambiguous batch response carries a top-level
|
|
670
|
+
# ``partial`` (dispatched but the batch confirmation never arrived — not
|
|
671
|
+
# retried); only a fully-served batch is "confirmed inline". The legacy
|
|
672
|
+
# ``_build_bulk_response`` never sets a top-level ``partial``, so this
|
|
673
|
+
# flag reliably marks the ambiguous path.
|
|
674
|
+
component_message = (
|
|
675
|
+
"dispatched via component (unconfirmed — not retried)"
|
|
676
|
+
if component_response.get("partial")
|
|
677
|
+
else "dispatched via component (confirmed inline)"
|
|
678
|
+
)
|
|
679
|
+
await safe_progress(
|
|
680
|
+
ctx,
|
|
681
|
+
progress=len(valid_operations),
|
|
682
|
+
total=len(valid_operations),
|
|
683
|
+
message=component_message,
|
|
684
|
+
)
|
|
685
|
+
return component_response
|
|
686
|
+
|
|
627
687
|
await safe_progress(
|
|
628
688
|
ctx,
|
|
629
689
|
progress=0,
|
|
@@ -664,6 +724,426 @@ class DeviceControlTools:
|
|
|
664
724
|
)
|
|
665
725
|
raise # unreachable: exception_to_structured_error always raises
|
|
666
726
|
|
|
727
|
+
def _resolve_component_op(
|
|
728
|
+
self,
|
|
729
|
+
entity_id: str,
|
|
730
|
+
action: str,
|
|
731
|
+
parameters: Any,
|
|
732
|
+
) -> dict[str, Any] | None:
|
|
733
|
+
"""Resolve one bulk op into a fully-formed component operation row.
|
|
734
|
+
|
|
735
|
+
Verb resolution stays server-side (D6): reuses the SAME
|
|
736
|
+
``get_domain_handler`` / ``_resolve_service_name`` / ``_build_service_call``
|
|
737
|
+
the legacy path uses, so the component receives only a fully-resolved
|
|
738
|
+
``{domain, service, service_data, entity_ids, expected_state}`` row and never
|
|
739
|
+
guesses a service name. ``entity_ids`` is the confirmation target;
|
|
740
|
+
``service_data`` already folds in the entity_id so the dispatch targets it;
|
|
741
|
+
``expected_state`` is the per-op confirmation hint (``_SERVICE_TO_STATE``).
|
|
742
|
+
|
|
743
|
+
Returns ``None`` for any op the legacy path would reject with a structured
|
|
744
|
+
per-op error (a malformed entity_id, an action outside the domain handler, or
|
|
745
|
+
unparseable parameters). A ``None`` aborts the WHOLE batch back to the legacy
|
|
746
|
+
path (nothing has dispatched yet), which then produces the proper per-op error
|
|
747
|
+
— cleaner than half-resolving a batch.
|
|
748
|
+
"""
|
|
749
|
+
try:
|
|
750
|
+
if "." not in entity_id:
|
|
751
|
+
return None
|
|
752
|
+
domain = entity_id.split(".")[0]
|
|
753
|
+
handler = get_domain_handler(domain)
|
|
754
|
+
valid_actions = handler.get("valid_actions", ["on", "off", "toggle"])
|
|
755
|
+
if action not in valid_actions:
|
|
756
|
+
return None
|
|
757
|
+
parsed = self._parse_parameters(parameters, entity_id, action)
|
|
758
|
+
service_call = self._build_service_call(entity_id, domain, action, parsed)
|
|
759
|
+
except Exception:
|
|
760
|
+
# Any resolution failure (incl. a ToolError from _parse_parameters on
|
|
761
|
+
# invalid JSON) → abort the batch to legacy, which surfaces the error.
|
|
762
|
+
return None
|
|
763
|
+
return {
|
|
764
|
+
"domain": service_call["domain"],
|
|
765
|
+
"service": service_call["service"],
|
|
766
|
+
"service_data": service_call["data"],
|
|
767
|
+
"entity_ids": [entity_id],
|
|
768
|
+
# Confirmation HINT (see util_helpers._SERVICE_TO_STATE): the expected
|
|
769
|
+
# primary state after this op's service, or None. The component confirms
|
|
770
|
+
# only on REACHING it (skipping intermediate/noise events) and immediate-
|
|
771
|
+
# matches an idempotent no-op; a None hint keeps any-first-event behavior.
|
|
772
|
+
"expected_state": _SERVICE_TO_STATE.get(service_call["service"]),
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
def _map_component_op_result(
|
|
776
|
+
self,
|
|
777
|
+
op: dict[str, Any],
|
|
778
|
+
entity_id: str,
|
|
779
|
+
action: str,
|
|
780
|
+
row: dict[str, Any],
|
|
781
|
+
component_op: dict[str, Any],
|
|
782
|
+
) -> dict[str, Any]:
|
|
783
|
+
"""Map one component op result into the legacy per-op result shape.
|
|
784
|
+
|
|
785
|
+
The component confirms inline, so the op returns already-verified with a
|
|
786
|
+
synthesized terminal status instead of the legacy ``pending_verification`` +
|
|
787
|
+
operation-id handle. A ``command_sent: True`` result is counted ``successful``
|
|
788
|
+
by ``_build_bulk_response`` exactly as the legacy dispatch result is; an op
|
|
789
|
+
whose ``async_call`` raised under the batch (``error`` set / not dispatched)
|
|
790
|
+
maps to a structured ``SERVICE_CALL_FAILED`` error and is NOT re-dispatched
|
|
791
|
+
(D9). When ``validate_first`` (the default) is set and the target's captured
|
|
792
|
+
pre-state is null (the entity does not exist), the op maps to a structured
|
|
793
|
+
``ENTITY_NOT_FOUND`` failure — parity with the legacy per-op validation.
|
|
794
|
+
"""
|
|
795
|
+
service_call = {
|
|
796
|
+
"domain": row["domain"],
|
|
797
|
+
"service": row["service"],
|
|
798
|
+
"data": row["service_data"],
|
|
799
|
+
}
|
|
800
|
+
if component_op.get("error") is not None or not component_op.get("dispatched"):
|
|
801
|
+
err = create_error_response(
|
|
802
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
803
|
+
str(component_op.get("error") or "Operation failed to dispatch"),
|
|
804
|
+
context={"entity_id": entity_id, "action": action},
|
|
805
|
+
)
|
|
806
|
+
err["service_call"] = service_call
|
|
807
|
+
return err
|
|
808
|
+
transitions = component_op.get("transitions") or []
|
|
809
|
+
# I3: honor validate_first (default). The component captured each target's
|
|
810
|
+
# pre-state; a null old_state on a confirmable op means the entity does not
|
|
811
|
+
# exist (HA no-ops the dispatch, then the wait stalls to partial). The legacy
|
|
812
|
+
# path returns a structured ENTITY_NOT_FOUND per-op failure — match it from
|
|
813
|
+
# the pre-state already in the transition (no extra hops), rather than
|
|
814
|
+
# counting a phantom entity as a successful command. Reachability is
|
|
815
|
+
# drift-bounded: a missing transition row from a length-drifted batch now routes
|
|
816
|
+
# to the ambiguous path (I2) before reaching here, so a null old_state here
|
|
817
|
+
# means a genuinely absent entity, not a dropped row.
|
|
818
|
+
if op.get("validate_first", True) and row.get("entity_ids"):
|
|
819
|
+
old_state = next(
|
|
820
|
+
(
|
|
821
|
+
t.get("old_state")
|
|
822
|
+
for t in transitions
|
|
823
|
+
if isinstance(t, dict) and t.get("entity_id") == entity_id
|
|
824
|
+
),
|
|
825
|
+
None,
|
|
826
|
+
)
|
|
827
|
+
if old_state is None:
|
|
828
|
+
err = create_error_response(
|
|
829
|
+
ErrorCode.ENTITY_NOT_FOUND,
|
|
830
|
+
f"Entity not found: {entity_id}",
|
|
831
|
+
suggestions=[
|
|
832
|
+
"Use ha_search to find the correct entity",
|
|
833
|
+
"Check the entity is not disabled in Home Assistant",
|
|
834
|
+
],
|
|
835
|
+
context={"entity_id": entity_id, "action": action},
|
|
836
|
+
)
|
|
837
|
+
err["service_call"] = service_call
|
|
838
|
+
return err
|
|
839
|
+
final_state = next(
|
|
840
|
+
(
|
|
841
|
+
t["new_state"].get("state")
|
|
842
|
+
for t in transitions
|
|
843
|
+
if isinstance(t, dict) and isinstance(t.get("new_state"), dict)
|
|
844
|
+
),
|
|
845
|
+
None,
|
|
846
|
+
)
|
|
847
|
+
confirmed = bool(component_op.get("confirmed"))
|
|
848
|
+
partial = bool(component_op.get("partial"))
|
|
849
|
+
return {
|
|
850
|
+
"entity_id": entity_id,
|
|
851
|
+
"action": action,
|
|
852
|
+
"parameters": op.get("parameters") or {},
|
|
853
|
+
"command_sent": True,
|
|
854
|
+
# M-status: an inline-confirmed op has no operation_id, so a
|
|
855
|
+
# "pending_verification" status is misleading (nothing will poll it). Use
|
|
856
|
+
# "dispatched_unconfirmed" — the same label the ambiguous batch path uses.
|
|
857
|
+
"status": "completed" if confirmed else "dispatched_unconfirmed",
|
|
858
|
+
"confirmed": confirmed,
|
|
859
|
+
"partial": partial,
|
|
860
|
+
"final_state": final_state,
|
|
861
|
+
"transitions": transitions,
|
|
862
|
+
"service_call": service_call,
|
|
863
|
+
"verification_method": "component_state_change",
|
|
864
|
+
"message": (
|
|
865
|
+
f"{entity_id} {action} " + ("confirmed" if confirmed else "dispatched")
|
|
866
|
+
),
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
def _resolve_component_rows(
|
|
870
|
+
self,
|
|
871
|
+
valid_operations: list[tuple[int, dict[str, Any], str, str]],
|
|
872
|
+
) -> list[dict[str, Any]] | None:
|
|
873
|
+
"""Resolve every valid op into a component row (server-side, D6).
|
|
874
|
+
|
|
875
|
+
Returns ``None`` — abort the WHOLE batch to legacy BEFORE any dispatch — when
|
|
876
|
+
any op cannot be resolved (the legacy path then surfaces the proper per-op
|
|
877
|
+
error, and no partial batch lands) or when nothing resolvable remains (an
|
|
878
|
+
empty batch; the component's schema rejects it anyway).
|
|
879
|
+
"""
|
|
880
|
+
rows: list[dict[str, Any]] = []
|
|
881
|
+
for _idx, op, entity_id, action in valid_operations:
|
|
882
|
+
row = self._resolve_component_op(entity_id, action, op.get("parameters"))
|
|
883
|
+
if row is None:
|
|
884
|
+
return None
|
|
885
|
+
rows.append(row)
|
|
886
|
+
return rows or None
|
|
887
|
+
|
|
888
|
+
async def _bulk_via_component(
|
|
889
|
+
self,
|
|
890
|
+
operations: list[dict[str, Any]],
|
|
891
|
+
valid_operations: list[tuple[int, dict[str, Any], str, str]],
|
|
892
|
+
skipped_operations: list[dict[str, Any]],
|
|
893
|
+
parallel: bool,
|
|
894
|
+
) -> dict[str, Any] | None:
|
|
895
|
+
"""Route the batch through the component ``bulk_call_service`` capability.
|
|
896
|
+
|
|
897
|
+
Returns a legacy-shaped bulk response (built by the SAME
|
|
898
|
+
``_build_bulk_response`` the legacy path uses, with ``operation_ids`` empty
|
|
899
|
+
and ``follow_up`` None since ops are confirmed inline) when the component
|
|
900
|
+
advertises ``bulk_call_service`` and the frame lands, else ``None`` so the
|
|
901
|
+
caller runs the unchanged legacy operation-registry path.
|
|
902
|
+
|
|
903
|
+
**D9 (at-most-once, per batch).** The boundary is PRE-SEND vs POST-SEND:
|
|
904
|
+
|
|
905
|
+
* ``None`` (nothing dispatched → safe legacy first fire): a capability miss,
|
|
906
|
+
an op that could not be resolved server-side, an empty batch, a batch whose
|
|
907
|
+
entity_ids are not all distinct (the component's per-entity waiter cannot
|
|
908
|
+
confirm a repeated entity), an ``unknown_command`` (caps invalidated), a
|
|
909
|
+
connection-ESTABLISHMENT failure, a ``HomeAssistantCommandNotSent`` (the
|
|
910
|
+
frame provably never left the process — the send_command readiness guard),
|
|
911
|
+
or a command-ERROR response. A command error is pre-dispatch because the
|
|
912
|
+
batch's all-guards-first pass (D1 / ServiceNotFound) raises before ANY
|
|
913
|
+
``async_call`` AND the component's post-dispatch assembly is TOTAL (never
|
|
914
|
+
raises — I1), so no landed write can surface as a command error.
|
|
915
|
+
* A returned result means the batch dispatched: each op's own ``dispatched``
|
|
916
|
+
flag is authoritative and NOTHING is re-dispatched, even the ops that failed.
|
|
917
|
+
* A response-wait TIMEOUT, a post-send transport drop, OR a malformed/unusable
|
|
918
|
+
SUCCESS envelope (non-dict result or op-list length drift) is AMBIGUOUS for
|
|
919
|
+
the WHOLE batch frame (some/all ops may have landed; a success frame is built
|
|
920
|
+
only AFTER every ``async_call`` fired, and ``async_call`` under the batch is
|
|
921
|
+
unbounded). It returns a partial batch response — every op reported
|
|
922
|
+
dispatched-but-unconfirmed — and is NEVER re-dispatched via legacy (a
|
|
923
|
+
re-dispatch would double-fire every landed op, inverting ``toggle`` ops).
|
|
924
|
+
"""
|
|
925
|
+
caps = await get_component_caps(self.client)
|
|
926
|
+
if not component_supports(caps, "bulk_call_service"):
|
|
927
|
+
return None
|
|
928
|
+
|
|
929
|
+
rows = self._resolve_component_rows(valid_operations)
|
|
930
|
+
# PRE-SEND, route the WHOLE batch to legacy (nothing dispatched yet → safe first
|
|
931
|
+
# fire, D9) when either: an op could not be resolved server-side (``rows`` None),
|
|
932
|
+
# or the batch targets the same entity_id twice. The component keys ONE
|
|
933
|
+
# entity-keyed transition waiter per entity, so the first ``state_changed``
|
|
934
|
+
# satisfies every op for that entity — both would report the same
|
|
935
|
+
# transition/final_state (e.g. `light.a on` then `light.a off` both read as the
|
|
936
|
+
# first). The legacy sequential path confirms each op independently.
|
|
937
|
+
if rows is None or self._has_duplicate_entity_ids(valid_operations):
|
|
938
|
+
return None
|
|
939
|
+
|
|
940
|
+
# PRE-SEND: an establishment failure means the batch frame provably never
|
|
941
|
+
# reached the component → legacy is a safe first fire. Split from the send
|
|
942
|
+
# below so a POST-SEND failure is never misclassified as pre-send.
|
|
943
|
+
try:
|
|
944
|
+
ws = await get_websocket_client(
|
|
945
|
+
url=self.client.base_url, token=self.client.token
|
|
946
|
+
)
|
|
947
|
+
except Exception as exc:
|
|
948
|
+
logger.warning(
|
|
949
|
+
"%s establishment failed; falling back to legacy: %r",
|
|
950
|
+
WS_BULK_CALL_SERVICE,
|
|
951
|
+
exc,
|
|
952
|
+
)
|
|
953
|
+
return None
|
|
954
|
+
# ``send_command`` transmits the batch frame INSIDE itself, AFTER its readiness
|
|
955
|
+
# guard and the socket write — so exception TYPE marks the send boundary:
|
|
956
|
+
# ``HomeAssistantCommandNotSent`` is raised ONLY at the readiness guard (the one
|
|
957
|
+
# provably-never-sent site → safe legacy first fire). A command-ERROR response is
|
|
958
|
+
# pre-dispatch: the all-guards-first D1 / ServiceNotFound pass raises before any
|
|
959
|
+
# async_call AND the component's post-dispatch assembly is total (never raises),
|
|
960
|
+
# so a command error cannot carry a landed write → legacy is safe. A response-
|
|
961
|
+
# wait TIMEOUT, a send() that raised (bytes may already be on the socket), OR a
|
|
962
|
+
# post-send transport drop (a mid-await socket close raises plain
|
|
963
|
+
# ``HomeAssistantConnectionError``) is AMBIGUOUS for the WHOLE batch → report
|
|
964
|
+
# every op dispatched-but-unconfirmed, NEVER re-dispatch. ``frame_timeout``
|
|
965
|
+
# honors the per-op ``timeout_seconds`` legacy respects (M-timeout).
|
|
966
|
+
frame_timeout = self._bulk_frame_timeout(valid_operations)
|
|
967
|
+
try:
|
|
968
|
+
raw = await ws.send_command(
|
|
969
|
+
WS_BULK_CALL_SERVICE,
|
|
970
|
+
operations=rows,
|
|
971
|
+
parallel=parallel,
|
|
972
|
+
wait=True,
|
|
973
|
+
timeout=frame_timeout,
|
|
974
|
+
# Keep the response-wait comfortably above the batch confirmation wait
|
|
975
|
+
# so the component's own bounded wait (not the client) decides the
|
|
976
|
+
# partial cutoff, preserving the legacy 10s/30s margin at any timeout.
|
|
977
|
+
_wait_timeout=frame_timeout + 20.0,
|
|
978
|
+
)
|
|
979
|
+
except HomeAssistantCommandNotSent as exc:
|
|
980
|
+
# PRE-SEND: the batch frame provably never left the process. Nothing
|
|
981
|
+
# dispatched, so legacy is a safe first fire.
|
|
982
|
+
logger.warning(
|
|
983
|
+
"%s not sent; falling back to legacy: %r",
|
|
984
|
+
WS_BULK_CALL_SERVICE,
|
|
985
|
+
exc,
|
|
986
|
+
)
|
|
987
|
+
return None
|
|
988
|
+
except HomeAssistantCommandError as exc:
|
|
989
|
+
# unknown_command → invalidate the cached caps so the next call re-probes;
|
|
990
|
+
# any other command error → legacy (provably pre-dispatch per D9 above).
|
|
991
|
+
if is_unknown_command(exc):
|
|
992
|
+
invalidate_caps(self.client)
|
|
993
|
+
else:
|
|
994
|
+
logger.warning(
|
|
995
|
+
"%s command error; falling back to legacy: %r",
|
|
996
|
+
WS_BULK_CALL_SERVICE,
|
|
997
|
+
exc,
|
|
998
|
+
)
|
|
999
|
+
return None
|
|
1000
|
+
except Exception as exc:
|
|
1001
|
+
# HomeAssistantCommandTimeout (the response-wait expired — the batch frame
|
|
1002
|
+
# WAS sent) or any post-send transport drop: the batch is ambiguous-
|
|
1003
|
+
# dispatched. Report partial, NEVER re-dispatch (D9 at-most-once).
|
|
1004
|
+
logger.warning(
|
|
1005
|
+
"%s post-send timeout/drop; reporting batch partial (not retried): %r",
|
|
1006
|
+
WS_BULK_CALL_SERVICE,
|
|
1007
|
+
exc,
|
|
1008
|
+
)
|
|
1009
|
+
return self._build_ambiguous_bulk_response(
|
|
1010
|
+
operations, valid_operations, rows, skipped_operations, parallel
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
# A SUCCESS result frame is produced ONLY after the batch prep ran to completion
|
|
1014
|
+
# (every op's async_call fired), so a malformed/unusable success envelope (a
|
|
1015
|
+
# non-dict result, or an op-list whose length drifts from the rows we sent)
|
|
1016
|
+
# means the writes already HAPPENED. Report the batch AMBIGUOUS (every op
|
|
1017
|
+
# partial, never re-dispatched) — a ``None`` here would route to the legacy
|
|
1018
|
+
# operation-registry path and double-fire every landed op.
|
|
1019
|
+
op_results = self._component_bulk_op_results(raw, len(rows))
|
|
1020
|
+
if op_results is None:
|
|
1021
|
+
return self._build_ambiguous_bulk_response(
|
|
1022
|
+
operations, valid_operations, rows, skipped_operations, parallel
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
results = [
|
|
1026
|
+
self._map_component_op_result(op, entity_id, action, row, component_op)
|
|
1027
|
+
for (_idx, op, entity_id, action), row, component_op in zip(
|
|
1028
|
+
valid_operations, rows, op_results, strict=True
|
|
1029
|
+
)
|
|
1030
|
+
]
|
|
1031
|
+
# Reuse the legacy response builder: operation_ids is empty (ops confirmed
|
|
1032
|
+
# inline, no polling handle) → follow_up is None, and the successful/failed
|
|
1033
|
+
# tallies key off command_sent exactly as the legacy path.
|
|
1034
|
+
return self._build_bulk_response(
|
|
1035
|
+
operations, results, [], skipped_operations, parallel
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
@staticmethod
|
|
1039
|
+
def _has_duplicate_entity_ids(
|
|
1040
|
+
valid_operations: list[tuple[int, dict[str, Any], str, str]],
|
|
1041
|
+
) -> bool:
|
|
1042
|
+
"""True when two+ ops in the batch target the same entity_id.
|
|
1043
|
+
|
|
1044
|
+
The component keys ONE transition waiter per entity_id, so the first
|
|
1045
|
+
``state_changed`` satisfies every op sharing that entity — both would report the
|
|
1046
|
+
same transition/final_state. The legacy sequential path confirms each op
|
|
1047
|
+
independently, so ``_bulk_via_component`` routes the WHOLE batch to legacy when
|
|
1048
|
+
this returns True.
|
|
1049
|
+
"""
|
|
1050
|
+
entity_ids = [entity_id for _idx, _op, entity_id, _action in valid_operations]
|
|
1051
|
+
return len(entity_ids) != len(set(entity_ids))
|
|
1052
|
+
|
|
1053
|
+
@staticmethod
|
|
1054
|
+
def _bulk_frame_timeout(
|
|
1055
|
+
valid_operations: list[tuple[int, dict[str, Any], str, str]],
|
|
1056
|
+
) -> float:
|
|
1057
|
+
"""The batch confirmation-wait bound, honoring per-op ``timeout_seconds``.
|
|
1058
|
+
|
|
1059
|
+
Legacy runs each op with its own ``timeout_seconds`` (default 10); the single
|
|
1060
|
+
batch frame shares ONE bounded wait, so use the max over the valid ops (capped
|
|
1061
|
+
at 60s) rather than a hardcoded 10s (M-timeout). ``valid_operations`` is
|
|
1062
|
+
non-empty here (an empty batch returned to legacy before the send). An explicit
|
|
1063
|
+
``timeout_seconds`` of 0 is honored (parity with legacy and the component
|
|
1064
|
+
schema); only an ABSENT key defaults to 10.
|
|
1065
|
+
"""
|
|
1066
|
+
return min(
|
|
1067
|
+
60.0,
|
|
1068
|
+
max(
|
|
1069
|
+
float(v) if (v := op.get("timeout_seconds")) is not None else 10.0
|
|
1070
|
+
for _idx, op, _eid, _action in valid_operations
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
@staticmethod
|
|
1075
|
+
def _component_bulk_op_results(
|
|
1076
|
+
raw: dict[str, Any], expected_len: int
|
|
1077
|
+
) -> list[Any] | None:
|
|
1078
|
+
"""The per-op results list from a batch frame, or ``None`` if malformed.
|
|
1079
|
+
|
|
1080
|
+
The component preserves op order and returns exactly one result per row it was
|
|
1081
|
+
sent. A non-dict result or an op-list whose length drifts from ``expected_len``
|
|
1082
|
+
is an unusable/incompatible SUCCESS envelope — the caller treats ``None`` as
|
|
1083
|
+
AMBIGUOUS (the writes already landed; never reconcile a mismatched batch).
|
|
1084
|
+
"""
|
|
1085
|
+
result = raw.get("result")
|
|
1086
|
+
if not isinstance(result, dict):
|
|
1087
|
+
return None
|
|
1088
|
+
op_results = result.get("operations")
|
|
1089
|
+
if not isinstance(op_results, list) or len(op_results) != expected_len:
|
|
1090
|
+
return None
|
|
1091
|
+
return op_results
|
|
1092
|
+
|
|
1093
|
+
def _build_ambiguous_bulk_response(
|
|
1094
|
+
self,
|
|
1095
|
+
operations: list[dict[str, Any]],
|
|
1096
|
+
valid_operations: list[tuple[int, dict[str, Any], str, str]],
|
|
1097
|
+
rows: list[dict[str, Any]],
|
|
1098
|
+
skipped_operations: list[dict[str, Any]],
|
|
1099
|
+
parallel: bool,
|
|
1100
|
+
) -> dict[str, Any]:
|
|
1101
|
+
"""Partial-success bulk response for a post-send batch-frame timeout/drop.
|
|
1102
|
+
|
|
1103
|
+
The batch frame WAS sent, so some/all ops may have dispatched; the batch
|
|
1104
|
+
response never arrived. Per D9 at-most-once every valid op is reported
|
|
1105
|
+
dispatched-but-unconfirmed (``partial``) and the batch is NEVER re-dispatched
|
|
1106
|
+
via legacy. ``rows`` is aligned 1:1 with ``valid_operations`` (both built in
|
|
1107
|
+
order; an unresolvable op returned to legacy earlier, so no ``None`` rows
|
|
1108
|
+
reach here).
|
|
1109
|
+
"""
|
|
1110
|
+
results: list[dict[str, Any]] = [
|
|
1111
|
+
{
|
|
1112
|
+
"entity_id": entity_id,
|
|
1113
|
+
"action": action,
|
|
1114
|
+
"parameters": op.get("parameters") or {},
|
|
1115
|
+
"command_sent": True,
|
|
1116
|
+
"status": "dispatched_unconfirmed",
|
|
1117
|
+
"confirmed": False,
|
|
1118
|
+
"partial": True,
|
|
1119
|
+
"final_state": None,
|
|
1120
|
+
"transitions": [],
|
|
1121
|
+
"service_call": {
|
|
1122
|
+
"domain": row["domain"],
|
|
1123
|
+
"service": row["service"],
|
|
1124
|
+
"data": row["service_data"],
|
|
1125
|
+
},
|
|
1126
|
+
"verification_method": "component_state_change",
|
|
1127
|
+
"message": (
|
|
1128
|
+
f"{entity_id} {action} dispatched but the batch confirmation did "
|
|
1129
|
+
"not arrive within the timeout; not retried"
|
|
1130
|
+
),
|
|
1131
|
+
}
|
|
1132
|
+
for (_idx, op, entity_id, action), row in zip(
|
|
1133
|
+
valid_operations, rows, strict=True
|
|
1134
|
+
)
|
|
1135
|
+
]
|
|
1136
|
+
response = self._build_bulk_response(
|
|
1137
|
+
operations, results, [], skipped_operations, parallel
|
|
1138
|
+
)
|
|
1139
|
+
response["partial"] = True
|
|
1140
|
+
response.setdefault("warnings", []).append(
|
|
1141
|
+
"The bulk operation was dispatched but Home Assistant did not confirm "
|
|
1142
|
+
"within the timeout. Operations were NOT retried (at-most-once). Use "
|
|
1143
|
+
"ha_get_state to verify the affected entities."
|
|
1144
|
+
)
|
|
1145
|
+
return response
|
|
1146
|
+
|
|
667
1147
|
@staticmethod
|
|
668
1148
|
def _tool_error_to_dict(e: ToolError) -> dict[str, Any]:
|
|
669
1149
|
"""Extract structured error dict from ToolError without double-encoding."""
|