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.
Files changed (187) hide show
  1. {ha_mcp_dev-7.13.0.dev1987/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.13.0.dev1990}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/rest_client.py +18 -0
  4. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/websocket_client.py +14 -1
  5. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/device_control.py +493 -13
  6. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_service.py +376 -11
  7. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/util_helpers.py +20 -0
  8. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/operation_manager.py +27 -0
  9. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  10. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/LICENSE +0 -0
  11. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/README.md +0 -0
  13. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/_version.py +0 -0
  18. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/backup_manager.py +0 -0
  22. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/browser_landing.py +0 -0
  23. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/__init__.py +0 -0
  24. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/supervisor_client.py +0 -0
  25. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/client/websocket_listener.py +0 -0
  26. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/config.py +0 -0
  27. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  28. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  29. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  30. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  31. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  32. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
  33. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/errors.py +0 -0
  34. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/llm_exposure.py +0 -0
  35. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/__init__.py +0 -0
  36. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/approval_queue.py +0 -0
  37. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/evaluator.py +0 -0
  38. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/handlers.py +0 -0
  39. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/middleware.py +0 -0
  40. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/model.py +0 -0
  41. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/persistence.py +0 -0
  42. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/policy/value_sources.py +0 -0
  43. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/py.typed +0 -0
  44. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/read_only.py +0 -0
  45. {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
  46. {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
  47. {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
  48. {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
  49. {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
  50. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  51. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  52. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  53. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  54. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
  67. {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
  68. {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
  69. {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
  70. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/server.py +0 -0
  71. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/__init__.py +0 -0
  72. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  73. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
  74. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  75. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  76. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  77. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
  78. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  79. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_persistence.py +0 -0
  80. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  81. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_theme.py +0 -0
  82. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  83. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  84. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/de.json +0 -0
  85. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/en.json +0 -0
  86. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
  87. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.css +0 -0
  88. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.html +0 -0
  89. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/settings_ui/settings.js +0 -0
  90. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/smoke_test.py +0 -0
  91. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  92. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/strict_bps.py +0 -0
  93. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/__init__.py +0 -0
  94. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/auto_backup.py +0 -0
  95. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/backup.py +0 -0
  96. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  97. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_api.py +0 -0
  98. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_config_reads.py +0 -0
  99. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_devices.py +0 -0
  100. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_registries.py +0 -0
  101. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  102. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  103. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/enhanced.py +0 -0
  104. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/helpers.py +0 -0
  105. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/__init__.py +0 -0
  106. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/base.py +0 -0
  107. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/matter.py +0 -0
  108. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/thread.py +0 -0
  109. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  110. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/radio/zwave.py +0 -0
  111. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/reference_validator.py +0 -0
  112. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/registry.py +0 -0
  113. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  114. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  115. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  116. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  117. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  118. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  119. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  120. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  121. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  122. {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
  123. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_addons.py +0 -0
  124. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_areas.py +0 -0
  125. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  126. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  127. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_calendar.py +0 -0
  128. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_camera.py +0 -0
  129. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_categories.py +0 -0
  130. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_code.py +0 -0
  131. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  132. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  133. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  134. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  135. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  136. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  137. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_dev.py +0 -0
  138. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_energy.py +0 -0
  139. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_entities.py +0 -0
  140. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  141. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_groups.py +0 -0
  142. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_hacs.py +0 -0
  143. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_history.py +0 -0
  144. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_integrations.py +0 -0
  145. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_labels.py +0 -0
  146. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_radio.py +0 -0
  147. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_registry.py +0 -0
  148. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_resources.py +0 -0
  149. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_search.py +0 -0
  150. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_services.py +0 -0
  151. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_system.py +0 -0
  152. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_themes.py +0 -0
  153. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_todo.py +0 -0
  154. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_traces.py +0 -0
  155. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_updates.py +0 -0
  156. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_utility.py +0 -0
  157. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  158. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  159. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  160. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/tools_zones.py +0 -0
  161. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/tools/validation_middleware.py +0 -0
  162. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/__init__.py +0 -0
  163. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/categorized_search.py +0 -0
  164. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  165. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/transport_security.py +0 -0
  166. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/update_check.py +0 -0
  167. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/__init__.py +0 -0
  168. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/config_hash.py +0 -0
  169. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/data_paths.py +0 -0
  170. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/domain_handlers.py +0 -0
  171. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  172. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  173. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/python_sandbox.py +0 -0
  174. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/skill_loader.py +0 -0
  175. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/utils/usage_logger.py +0 -0
  176. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/__init__.py +0 -0
  177. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/model.py +0 -0
  178. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/persistence.py +0 -0
  179. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp/visibility/resolver.py +0 -0
  180. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  181. {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
  182. {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
  183. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  184. {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
  185. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/tests/__init__.py +0 -0
  186. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/tests/test_constants.py +0 -0
  187. {ha_mcp_dev-7.13.0.dev1987 → ha_mcp_dev-7.13.0.dev1990}/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.13.0.dev1987
3
+ Version: 7.13.0.dev1990
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.13.0.dev1987"
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
 
@@ -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
- raise HomeAssistantConnectionError("WebSocket not authenticated")
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 HomeAssistantClient
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 get_operation_from_memory, store_pending_operation
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."""