ha-mcp-dev 7.14.0.dev2053__tar.gz → 7.14.0.dev2056__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 (188) hide show
  1. {ha_mcp_dev-7.14.0.dev2053/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.14.0.dev2056}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/client/rest_client.py +115 -26
  4. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/client/websocket_client.py +7 -6
  5. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/backup.py +4 -5
  6. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/component_api.py +4 -5
  7. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/component_config_reads.py +14 -15
  8. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/component_devices.py +9 -9
  9. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/component_registries.py +8 -7
  10. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/component_registry_lookup.py +9 -8
  11. ha_mcp_dev-7.14.0.dev2056/src/ha_mcp/tools/smart_search/_base.py +51 -0
  12. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_entities.py +23 -11
  13. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_overview.py +14 -4
  14. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_scenes.py +3 -2
  15. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_config_automations.py +8 -2
  16. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_config_dashboards.py +13 -10
  17. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_config_helpers.py +20 -17
  18. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_config_scenes.py +6 -1
  19. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_config_scripts.py +10 -2
  20. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_dev.py +5 -5
  21. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_entities.py +2 -3
  22. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_integrations.py +57 -19
  23. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_registry.py +45 -10
  24. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_search.py +25 -13
  25. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_service.py +33 -7
  26. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_services.py +46 -14
  27. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_system.py +22 -12
  28. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_traces.py +6 -5
  29. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_voice_assistant.py +8 -8
  30. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/util_helpers.py +56 -14
  31. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/visibility/resolver.py +3 -0
  32. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  33. ha_mcp_dev-7.14.0.dev2053/src/ha_mcp/tools/smart_search/_base.py +0 -38
  34. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/LICENSE +0 -0
  35. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/MANIFEST.in +0 -0
  36. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/README.md +0 -0
  37. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/setup.cfg +0 -0
  38. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/__init__.py +0 -0
  39. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/__main__.py +0 -0
  40. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/_pypi_marker +0 -0
  41. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/_version.py +0 -0
  42. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/auth/__init__.py +0 -0
  43. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/auth/consent_form.py +0 -0
  44. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/auth/provider.py +0 -0
  45. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/backup_manager.py +0 -0
  46. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/browser_landing.py +0 -0
  47. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/client/__init__.py +0 -0
  48. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/client/supervisor_client.py +0 -0
  49. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/client/websocket_listener.py +0 -0
  50. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/config.py +0 -0
  51. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  52. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  53. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  54. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  55. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  56. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
  57. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/errors.py +0 -0
  58. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/llm_exposure.py +0 -0
  59. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/__init__.py +0 -0
  60. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/approval_queue.py +0 -0
  61. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/evaluator.py +0 -0
  62. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/handlers.py +0 -0
  63. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/middleware.py +0 -0
  64. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/model.py +0 -0
  65. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/persistence.py +0 -0
  66. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/policy/value_sources.py +0 -0
  67. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/py.typed +0 -0
  68. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/read_only.py +0 -0
  69. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  70. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  71. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  72. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  73. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  74. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  75. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  76. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  77. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  78. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  79. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  80. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  81. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  82. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  83. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  84. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  85. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  86. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  87. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  88. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  89. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  90. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  91. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  92. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  93. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  94. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/server.py +0 -0
  95. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/__init__.py +0 -0
  96. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  97. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
  98. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  99. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  100. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  101. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
  102. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  103. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_persistence.py +0 -0
  104. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  105. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_theme.py +0 -0
  106. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  107. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  108. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/locales/de.json +0 -0
  109. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/locales/en.json +0 -0
  110. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
  111. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/settings.css +0 -0
  112. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/settings.html +0 -0
  113. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/settings_ui/settings.js +0 -0
  114. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/smoke_test.py +0 -0
  115. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  116. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/strict_bps.py +0 -0
  117. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/__init__.py +0 -0
  118. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/auto_backup.py +0 -0
  119. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  120. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  121. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/device_control.py +0 -0
  122. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/enhanced.py +0 -0
  123. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/helpers.py +0 -0
  124. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/__init__.py +0 -0
  125. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/base.py +0 -0
  126. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/matter.py +0 -0
  127. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/thread.py +0 -0
  128. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  129. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/radio/zwave.py +0 -0
  130. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/reference_validator.py +0 -0
  131. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/registry.py +0 -0
  132. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  133. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  134. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  135. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  136. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  137. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  138. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_addons.py +0 -0
  139. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_areas.py +0 -0
  140. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  141. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  142. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_calendar.py +0 -0
  143. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_camera.py +0 -0
  144. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_categories.py +0 -0
  145. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_code.py +0 -0
  146. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  147. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_energy.py +0 -0
  148. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  149. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_groups.py +0 -0
  150. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_hacs.py +0 -0
  151. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_history.py +0 -0
  152. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_labels.py +0 -0
  153. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_radio.py +0 -0
  154. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_resources.py +0 -0
  155. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_themes.py +0 -0
  156. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_todo.py +0 -0
  157. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_updates.py +0 -0
  158. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_utility.py +0 -0
  159. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  160. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  161. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/tools_zones.py +0 -0
  162. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/tools/validation_middleware.py +0 -0
  163. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/transforms/__init__.py +0 -0
  164. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/transforms/categorized_search.py +0 -0
  165. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  166. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/transport_security.py +0 -0
  167. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/update_check.py +0 -0
  168. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/__init__.py +0 -0
  169. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/config_hash.py +0 -0
  170. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/data_paths.py +0 -0
  171. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/domain_handlers.py +0 -0
  172. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  173. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  174. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/operation_manager.py +0 -0
  175. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/python_sandbox.py +0 -0
  176. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/skill_loader.py +0 -0
  177. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/utils/usage_logger.py +0 -0
  178. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/visibility/__init__.py +0 -0
  179. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/visibility/model.py +0 -0
  180. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp/visibility/persistence.py +0 -0
  181. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  182. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  183. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  184. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  185. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  186. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/tests/__init__.py +0 -0
  187. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/tests/test_constants.py +0 -0
  188. {ha_mcp_dev-7.14.0.dev2053 → ha_mcp_dev-7.14.0.dev2056}/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.14.0.dev2053
3
+ Version: 7.14.0.dev2056
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.14.0.dev2053"
7
+ version = "7.14.0.dev2056"
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
@@ -11,6 +11,7 @@ import time
11
11
  from typing import Any
12
12
 
13
13
  import httpx
14
+ from websockets.exceptions import WebSocketException
14
15
 
15
16
  from .._version import get_supervisor_base_url, is_running_in_addon
16
17
  from ..config import get_global_settings
@@ -133,6 +134,20 @@ class HomeAssistantCommandTimeout(HomeAssistantError):
133
134
  """
134
135
 
135
136
 
137
+ # Exception classes that are evidence no answer came back from Home Assistant,
138
+ # as opposed to Home Assistant answering with a rejection. ``OSError`` covers
139
+ # ``ConnectionResetError``; ``WebSocketException`` covers the
140
+ # ``ConnectionClosed`` family that ``send_command`` re-raises unwrapped;
141
+ # ``HomeAssistantCommandTimeout`` covers a socket that is still open but has
142
+ # stopped answering, which leaves a caller just as blind as a closed one.
143
+ _NO_ANSWER_ERRORS = (
144
+ HomeAssistantConnectionError,
145
+ HomeAssistantCommandTimeout,
146
+ OSError,
147
+ WebSocketException,
148
+ )
149
+
150
+
136
151
  class HomeAssistantClient:
137
152
  """Authenticated HTTP client for Home Assistant API."""
138
153
 
@@ -1437,6 +1452,11 @@ class HomeAssistantClient:
1437
1452
  retry_delay = 0.5 # seconds
1438
1453
 
1439
1454
  for attempt in range(max_retries):
1455
+ # Whether the failure happened before a usable connection existed.
1456
+ # Failing to obtain one is transport death whatever class the
1457
+ # manager raises, and it raises a bare ``Exception`` when
1458
+ # ``connect()`` returns False, so this cannot be decided by type.
1459
+ acquiring = True
1440
1460
  try:
1441
1461
  # Use per-client WebSocket keyed to this client's credentials
1442
1462
  # (verify_ssl included so a verify_ssl=False client never
@@ -1446,6 +1466,7 @@ class HomeAssistantClient:
1446
1466
  token=self.token,
1447
1467
  verify_ssl=self.verify_ssl,
1448
1468
  )
1469
+ acquiring = False
1449
1470
 
1450
1471
  # Special handling for render_template which returns an event with the actual result
1451
1472
  if message.get("type") == "render_template":
@@ -1460,31 +1481,70 @@ class HomeAssistantClient:
1460
1481
 
1461
1482
  except Exception as e:
1462
1483
  error_str = str(e)
1463
-
1464
- # Detect transient 403 errors (rate limiting / reverse proxy throttling)
1465
- if "403" in error_str and "Forbidden" in error_str:
1466
- if attempt < max_retries - 1:
1467
- logger.warning(
1468
- f"WebSocket 403 error (attempt {attempt + 1}/{max_retries}), "
1469
- f"retrying after {retry_delay}s: {error_str}"
1470
- )
1471
- await asyncio.sleep(retry_delay)
1472
- continue
1473
- else:
1474
- logger.error(
1475
- f"WebSocket 403 error after {max_retries} attempts: {error_str}"
1476
- )
1477
- return {
1478
- "success": False,
1479
- "error": f"WebSocket request blocked (403 Forbidden): {error_str}",
1480
- "error_code": getattr(e, "code", None),
1481
- "suggestions": [
1482
- "This may be caused by a reverse proxy or security filter",
1483
- "Try simplifying the request (e.g., shorter templates, fewer parameters)",
1484
- "If using complex templates, try breaking them into smaller parts",
1485
- "Check if your Home Assistant is behind a reverse proxy with security rules",
1486
- ],
1487
- }
1484
+ is_403 = "403" in error_str and "Forbidden" in error_str
1485
+
1486
+ # Transient 403 (rate limiting / reverse proxy throttling): retry
1487
+ # before deciding anything, since the next attempt may succeed.
1488
+ if is_403 and attempt < max_retries - 1:
1489
+ logger.warning(
1490
+ f"WebSocket 403 error (attempt {attempt + 1}/{max_retries}), "
1491
+ f"retrying after {retry_delay}s: {error_str}"
1492
+ )
1493
+ await asyncio.sleep(retry_delay)
1494
+ continue
1495
+
1496
+ # Phase first, type second. Failing before a client exists is
1497
+ # evidence about the transport whatever class was raised, and
1498
+ # the pooled manager raises a bare ``Exception`` when
1499
+ # ``connect()`` returns False, so that case cannot be decided
1500
+ # by type. Past acquisition only the no-answer classes count:
1501
+ # ``send_command`` deliberately re-raises the original
1502
+ # ``ConnectionClosed`` / ``ConnectionResetError`` from the send
1503
+ # rather than wrapping it, to keep at-most-once semantics for
1504
+ # write callers, so those arrive here untranslated. Anything
1505
+ # else (a ``ValueError`` from building the message, say) is a
1506
+ # bug rather than evidence about the connection.
1507
+ if acquiring or isinstance(e, _NO_ANSWER_ERRORS):
1508
+ # One error channel for "no answer came back" (#1947): the
1509
+ # caller decides the policy, rather than every consumer
1510
+ # having to notice a marker on an otherwise ordinary
1511
+ # failure envelope. Callers that legitimately continue on a
1512
+ # missing answer catch this and say in ``warnings`` what
1513
+ # they skipped; callers whose output would read as a
1514
+ # negative finding let it propagate.
1515
+ logger.error(
1516
+ f"WebSocket transport failed ({message.get('type')}): {e}"
1517
+ )
1518
+ if isinstance(e, HomeAssistantConnectionError):
1519
+ # Already the right class — re-raise unchanged so
1520
+ # subtypes (``HomeAssistantCommandNotSent``) and the
1521
+ # original traceback survive.
1522
+ raise
1523
+ raise HomeAssistantConnectionError(error_str) from e
1524
+
1525
+ # Deliberate asymmetry (#1966 review item 8): a 403 that
1526
+ # exhausts its retries DURING acquisition is transport death -
1527
+ # no client was ever obtained - so it raises and loses the
1528
+ # reverse-proxy suggestions below. The 403 text survives in the
1529
+ # message, and returning a soft envelope there instead would
1530
+ # put a blocked transport back on the degrade path this change
1531
+ # exists to close. Past acquisition the socket is alive and HA
1532
+ # (or a proxy) answered, so the tailored suggestions apply.
1533
+ if is_403:
1534
+ logger.error(
1535
+ f"WebSocket 403 error after {max_retries} attempts: {error_str}"
1536
+ )
1537
+ return {
1538
+ "success": False,
1539
+ "error": f"WebSocket request blocked (403 Forbidden): {error_str}",
1540
+ "error_code": getattr(e, "code", None),
1541
+ "suggestions": [
1542
+ "This may be caused by a reverse proxy or security filter",
1543
+ "Try simplifying the request (e.g., shorter templates, fewer parameters)",
1544
+ "If using complex templates, try breaking them into smaller parts",
1545
+ "Check if your Home Assistant is behind a reverse proxy with security rules",
1546
+ ],
1547
+ }
1488
1548
 
1489
1549
  # Name the command in the log line: a bare "Command failed:
1490
1550
  # Unknown command." is undiagnosable from a user's log
@@ -1499,7 +1559,11 @@ class HomeAssistantClient:
1499
1559
  "error_code": getattr(e, "code", None),
1500
1560
  }
1501
1561
 
1502
- return {"success": False, "error": "WebSocket request failed"}
1562
+ # Unreachable: every attempt either returns, raises, or ``continue``s,
1563
+ # and the last attempt cannot ``continue`` (the 403 branch guards on
1564
+ # ``attempt < max_retries - 1``). Assert rather than return a
1565
+ # contextless failure envelope that would read as a soft error.
1566
+ raise AssertionError("send_websocket_message loop exited without a result")
1503
1567
 
1504
1568
  async def _handle_render_template(
1505
1569
  self, ws_client: Any, message: dict[str, Any]
@@ -1536,12 +1600,23 @@ class HomeAssistantClient:
1536
1600
  }
1537
1601
 
1538
1602
  except TimeoutError:
1603
+ # Template-level, not transport-level: the wait budget here is the
1604
+ # caller's own ``timeout`` (default 3s) plus two seconds, and a
1605
+ # template that is merely slow to render leaves the socket healthy.
1606
+ # Deliberately NOT treated as a dead transport, unlike the 30s
1607
+ # round-trip ``HomeAssistantCommandTimeout`` in ``send_command``.
1539
1608
  return {
1540
1609
  "success": False,
1541
1610
  "error": "Event timeout - template result not received",
1542
1611
  "template": message.get("template"),
1543
1612
  }
1544
1613
  except Exception as e:
1614
+ if isinstance(e, _NO_ANSWER_ERRORS):
1615
+ # Let the caller's classification decide. Returning an envelope
1616
+ # here would route a transport death around the fail-loud policy
1617
+ # in ``send_websocket_message`` — the very swallow this branch
1618
+ # used to hide (#1947).
1619
+ raise
1545
1620
  return {
1546
1621
  "success": False,
1547
1622
  "error": str(e),
@@ -1579,6 +1654,13 @@ class HomeAssistantClient:
1579
1654
  f"Resolved script entity_id {entity_id} to storage key {unique_id}"
1580
1655
  )
1581
1656
  return str(unique_id)
1657
+ except HomeAssistantConnectionError:
1658
+ # A dead transport must not become a silent guess: the fallback
1659
+ # below assumes the storage key equals the bare entity_id, which is
1660
+ # exactly what a UI rename breaks. Guessing it on a write path
1661
+ # writes a NEW object under the wrong key instead of updating the
1662
+ # renamed one, so let the caller fail loud (#1947).
1663
+ raise
1582
1664
  except Exception:
1583
1665
  logger.debug(
1584
1666
  f"Entity registry lookup failed for {entity_id}, using bare id: {bare_id}",
@@ -1720,6 +1802,13 @@ class HomeAssistantClient:
1720
1802
  f"Resolved scene entity_id {entity_id} to storage key {unique_id}"
1721
1803
  )
1722
1804
  return str(unique_id)
1805
+ except HomeAssistantConnectionError:
1806
+ # A dead transport must not become a silent guess: the fallback
1807
+ # below assumes the storage key equals the bare entity_id, which is
1808
+ # exactly what a UI rename breaks. Guessing it on a write path
1809
+ # writes a NEW object under the wrong key instead of updating the
1810
+ # renamed one, so let the caller fail loud (#1947).
1811
+ raise
1723
1812
  except Exception:
1724
1813
  logger.debug(
1725
1814
  f"Entity registry lookup failed for {entity_id}, using bare id: {bare_id}",
@@ -22,6 +22,7 @@ import websockets
22
22
 
23
23
  from ..config import get_global_settings
24
24
  from .rest_client import (
25
+ HomeAssistantAuthError,
25
26
  HomeAssistantCommandError,
26
27
  HomeAssistantCommandNotSent,
27
28
  HomeAssistantCommandTimeout,
@@ -368,8 +369,8 @@ class HomeAssistantWebSocketClient:
368
369
  message_type="auth_invalid", timeout=1
369
370
  )
370
371
  if auth_invalid:
371
- raise Exception("Authentication failed: Invalid token")
372
- raise Exception("Authentication timeout")
372
+ raise HomeAssistantAuthError("Authentication failed: Invalid token")
373
+ raise HomeAssistantConnectionError("Authentication timeout")
373
374
 
374
375
  self._state.mark_authenticated()
375
376
  logger.info("WebSocket connected and authenticated successfully")
@@ -413,7 +414,7 @@ class HomeAssistantWebSocketClient:
413
414
  async def _send_auth(self) -> None:
414
415
  """Send authentication message."""
415
416
  if not self.websocket:
416
- raise Exception("WebSocket not connected")
417
+ raise HomeAssistantConnectionError("WebSocket not connected")
417
418
  auth_message = {"type": "auth", "access_token": self.token}
418
419
  await self.websocket.send(json.dumps(auth_message))
419
420
 
@@ -434,7 +435,7 @@ class HomeAssistantWebSocketClient:
434
435
  async def _message_handler(self) -> None:
435
436
  """Background task to handle incoming WebSocket messages."""
436
437
  if not self.websocket:
437
- raise Exception("WebSocket not connected")
438
+ raise HomeAssistantConnectionError("WebSocket not connected")
438
439
  # None means a clean exit (the async-for loop simply ended); the
439
440
  # except blocks below fill this in so pending futures — and the
440
441
  # log — carry *why* the connection went away instead of a bare
@@ -573,7 +574,7 @@ class HomeAssistantWebSocketClient:
573
574
 
574
575
  async with self._send_lock:
575
576
  if not self.websocket:
576
- raise Exception("WebSocket not connected")
577
+ raise HomeAssistantConnectionError("WebSocket not connected")
577
578
  logger.debug(f"WebSocket sending: {message}")
578
579
  await self.websocket.send(json.dumps(message))
579
580
 
@@ -1204,7 +1205,7 @@ class WebSocketManager:
1204
1205
  # keeps a non-str (e.g. a MagicMock in tests) from polluting
1205
1206
  # the message with a repr.
1206
1207
  detail = f": {reason}" if isinstance(reason, str) else ""
1207
- raise Exception(
1208
+ raise HomeAssistantConnectionError(
1208
1209
  "Failed to connect to Home Assistant WebSocket" + detail
1209
1210
  )
1210
1211
 
@@ -244,9 +244,8 @@ async def _backup_prep_via_component(
244
244
  in-process read. ``None`` on capability miss, downgrade (``unknown_command``
245
245
  → invalidate the cached caps), or command error/timeout (logged) — the
246
246
  caller falls back to the legacy sequential calls. A
247
- ``HomeAssistantConnectionError`` (pooled-WS drop) or the plain ``Exception``
248
- ``get_websocket_client()`` raises on a failed (re)connect is caught here and
249
- mapped to ``None``: the legacy probes (``_get_local_backup_agent_id`` /
247
+ ``HomeAssistantConnectionError`` — a pooled-WS drop, or a failed
248
+ (re)connect — is caught here and mapped to ``None``: the legacy probes (``_get_local_backup_agent_id`` /
250
249
  ``_get_backup_password``) run on a DEDICATED ``get_connected_ws_client`` socket
251
250
  already connected before this read, NOT this pooled one — so a wedged pooled
252
251
  socket must fall back to the legacy calls rather than fail create/restore.
@@ -265,8 +264,8 @@ async def _backup_prep_via_component(
265
264
  logger.warning("%s failed; fell back to legacy: %r", WS_BACKUP_PREP, exc)
266
265
  return None
267
266
  except Exception as exc:
268
- # HomeAssistantConnectionError (pooled-WS drop) OR the plain Exception
269
- # get_websocket_client() raises on a failed (re)connect. The legacy probes
267
+ # HomeAssistantConnectionError: a pooled-WS drop or a failed
268
+ # (re)connect. The legacy probes
270
269
  # ride a dedicated already-connected socket, so fall back rather than fail
271
270
  # create/restore on a wedged pooled socket.
272
271
  logger.warning(
@@ -243,8 +243,8 @@ async def get_component_caps(client: Any) -> ComponentCaps | None:
243
243
  installed / upgraded mid-session (the REST client — the cache key — is not
244
244
  recreated on an HA restart) is eventually adopted instead of pinned absent.
245
245
  - ``HomeAssistantConnectionError`` / ``HomeAssistantCommandTimeout`` (WS
246
- down or slow) — and the plain ``Exception`` ``get_websocket_client()`` raises
247
- when ``WebSocketManager`` can't build the socket: cache a SHORT transient
246
+ down or slow, including the failed-connect raise from
247
+ ``WebSocketManager``): cache a SHORT transient
248
248
  negative (``_TRANSIENT_NEGATIVE_CACHE_TTL_S``) so repeated calls on a
249
249
  WS-broken install skip the slow connect and go straight to legacy, then
250
250
  re-probe once the window lapses (self-healing). The consuming tool's legacy
@@ -297,9 +297,8 @@ async def get_component_caps(client: Any) -> ComponentCaps | None:
297
297
  _store_transient_negative(client)
298
298
  return None
299
299
  except Exception:
300
- # Includes the plain Exception get_websocket_client() raises when
301
- # WebSocketManager can't build the socket — the connect-establishment
302
- # failure review-5 M8 targets. Short transient cache, self-healing.
300
+ # Broad on purpose: the probe must never promote an unexpected
301
+ # fault into a tool failure. Short transient cache, self-healing.
303
302
  logger.debug("%s probe failed unexpectedly", INFO_COMMAND, exc_info=True)
304
303
  _store_transient_negative(client)
305
304
  return None
@@ -42,9 +42,10 @@ still completes:
42
42
 
43
43
  - ``fetch_entity_lookup_via_component``'s consumers degrade gracefully. The scene
44
44
  resolver's ``config/entity_registry/list`` dump rides
45
- ``client.send_websocket_message`` — the swallowing bridge that returns
46
- ``{"success": False}`` instead of raising, so the resolver walks its retry loop
47
- to the naive ``scene.{id}`` fallback — and the automation resolver scans REST
45
+ ``client.send_websocket_message``, which answers a command HA rejected with
46
+ ``{"success": False}``, so the resolver walks its retry loop to the naive
47
+ ``scene.{id}`` fallback (a dead transport raises there instead, #1947) - and
48
+ the automation resolver scans REST
48
49
  ``get_states()`` and additionally catches broadly → ``None``. Its only routed
49
50
  call sites run AFTER a scene/automation upsert commits or BEFORE a REST delete,
50
51
  so an escaping transport error would report a landed write as failed / abort a
@@ -104,7 +105,7 @@ async def fetch_entity_lookup_via_component(
104
105
  ``None`` on capability miss, downgrade (``unknown_command`` → invalidate the
105
106
  cached caps), command error/timeout (logged), a connection-establishment
106
107
  failure (logged — see the module docstring: the resolvers' legacy paths ride
107
- the swallowing WS bridge / REST, NOT this pooled socket, so a transport
108
+ the WS bridge / REST, NOT this pooled socket, so a transport
108
109
  failure must fall back rather than abort a landed write), or a shape-drift
109
110
  payload (no ``matches`` list). Same error-taxonomy and silent fallback as
110
111
  ``component_devices.fetch_device_via_component``.
@@ -125,8 +126,8 @@ async def fetch_entity_lookup_via_component(
125
126
  logger.warning("%s failed; fell back to legacy: %r", WS_ENTITY_LOOKUP, exc)
126
127
  return None
127
128
  except Exception as exc:
128
- # HomeAssistantConnectionError (pooled-WS drop) OR the plain Exception
129
- # get_websocket_client() raises when WebSocketManager can't (re)connect.
129
+ # HomeAssistantConnectionError: a pooled-WS drop or a failed
130
+ # (re)connect.
130
131
  # The resolvers' legacy paths do NOT die identically (see module docstring),
131
132
  # so route to legacy rather than escape.
132
133
  logger.warning(
@@ -163,11 +164,10 @@ async def fetch_reference_data_via_component(
163
164
 
164
165
  Like every component fetch helper, a transport failure routes to ``None``
165
166
  (→ legacy REST) rather than escaping; the reference validator's legacy path is
166
- the REST ``get_services()`` / ``get_states()`` pair. The catch is
167
- broad because ``get_websocket_client()`` raises a plain ``Exception`` (not
168
- ``HomeAssistantConnectionError``) when ``WebSocketManager`` cannot build the
169
- socket; letting it escape would make ``validate_config_references`` hit its
170
- swallow-all fetch guard and skip ALL reference warnings even when REST is up.
167
+ the REST ``get_services()`` / ``get_states()`` pair. The catch stays broad
168
+ so no unexpected fault escapes: that would make ``validate_config_references``
169
+ hit its swallow-all fetch guard and skip ALL reference warnings even when
170
+ REST is up.
171
171
  Mirrors ``get_component_caps``' own broad-catch precedent.
172
172
  """
173
173
  caps = await get_component_caps(client)
@@ -187,10 +187,9 @@ async def fetch_reference_data_via_component(
187
187
  return None
188
188
  except Exception as exc:
189
189
  # Legacy is REST (get_services/get_states), NOT the pooled WS, so a
190
- # pooled-WS drop (HomeAssistantConnectionError) OR get_websocket_client()
191
- # raising a plain Exception when WebSocketManager can't (re)connect must
192
- # route to the REST fetch rather than escape into the validator's
193
- # swallow-all guard (which would skip every reference warning).
190
+ # pooled-WS drop or a failed (re)connect must route to the REST fetch
191
+ # rather than escape into the validator's swallow-all guard (which would
192
+ # skip every reference warning).
194
193
  logger.warning(
195
194
  "%s connection error; falling back to REST legacy: %r",
196
195
  WS_REFERENCE_DATA,
@@ -71,10 +71,10 @@ async def fetch_device_via_component(
71
71
  unavailable, fall back"). Falls back **silently**, mirroring
72
72
  ``ha_get_state``: the legacy path returns the byte-identical device either way
73
73
  and the ``log.warning`` preserves operator visibility. A
74
- ``HomeAssistantConnectionError`` (pooled-WS drop) or the plain ``Exception``
75
- ``get_websocket_client()`` raises on a failed (re)connect is caught here and
76
- mapped to ``None``: the consumers' legacy paths ride the swallowing
77
- ``send_websocket_message`` bridge (registry tools) or a dedicated one-shot WS
74
+ ``HomeAssistantConnectionError`` — a pooled-WS drop, or a failed
75
+ (re)connect — is caught here and mapped to ``None``: the consumers' legacy
76
+ paths ride the ``send_websocket_message`` bridge (registry tools) or a
77
+ dedicated one-shot WS
78
78
  client (the auto-backup capture) — NOT this pooled socket — so a transport
79
79
  failure must fall back rather than block a tool / a wrapped write.
80
80
 
@@ -100,10 +100,10 @@ async def fetch_device_via_component(
100
100
  logger.warning("%s failed; fell back to legacy: %r", WS_DEVICE_GET, exc)
101
101
  return None
102
102
  except Exception as exc:
103
- # HomeAssistantConnectionError (pooled-WS drop) OR the plain Exception
104
- # get_websocket_client() raises when WebSocketManager can't (re)connect.
105
- # The legacy paths ride the swallowing send_websocket_message bridge / a
106
- # dedicated capture socket, not this pooled one, so fall back to legacy.
103
+ # HomeAssistantConnectionError: a pooled-WS drop or a failed
104
+ # (re)connect. The legacy paths ride the send_websocket_message bridge /
105
+ # a dedicated capture socket, so fall back to legacy; if the transport
106
+ # itself is dead the bridge raises there in turn (#1947).
107
107
  logger.warning(
108
108
  "%s connection error; falling back to legacy: %r", WS_DEVICE_GET, exc
109
109
  )
@@ -173,7 +173,7 @@ async def fetch_device_list_via_component(client: Any) -> dict[str, Any] | None:
173
173
  return None
174
174
  except Exception as exc:
175
175
  # HomeAssistantConnectionError / plain establish Exception → legacy (the
176
- # legacy device list rides the swallowing send_websocket_message bridge).
176
+ # legacy device list rides the send_websocket_message bridge).
177
177
  logger.warning(
178
178
  "%s connection error; falling back to legacy: %r", WS_DEVICE_LIST, exc
179
179
  )
@@ -100,14 +100,15 @@ async def fetch_registries_via_component(
100
100
  failure (logged), or a malformed response — the outer ``result`` isn't a
101
101
  dict, or a requested slice isn't its expected list/dict shape (logged; see
102
102
  ``_has_valid_shape``) — the caller falls back to its legacy WS list call(s)
103
- in every case. A ``HomeAssistantConnectionError`` (pooled-WS drop) or the
104
- plain ``Exception`` ``get_websocket_client()`` raises on a failed (re)connect
105
- is caught here and mapped to ``None``: the auto-backup capture fetchers'
103
+ in every case. A ``HomeAssistantConnectionError`` — a pooled-WS drop, or a
104
+ failed (re)connect — is caught here and mapped to ``None``: the auto-backup capture fetchers'
106
105
  legacy ``_ws_send`` builds a DEDICATED one-shot WS client (which can succeed
107
106
  while the pooled socket is wedged) under a best-effort contract that requires
108
107
  warn-and-skip, never a blocked write; ``ha_list_floors_areas``' legacy rides
109
- the swallowing ``send_websocket_message`` bridge. Neither dies identically on
110
- a pooled-WS drop, so a transport failure must fall back rather than escape.
108
+ the ``send_websocket_message`` bridge, which is the SAME pooled connection
109
+ and raises on a dead transport (#1947) rather than degrading. Only the
110
+ capture path's dedicated socket survives a wedged pool, so a
111
+ component-side fault must fall back rather than escape.
111
112
  """
112
113
  caps = await get_component_caps(client)
113
114
  if not component_supports(caps, "registries"):
@@ -125,8 +126,8 @@ async def fetch_registries_via_component(
125
126
  logger.warning("%s failed; fell back to legacy: %r", WS_REGISTRIES, exc)
126
127
  return None
127
128
  except Exception as exc:
128
- # HomeAssistantConnectionError (pooled-WS drop) OR the plain Exception
129
- # get_websocket_client() raises on a failed (re)connect. The capture
129
+ # HomeAssistantConnectionError: a pooled-WS drop or a failed
130
+ # (re)connect. The capture
130
131
  # fetchers use a dedicated one-shot socket and forbid a blocked write, so
131
132
  # this must fall back to legacy rather than escape into the write path.
132
133
  logger.warning(
@@ -32,14 +32,15 @@ on any component error — lives in one place (the pattern ``component_devices``
32
32
  established for the ``device_get`` / ``device_list`` capabilities). Both helpers
33
33
  return ``None`` to mean "component unavailable — use the legacy path"; a component
34
34
  that answers returns its payload. Per the uniform transport-fallback taxonomy, a
35
- ``HomeAssistantConnectionError`` (pooled-WS drop) and the plain ``Exception``
36
- ``get_websocket_client()`` raises on a failed (re)connect are caught and mapped to
37
- ``None`` so the legacy path runs: the consumers' legacy reads (the whole-registry
35
+ ``HomeAssistantConnectionError`` — a pooled-WS drop, or a failed (re)connect —
36
+ is caught and mapped to ``None`` so the legacy path runs: the consumers' legacy reads (the whole-registry
38
37
  ``config/entity_registry/list`` dump and the per-id ``config/entity_registry/get``
39
- retry loop) ride the swallowing ``send_websocket_message`` bridge, which returns
40
- ``{"success": False}`` rather than raising — so they do NOT die identically on a
41
- pooled-WS drop, and letting a transport failure escape would skip the
42
- ``resolve_entities_via_component`` consumer's legacy retry loop entirely.
38
+ retry loop) ride the ``send_websocket_message`` bridge, which answers a
39
+ command HA rejected with ``{"success": False}`` - so a component-side fault
40
+ does not kill them, and letting it escape would skip the
41
+ ``resolve_entities_via_component`` consumer's legacy retry loop entirely. The
42
+ bridge uses the SAME pooled connection, so a dead transport raises there too
43
+ (#1947) rather than degrading.
43
44
  """
44
45
 
45
46
  from __future__ import annotations
@@ -96,7 +97,7 @@ async def fetch_entities_for_config_entry_via_component(
96
97
  return None
97
98
  except Exception as exc:
98
99
  # HomeAssistantConnectionError / plain establish Exception → legacy (the
99
- # legacy entity_registry/list dump rides the swallowing bridge; see module
100
+ # legacy entity_registry/list dump rides the bridge; see module
100
101
  # docstring). Never propagate a transport failure out of the read.
101
102
  logger.warning(
102
103
  "%s connection error; falling back to legacy: %r", WS_REGISTRY_LOOKUP, exc
@@ -0,0 +1,51 @@
1
+ """Shared base for the smart-search feature mixins.
2
+
3
+ ``_SearchBase`` declares the instance attributes that
4
+ ``SmartSearchTools.__init__`` sets, so each feature mixin type-checks in
5
+ isolation, and hosts the registry-list helper shared by the overview and
6
+ entity/area search mixins.
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from ...client.rest_client import HomeAssistantClient
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class _SearchBase:
18
+ """Attributes set by ``SmartSearchTools.__init__`` plus shared helpers."""
19
+
20
+ client: HomeAssistantClient
21
+ fuzzy_searcher: Any
22
+ settings: Any
23
+
24
+ @staticmethod
25
+ def _extract_registry_list(
26
+ result: Any, label: str, warnings: list[str] | None = None
27
+ ) -> list[dict[str, Any]]:
28
+ """Unwrap a WS registry-list result, returning ``[]`` on error/failure.
29
+
30
+ Every caller treats missing registry data as non-fatal rather than
31
+ raising: the overview degrades its area enrichment, and the area
32
+ search degrades to "no match found". Degrading is fine; degrading
33
+ silently is not — a caller that passes ``warnings`` gets a line
34
+ naming what was unavailable and why, so a thinner answer is
35
+ distinguishable from a genuinely empty registry (#1947).
36
+ """
37
+ cause: str | None = None
38
+ registry: list[dict[str, Any]] = []
39
+ if isinstance(result, Exception):
40
+ logger.debug(f"Could not fetch {label}: {result}")
41
+ cause = str(result) or type(result).__name__
42
+ elif isinstance(result, dict) and result.get("success"):
43
+ registry = result.get("result", [])
44
+ elif isinstance(result, dict):
45
+ cause = str(result.get("error") or "request failed")
46
+ else:
47
+ cause = f"unexpected response type: {type(result).__name__}"
48
+
49
+ if cause is not None and warnings is not None:
50
+ warnings.append(f"{label} unavailable: {cause}")
51
+ return registry
@@ -457,9 +457,15 @@ class EntitySearchMixin(_SearchBase):
457
457
  visibility_hidden, visibility_warnings = await load_hidden_set(
458
458
  results[2], results[0], self.client, results[3]
459
459
  )
460
- area_registry = self._parse_area_registry(results[1])
461
- entity_reg_map = self._parse_entity_reg_map(results[2])
462
- device_area_map = self._parse_device_area_map(results[3])
460
+ # Registry failures still fail open, but each names itself: an area
461
+ # search that answers "no match found" because the area registry
462
+ # never arrived must not look like a search that genuinely found
463
+ # nothing (#1947).
464
+ registry_warnings: list[str] = []
465
+ area_registry = self._parse_area_registry(results[1], registry_warnings)
466
+ entity_reg_map = self._parse_entity_reg_map(results[2], registry_warnings)
467
+ device_area_map = self._parse_device_area_map(results[3], registry_warnings)
468
+ degraded_warnings = registry_warnings + visibility_warnings
463
469
 
464
470
  area_query_lower = area_query.lower().strip()
465
471
  matched_area_ids = self._match_area_ids(area_registry, area_query_lower)
@@ -476,7 +482,7 @@ class EntitySearchMixin(_SearchBase):
476
482
  for aid, ainfo in area_registry.items()
477
483
  ],
478
484
  },
479
- visibility_warnings,
485
+ degraded_warnings,
480
486
  )
481
487
 
482
488
  entity_area_resolved, hidden_entity_ids = self._resolve_entity_areas(
@@ -499,7 +505,7 @@ class EntitySearchMixin(_SearchBase):
499
505
  "total_entities": total_entities,
500
506
  "areas": formatted_areas,
501
507
  },
502
- visibility_warnings,
508
+ degraded_warnings,
503
509
  )
504
510
 
505
511
  except Exception as e:
@@ -519,20 +525,24 @@ class EntitySearchMixin(_SearchBase):
519
525
  raise
520
526
 
521
527
  @classmethod
522
- def _parse_area_registry(cls, result: Any) -> dict[str, dict[str, Any]]:
528
+ def _parse_area_registry(
529
+ cls, result: Any, warnings: list[str] | None = None
530
+ ) -> dict[str, dict[str, Any]]:
523
531
  """Parse the area registry into ``area_id -> area info``."""
524
532
  area_registry: dict[str, dict[str, Any]] = {}
525
- for area in cls._extract_registry_list(result, "area registry"):
533
+ for area in cls._extract_registry_list(result, "area registry", warnings):
526
534
  area_id = area.get("area_id", "")
527
535
  if area_id:
528
536
  area_registry[area_id] = area
529
537
  return area_registry
530
538
 
531
539
  @classmethod
532
- def _parse_entity_reg_map(cls, result: Any) -> dict[str, dict[str, str | None]]:
540
+ def _parse_entity_reg_map(
541
+ cls, result: Any, warnings: list[str] | None = None
542
+ ) -> dict[str, dict[str, str | None]]:
533
543
  """Parse the entity registry into ``entity_id -> {area_id, device_id, hidden_by}``."""
534
544
  entity_reg_map: dict[str, dict[str, str | None]] = {}
535
- for entry in cls._extract_registry_list(result, "entity registry"):
545
+ for entry in cls._extract_registry_list(result, "entity registry", warnings):
536
546
  entity_id = entry.get("entity_id")
537
547
  if entity_id:
538
548
  entity_reg_map[entity_id] = {
@@ -543,10 +553,12 @@ class EntitySearchMixin(_SearchBase):
543
553
  return entity_reg_map
544
554
 
545
555
  @classmethod
546
- def _parse_device_area_map(cls, result: Any) -> dict[str, str | None]:
556
+ def _parse_device_area_map(
557
+ cls, result: Any, warnings: list[str] | None = None
558
+ ) -> dict[str, str | None]:
547
559
  """Parse the device registry into ``device_id -> area_id``."""
548
560
  device_area_map: dict[str, str | None] = {}
549
- for device in cls._extract_registry_list(result, "device registry"):
561
+ for device in cls._extract_registry_list(result, "device registry", warnings):
550
562
  device_id = device.get("id", "")
551
563
  if device_id:
552
564
  device_area_map[device_id] = device.get("area_id")