ha-mcp-dev 8.3.0.dev2413__tar.gz → 8.3.0.dev2418__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 (281) hide show
  1. {ha_mcp_dev-8.3.0.dev2413/src/ha_mcp_dev.egg-info → ha_mcp_dev-8.3.0.dev2418}/PKG-INFO +1 -1
  2. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/pyproject.toml +1 -1
  3. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/dashboard_screenshot/capture.py +14 -19
  4. ha_mcp_dev-8.3.0.dev2418/src/ha_mcp/dashboard_screenshot/theme_guard.py +501 -0
  5. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/read_only.py +18 -0
  6. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_dashboard_screenshot.py +8 -4
  7. ha_mcp_dev-8.3.0.dev2418/src/ha_mcp/tools/tools_themes.py +401 -0
  8. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. ha_mcp_dev-8.3.0.dev2413/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -234
  10. ha_mcp_dev-8.3.0.dev2413/src/ha_mcp/tools/tools_themes.py +0 -192
  11. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/LICENSE +0 -0
  12. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/MANIFEST.in +0 -0
  13. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/README.md +0 -0
  14. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/setup.cfg +0 -0
  15. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/__init__.py +0 -0
  16. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/__main__.py +0 -0
  17. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_pypi_marker +0 -0
  18. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_rich_compat.py +0 -0
  19. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/__init__.py +0 -0
  20. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/requirements.txt +0 -0
  21. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/LICENSE +0 -0
  22. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/MANIFEST.sha256 +0 -0
  23. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/VENDORED +0 -0
  24. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/__init__.py +0 -0
  25. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/__main__.py +0 -0
  26. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/__init__.py +0 -0
  27. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/client.py +0 -0
  28. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/connection.py +0 -0
  29. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/messages.py +0 -0
  30. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/router.py +0 -0
  31. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/asyncio/server.py +0 -0
  32. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/auth.py +0 -0
  33. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/cli.py +0 -0
  34. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/client.py +0 -0
  35. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/connection.py +0 -0
  36. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/datastructures.py +0 -0
  37. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/exceptions.py +0 -0
  38. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/extensions/__init__.py +0 -0
  39. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/extensions/base.py +0 -0
  40. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/extensions/permessage_deflate.py +0 -0
  41. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/frames.py +0 -0
  42. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/headers.py +0 -0
  43. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/http11.py +0 -0
  44. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/imports.py +0 -0
  45. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/__init__.py +0 -0
  46. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/auth.py +0 -0
  47. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/client.py +0 -0
  48. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/exceptions.py +0 -0
  49. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/framing.py +0 -0
  50. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/handshake.py +0 -0
  51. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/http.py +0 -0
  52. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/protocol.py +0 -0
  53. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/legacy/server.py +0 -0
  54. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/protocol.py +0 -0
  55. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/proxy.py +0 -0
  56. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/py.typed +0 -0
  57. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/server.py +0 -0
  58. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/streams.py +0 -0
  59. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/__init__.py +0 -0
  60. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/client.py +0 -0
  61. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/connection.py +0 -0
  62. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/messages.py +0 -0
  63. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/router.py +0 -0
  64. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/server.py +0 -0
  65. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/sync/utils.py +0 -0
  66. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/__init__.py +0 -0
  67. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/client.py +0 -0
  68. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/connection.py +0 -0
  69. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/messages.py +0 -0
  70. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/router.py +0 -0
  71. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/server.py +0 -0
  72. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/trio/utils.py +0 -0
  73. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/typing.py +0 -0
  74. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/uri.py +0 -0
  75. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/utils.py +0 -0
  76. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_vendor/websockets/version.py +0 -0
  77. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/_version.py +0 -0
  78. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/auth/__init__.py +0 -0
  79. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/auth/consent_form.py +0 -0
  80. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/auth/oidc_compat.py +0 -0
  81. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/auth/provider.py +0 -0
  82. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/backup_manager.py +0 -0
  83. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/browser_landing.py +0 -0
  84. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/client/__init__.py +0 -0
  85. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/client/rest_client.py +0 -0
  86. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/client/supervisor_client.py +0 -0
  87. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/client/websocket_client.py +0 -0
  88. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/client/websocket_listener.py +0 -0
  89. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/config.py +0 -0
  90. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  91. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  92. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  93. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  94. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/errors.py +0 -0
  95. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/hacs_auto_refresh.py +0 -0
  96. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/llm_exposure.py +0 -0
  97. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/__init__.py +0 -0
  98. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/approval_queue.py +0 -0
  99. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/editing.py +0 -0
  100. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/evaluator.py +0 -0
  101. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/handlers.py +0 -0
  102. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/middleware.py +0 -0
  103. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/model.py +0 -0
  104. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/persistence.py +0 -0
  105. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/policy/value_sources.py +0 -0
  106. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/py.typed +0 -0
  107. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/redaction.py +0 -0
  108. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/renamed_tools.py +0 -0
  109. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  110. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  111. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  112. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  113. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  114. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  115. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  116. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  117. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  118. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  119. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  120. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  121. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  122. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  123. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  124. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  125. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  126. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  127. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  128. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  129. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  130. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  131. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  132. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  133. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  134. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/server.py +0 -0
  135. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/__init__.py +0 -0
  136. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  137. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
  138. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  139. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  140. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  141. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
  142. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  143. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_persistence.py +0 -0
  144. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  145. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_theme.py +0 -0
  146. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  147. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  148. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/cs.json +0 -0
  149. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/de.json +0 -0
  150. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/en.json +0 -0
  151. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/eo.json +0 -0
  152. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/es.json +0 -0
  153. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/fr.json +0 -0
  154. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/it.json +0 -0
  155. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/ko.json +0 -0
  156. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/nl.json +0 -0
  157. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/pl.json +0 -0
  158. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
  159. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/sv.json +0 -0
  160. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/locales/zh-Hans.json +0 -0
  161. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/settings.css +0 -0
  162. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/settings.html +0 -0
  163. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/settings_ui/settings.js +0 -0
  164. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/smoke_test.py +0 -0
  165. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  166. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/strict_bps.py +0 -0
  167. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/__init__.py +0 -0
  168. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/auto_backup.py +0 -0
  169. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/backup.py +0 -0
  170. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  171. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/best_practice_result.py +0 -0
  172. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/best_practice_variables.py +0 -0
  173. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/bulk_selector.py +0 -0
  174. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_api.py +0 -0
  175. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_config_entries.py +0 -0
  176. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_config_reads.py +0 -0
  177. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_devices.py +0 -0
  178. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_registries.py +0 -0
  179. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  180. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  181. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_flow_form.py +0 -0
  182. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_flow_menu.py +0 -0
  183. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_flow_walker.py +0 -0
  184. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_identity.py +0 -0
  185. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_reconfigure.py +0 -0
  186. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/config_entry_reload_watch.py +0 -0
  187. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/device_control.py +0 -0
  188. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/error_log_parsing.py +0 -0
  189. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/hacs_registration.py +0 -0
  190. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/helpers.py +0 -0
  191. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/integration_reconfigure.py +0 -0
  192. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/__init__.py +0 -0
  193. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/base.py +0 -0
  194. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/matter.py +0 -0
  195. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/thread.py +0 -0
  196. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  197. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/radio/zwave.py +0 -0
  198. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/reference_validator.py +0 -0
  199. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/registry.py +0 -0
  200. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/renamed_tool_middleware.py +0 -0
  201. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  202. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  203. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  204. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
  205. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  206. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  207. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  208. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  209. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  210. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  211. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_addons.py +0 -0
  212. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_areas.py +0 -0
  213. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_assist_pipeline.py +0 -0
  214. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  215. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  216. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_calendar.py +0 -0
  217. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_camera.py +0 -0
  218. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_categories.py +0 -0
  219. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_code.py +0 -0
  220. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  221. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  222. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  223. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  224. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  225. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_dev.py +0 -0
  226. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_energy.py +0 -0
  227. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_entities.py +0 -0
  228. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  229. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_groups.py +0 -0
  230. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_hacs.py +0 -0
  231. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_history.py +0 -0
  232. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_integrations.py +0 -0
  233. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_labels.py +0 -0
  234. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_radio.py +0 -0
  235. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_registry.py +0 -0
  236. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_resources.py +0 -0
  237. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_search.py +0 -0
  238. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_security_policy.py +0 -0
  239. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_service.py +0 -0
  240. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_services.py +0 -0
  241. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_system.py +0 -0
  242. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_todo.py +0 -0
  243. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_traces.py +0 -0
  244. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_updates.py +0 -0
  245. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_utility.py +0 -0
  246. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  247. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  248. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  249. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/tools_zones.py +0 -0
  250. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/util_helpers.py +0 -0
  251. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/tools/validation_middleware.py +0 -0
  252. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/transforms/__init__.py +0 -0
  253. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/transforms/categorized_search.py +0 -0
  254. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  255. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/transport_security.py +0 -0
  256. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/update_check.py +0 -0
  257. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/__init__.py +0 -0
  258. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/config_hash.py +0 -0
  259. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/config_write_lock.py +0 -0
  260. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/data_paths.py +0 -0
  261. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/domain_handlers.py +0 -0
  262. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/entity_membership.py +0 -0
  263. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  264. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  265. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/operation_manager.py +0 -0
  266. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/python_sandbox.py +0 -0
  267. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/skill_loader.py +0 -0
  268. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/utils/usage_logger.py +0 -0
  269. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/visibility/__init__.py +0 -0
  270. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/visibility/enforcement.py +0 -0
  271. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/visibility/model.py +0 -0
  272. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/visibility/persistence.py +0 -0
  273. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp/visibility/resolver.py +0 -0
  274. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  275. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  276. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  277. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  278. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  279. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/tests/__init__.py +0 -0
  280. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/tests/test_constants.py +0 -0
  281. {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2418}/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: 8.3.0.dev2413
3
+ Version: 8.3.0.dev2418
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 = "8.3.0.dev2413"
7
+ version = "8.3.0.dev2418"
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
@@ -855,15 +855,12 @@ async def capture_dashboard_images(
855
855
  preset's native orientation. ``full_page`` is a compatibility alias for
856
856
  requesting the engine's native ``WIDTHxauto`` viewport.
857
857
 
858
- The batch used to be bracketed by a :class:`ThemeGuard` that restored the
859
- engine user's saved frontend theme when a cold render changed it (issue
860
- #1909). That bracket is currently disabled (#1991): upstream Puppet no
861
- longer dispatches ``settheme`` on cold renders, so there is nothing to undo.
862
- The guard construction and the ``client`` / ``capture_warnings`` plumbing
863
- are retained so the bracket can be re-enabled by uncommenting the
864
- snapshot/restore calls if a future engine regression reintroduces the write;
865
- while disabled ``capture_warnings`` simply stays empty and never affects the
866
- captures themselves.
858
+ Each batch is bracketed by a :class:`ThemeGuard`, which reads the engine
859
+ user's saved frontend theme before and after rendering. It never writes:
860
+ when the render changed the theme it reports the previous value through
861
+ ``capture_warnings`` so the agent can restore it with the write-annotated
862
+ ``ha_manage_theme``, keeping this path honestly read-only (#1909, #1991).
863
+ Guard failures are non-fatal.
867
864
  """
868
865
  path = _validate_dashboard_path(dashboard_path)
869
866
  options = validate_capture_parameters(
@@ -888,16 +885,14 @@ async def capture_dashboard_images(
888
885
  mime_type = _MIME_TYPES[options.image_format]
889
886
  captures: list[DashboardImageCapture] = []
890
887
 
891
- # ThemeGuard bracket — currently DISABLED (#1991). Stock Puppet used to
892
- # dispatch a theme write into the authenticated frontend on cold renders,
893
- # which Home Assistant persisted to the engine user's real profile (#1909);
894
- # ha-mcp snapshotted before and restored after to undo it. Upstream Puppet
895
- # has since fixed the cold-render settheme dispatch, so the bracket is no
896
- # longer needed. The guard is still constructed (and the snapshot/restore
897
- # calls kept below, commented out) so it can be re-enabled by uncommenting
898
- # if a future engine regression reintroduces the write.
888
+ # ThemeGuard bracket. Puppet's settheme dispatch persists onto the engine
889
+ # token user's profile and syncs to that user's live sessions (#1909).
890
+ # Both reads here; the guard reports the change rather than undoing it, so
891
+ # this tool issues no writes and stays read-only (#1991). Armed for every
892
+ # capture, not just themed ones: the upstream fix that would spare the
893
+ # no-parameter case is unreleased as of Puppet 2.6.0.
899
894
  guard = ThemeGuard.for_capture(engine_target.addon_credential, client)
900
- # await guard.take_snapshot()
895
+ await guard.take_snapshot()
901
896
  batch_error: ToolError | None = None
902
897
  try:
903
898
  async with httpx.AsyncClient(
@@ -977,7 +972,7 @@ async def capture_dashboard_images(
977
972
  # and its outcome can be attached to the error payload below.
978
973
  batch_error = exc
979
974
  finally:
980
- # await guard.restore() # ThemeGuard bracket disabled (#1991) — see above.
975
+ await guard.detect_change()
981
976
  if capture_warnings is not None:
982
977
  capture_warnings.extend(guard.warnings)
983
978
 
@@ -0,0 +1,501 @@
1
+ """Detect when the screenshot engine changes the engine user's saved theme.
2
+
3
+ Puppet dispatches Home Assistant's ``settheme`` event on cold-browser
4
+ renders. Its ``dark`` query flag is presence-based, so "not requested"
5
+ reaches the frontend as an explicit "light". Home Assistant persists that
6
+ selection server-side per user (``frontend/set_user_data``, key ``"theme"``)
7
+ and syncs it to every session of the user whose long-lived token the engine
8
+ runs with, so a screenshot flips that user's real web and mobile UI (#1909).
9
+
10
+ Upstream stopped the dispatch for renders requesting nothing
11
+ (balloob/home-assistant-addons#89), but by its own title only for that case,
12
+ and it is unreleased as of Puppet 2.6.0 -- so on current releases every
13
+ render writes.
14
+
15
+ **This guard never writes.** It reads the saved theme before the batch and
16
+ again afterwards, and when the render changed it, reports the previous value
17
+ so the agent can restore it with ``ha_manage_theme`` -- a tool correctly
18
+ annotated as a write. That keeps the screenshot and dashboard-get tools
19
+ honestly ``readOnlyHint: True`` (#1991, PR #2014) while still surfacing the
20
+ damage. Because nothing is written, concurrent batches cannot corrupt each
21
+ other and no serialization is needed.
22
+
23
+ Pointing the engine at its own dedicated Home Assistant user avoids the
24
+ problem outright: the write then lands on an account nobody looks at.
25
+
26
+ Credential resolution mirrors engine discovery:
27
+
28
+ - **HA OS / Supervised** -- the Puppet add-on's own ``access_token`` and
29
+ ``home_assistant_url`` options, taken from the Supervisor add-on info that
30
+ engine discovery already fetches. The token lives only in process memory
31
+ and is never logged or returned.
32
+ - **Docker / standalone / OAuth / embedded** -- ha-mcp's direct Home
33
+ Assistant credentials, which match whenever the engine runs with a token
34
+ for the same user (the common single-user setup).
35
+ - Anything else -- detection stays inactive and captures behave as before.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import ipaddress
42
+ import json
43
+ import logging
44
+ from collections.abc import AsyncIterator, Mapping
45
+ from contextlib import asynccontextmanager
46
+ from dataclasses import dataclass, field
47
+ from typing import TYPE_CHECKING, Any
48
+
49
+ from .._version import is_running_in_addon
50
+
51
+ if TYPE_CHECKING:
52
+ from ..client.websocket_client import HomeAssistantWebSocketClient
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ THEME_USER_DATA_KEY = "theme"
57
+
58
+ # Sentinel: None is a legitimate stored theme value, so it cannot mean "unset".
59
+ _UNSET: Any = object()
60
+
61
+ # Where Puppet reaches Home Assistant when its ``home_assistant_url`` option
62
+ # is unset — the Supervisor-internal alias, mirrored from the add-on default.
63
+ DEFAULT_ENGINE_HA_URL = "http://homeassistant:8123"
64
+
65
+ # Hosts the Supervisor-internal / loopback routes use. Cleartext to these
66
+ # never leaves the host or its container network, which SECURITY.md names as
67
+ # the trusted zone for standard mode. Cleartext to anything else would put the
68
+ # engine account's bearer token on the wire for an on-path attacker, so it is
69
+ # refused rather than sent.
70
+ _LOCAL_HOSTS = frozenset(
71
+ {"homeassistant", "localhost", "supervisor", "127.0.0.1", "::1", "[::1]"}
72
+ )
73
+
74
+
75
+ def _read_only_mode() -> bool:
76
+ """Whether server Read Only Mode is on. Never raises.
77
+
78
+ Read through a helper so the report has one seam: Read Only Mode blocks
79
+ set_engine_theme at call time while captures keep running, so the remedy
80
+ must know about it.
81
+ """
82
+ try:
83
+ from ..config import get_global_settings
84
+
85
+ return bool(get_global_settings().read_only_mode)
86
+ except Exception: # pragma: no cover - settings must never break a read
87
+ return False
88
+
89
+
90
+ def _refusal_reason(url: str) -> str | None:
91
+ """Why ``url`` must not carry the engine token, or None when it may.
92
+
93
+ The two refusals are distinct and must say so. An unsupported scheme is
94
+ refused because the transport maps ONLY https to wss and everything else
95
+ to cleartext ws -- so wss://homeassistant and the ALLOWED
96
+ http://homeassistant produce the identical wire URL, and calling that
97
+ "cleartext to a remote host" is simply wrong. "Use https://" is also not
98
+ available advice for a Supervisor-internal endpoint.
99
+ """
100
+ from urllib.parse import urlparse
101
+
102
+ scheme = urlparse(url).scheme
103
+ if not _refuses_cleartext(url):
104
+ return None
105
+ if scheme not in ("http", "https"):
106
+ return (
107
+ f"refusing an engine URL with the unsupported scheme {scheme!r} "
108
+ f"({url}): the WebSocket transport maps only https to wss and "
109
+ "every other scheme to cleartext ws, so this would not be "
110
+ "encrypted. Use http:// for a local endpoint or https:// "
111
+ "otherwise."
112
+ )
113
+ return (
114
+ "refusing to send the screenshot engine's token in cleartext to a "
115
+ f"remote host ({url}); use https://"
116
+ )
117
+
118
+
119
+ def _refuses_cleartext(url: str) -> bool:
120
+ """True when ``url`` must not carry the engine token."""
121
+ from urllib.parse import urlparse
122
+
123
+ parsed = urlparse(url)
124
+ # Allowlist, not a denylist. The transport maps ONLY https to wss and
125
+ # everything else to ws (websocket_client.py), so a wss:// engine URL --
126
+ # the spelling that looks secure and is natural to type for a WebSocket
127
+ # endpoint -- is silently downgraded to cleartext. Keying on "not http"
128
+ # waved exactly that through while refusing the honest http:// spelling
129
+ # of the same mistake. The Supervisor accepts any scheme
130
+ # (home_assistant_url is a bare str in Puppet's config.yaml) and
131
+ # addon_credential_from_options passes it through unvalidated, so this is
132
+ # the place the two rules have to agree.
133
+ if parsed.scheme == "https":
134
+ return False
135
+ if parsed.scheme != "http":
136
+ return True
137
+ host = (parsed.hostname or "").lower()
138
+ if host in _LOCAL_HOSTS or host.endswith(".local"):
139
+ return False
140
+ # Private ranges stay on the local network, the documented trusted zone --
141
+ # but ONLY as real IP literals. A DNS name that merely looks like one
142
+ # ("10.attacker.example") resolves wherever its owner points it, so string
143
+ # prefix matching would hand the token to an external host.
144
+ try:
145
+ address = ipaddress.ip_address(host.strip("[]"))
146
+ except ValueError:
147
+ return True
148
+ return not (address.is_private or address.is_loopback or address.is_link_local)
149
+
150
+
151
+ _RESTORE_HINT = (
152
+ "the engine token's user can re-select their theme under Profile > "
153
+ "General in the Home Assistant UI"
154
+ )
155
+
156
+ # The engine returns the image as soon as the render settles, but the
157
+ # frontend's settheme handler saves the user data asynchronously — with a
158
+ # very low wait_ms the write can land after the HTTP response. Wait this
159
+ # long before the post-capture read so it observes the engine's write
160
+ # instead of racing it (a stale read would compare equal, skip the
161
+ # restore, and let the late write survive).
162
+ RESTORE_SETTLE_SECONDS = 1.0
163
+
164
+ # The guard runs before the engine is contacted and must never eat the
165
+ # caller's MCP timeout window; send_command otherwise waits 30s by default.
166
+ COMMAND_TIMEOUT_SECONDS = 5.0
167
+
168
+ # COMMAND_TIMEOUT_SECONDS only bounds a command once the socket is open and
169
+ # authenticated. An unreachable endpoint or a bad token stalls in connect/auth
170
+ # instead, before the engine is ever contacted, so the whole session is bounded
171
+ # too.
172
+ SESSION_TIMEOUT_SECONDS = 10.0
173
+
174
+ # Cleanup runs shielded so a cancellation cannot abort the close mid-flight,
175
+ # but shielded work still has to be bounded or a blocked disconnect() would
176
+ # extend the session past SESSION_TIMEOUT_SECONDS. The shielded task keeps
177
+ # running in the background when this bound expires; we just stop waiting.
178
+ CLOSE_TIMEOUT_SECONDS = 2.0
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class EngineCredential:
183
+ """Home Assistant URL + token that authenticate as the engine's user.
184
+
185
+ Deliberately carries only the two values the theme guard needs, so the
186
+ engine add-on's raw Supervisor options (a secret-bearing dict) never
187
+ cross module boundaries. The token must never be logged or surfaced in
188
+ responses.
189
+ """
190
+
191
+ url: str
192
+ token: str
193
+ verify_ssl: bool | None = None
194
+
195
+
196
+ def addon_credential_from_options(
197
+ addon_options: Mapping[str, Any] | None,
198
+ ) -> EngineCredential | None:
199
+ """Extract the engine user's credential from Puppet add-on options."""
200
+ if not addon_options:
201
+ return None
202
+ token = str(addon_options.get("access_token") or "").strip()
203
+ if not token:
204
+ return None
205
+ url = str(addon_options.get("home_assistant_url") or "").strip()
206
+ return EngineCredential(url=url or DEFAULT_ENGINE_HA_URL, token=token)
207
+
208
+
209
+ def _client_credential(client: Any) -> EngineCredential | None:
210
+ """Fall back to ha-mcp's own direct Home Assistant credential.
211
+
212
+ Only meaningful outside add-on mode: the Supervisor proxy authenticates
213
+ as the Supervisor system user, whose frontend profile is unrelated to
214
+ the engine token's user, so protecting it would be a silent no-op.
215
+ Embedded mode is deliberately NOT excluded — there the HA core container
216
+ carries ``SUPERVISOR_TOKEN`` but the server is a plain admin client with
217
+ a real user token, exactly what this fallback needs.
218
+ """
219
+ if is_running_in_addon():
220
+ return None
221
+ base_url = str(getattr(client, "base_url", "") or "").strip()
222
+ token = str(getattr(client, "token", "") or "").strip()
223
+ if not base_url.startswith(("http://", "https://")) or not token:
224
+ return None
225
+ # Carry the client's own TLS setting: a direct client built with
226
+ # verify_ssl=False (self-signed HA) must not fall back to the global
227
+ # default, or every session fails and no change is ever detected.
228
+ verify_ssl = getattr(client, "verify_ssl", None)
229
+ return EngineCredential(
230
+ url=base_url,
231
+ token=token,
232
+ verify_ssl=verify_ssl if isinstance(verify_ssl, bool) else None,
233
+ )
234
+
235
+
236
+ @dataclass
237
+ class ThemeGuard:
238
+ """Per-capture-batch snapshot/restore of the engine user's saved theme.
239
+
240
+ ``credential`` and ``warnings`` are the public contract; the snapshot
241
+ pair is internal lifecycle state driven only by :meth:`take_snapshot`
242
+ and :meth:`restore`.
243
+ """
244
+
245
+ credential: EngineCredential | None
246
+ warnings: list[str] = field(default_factory=list)
247
+ _snapshot: Any = None
248
+ _snapshot_taken: bool = False
249
+ changed_from: Any = None
250
+ # False when the credential is ha-mcp's own rather than the engine's own
251
+ # token: it MAY be the same Home Assistant user (the common single-user
252
+ # setup) but that cannot be established, so the report says so.
253
+ credential_is_engine: bool = True
254
+
255
+ @classmethod
256
+ def for_capture(
257
+ cls,
258
+ addon_credential: EngineCredential | None,
259
+ client: Any,
260
+ ) -> ThemeGuard:
261
+ """Resolve the engine user's credential for one capture batch."""
262
+ credential = addon_credential or _client_credential(client)
263
+ # Only the add-on's own token provably belongs to the engine. The
264
+ # client fallback MAY be the same user (the common single-user setup)
265
+ # but that cannot be established, so the report says so rather than
266
+ # implying the engine account was the one observed.
267
+ provably_engine = addon_credential is not None
268
+ if credential is None:
269
+ logger.debug(
270
+ "Dashboard theme guard inactive: no engine credential is "
271
+ "discoverable in this deployment"
272
+ )
273
+ return cls(credential=credential, credential_is_engine=provably_engine)
274
+
275
+ @asynccontextmanager
276
+ async def _session(self) -> AsyncIterator[HomeAssistantWebSocketClient]:
277
+ """Yield a short-lived authenticated WebSocket as the engine user."""
278
+ from ..client.websocket_client import HomeAssistantWebSocketClient
279
+
280
+ assert self.credential is not None
281
+ refusal = _refusal_reason(self.credential.url)
282
+ if refusal is not None:
283
+ raise ConnectionError(refusal)
284
+ ws = HomeAssistantWebSocketClient(
285
+ self.credential.url,
286
+ self.credential.token,
287
+ verify_ssl=self.credential.verify_ssl,
288
+ )
289
+ # The bound lives HERE, at the single construction site, rather than at
290
+ # each caller: _session is the only place a session carrying the engine
291
+ # credential is built, so a future entry point inherits the deadline
292
+ # along with the cleartext refusal instead of having to remember it.
293
+ # It spans connect, auth and the caller's body -- an unreachable
294
+ # endpoint stalls in connect, before the engine is ever contacted.
295
+ #
296
+ # connect() is INSIDE the try: the deadline cancels via CancelledError,
297
+ # a BaseException, so leaving it outside skipped disconnect() entirely
298
+ # and stranded the socket plus its background reader task.
299
+ try:
300
+ async with asyncio.timeout(SESSION_TIMEOUT_SECONDS):
301
+ if not await ws.connect():
302
+ reason = ws.last_connect_error
303
+ detail = f": {reason}" if isinstance(reason, str) else ""
304
+ raise ConnectionError(
305
+ f"could not authenticate to {self.credential.url}{detail}"
306
+ )
307
+ yield ws
308
+ finally:
309
+ # Best-effort: a real failure to close must not mask the original
310
+ # exception (including the cancellation that triggered cleanup).
311
+ # Owned explicitly: a bare shield leaves the inner task pending
312
+ # after a timeout or cancellation, still holding the socket and
313
+ # able to raise late with nobody to receive it.
314
+ close = asyncio.ensure_future(ws.disconnect())
315
+ try:
316
+ # shield: we are frequently here *because* of a cancellation
317
+ # (SESSION_TIMEOUT_SECONDS). A bare await would be cancelled
318
+ # at once and the socket would never actually close.
319
+ await asyncio.wait_for(
320
+ asyncio.shield(close), timeout=CLOSE_TIMEOUT_SECONDS
321
+ )
322
+ except (Exception, TimeoutError) as close_error:
323
+ logger.debug(
324
+ "Ignoring error while closing the theme-guard session: %s",
325
+ close_error,
326
+ )
327
+ finally:
328
+ if not close.done():
329
+ close.cancel()
330
+ # Retrieve the outcome so a late failure never surfaces as an
331
+ # orphaned "exception was never retrieved" warning.
332
+ await asyncio.gather(close, return_exceptions=True)
333
+
334
+ @staticmethod
335
+ async def _fetch_theme(ws: HomeAssistantWebSocketClient) -> Any:
336
+ """Read the persisted ``theme`` frontend user-data value (may be None)."""
337
+ response = await ws.send_command(
338
+ "frontend/get_user_data",
339
+ key=THEME_USER_DATA_KEY,
340
+ _wait_timeout=COMMAND_TIMEOUT_SECONDS,
341
+ )
342
+ payload = response.get("result") if isinstance(response, dict) else None
343
+ return payload.get("value") if isinstance(payload, dict) else None
344
+
345
+ async def _read_theme(self) -> Any:
346
+ """One bounded read of the engine user's saved theme."""
347
+ async with self._session() as ws:
348
+ return await self._fetch_theme(ws)
349
+
350
+ async def take_snapshot(self) -> None:
351
+ """Record the saved theme before the engine renders. Never raises."""
352
+ if self.credential is None:
353
+ return
354
+ try:
355
+ self._snapshot = await self._read_theme()
356
+ self._snapshot_taken = True
357
+ except Exception as exc:
358
+ logger.warning(
359
+ "Could not read the screenshot engine user's saved theme "
360
+ "before rendering: %s",
361
+ exc,
362
+ )
363
+ self.warnings.append(
364
+ "Could not read the screenshot engine user's saved frontend "
365
+ f"theme before rendering; if the render changed it, {_RESTORE_HINT}."
366
+ )
367
+
368
+ async def detect_change(self) -> None:
369
+ """Report -- never repair -- a theme the render changed. Never raises.
370
+
371
+ Writing the value back here would make the screenshot tools issue
372
+ ``frontend/set_user_data``, which is exactly what disqualifies them
373
+ from ``readOnlyHint: True``. Instead the previous value is surfaced
374
+ as a warning naming the write-annotated tool that can restore it.
375
+ """
376
+ if not self._snapshot_taken or self.credential is None:
377
+ return
378
+ try:
379
+ # Puppet's settheme dispatch happens during page navigation, but
380
+ # the frontend's resulting user-data write is asynchronous -- let
381
+ # it land before reading (see RESTORE_SETTLE_SECONDS).
382
+ await asyncio.sleep(RESTORE_SETTLE_SECONDS)
383
+ current = await self._read_theme()
384
+ except Exception as exc:
385
+ logger.warning(
386
+ "Could not re-read the screenshot engine user's saved theme "
387
+ "after rendering: %s",
388
+ exc,
389
+ )
390
+ self.warnings.append(
391
+ "Could not check whether the screenshot render changed the "
392
+ "saved frontend theme of the engine token's user; "
393
+ f"{_RESTORE_HINT}."
394
+ )
395
+ return
396
+ if current == self._snapshot:
397
+ return
398
+ self.changed_from = self._snapshot
399
+ # A never-configured baseline is restored as {} rather than null:
400
+ # live frontend sessions ignore a null subscription push (they stay
401
+ # flipped until reload), while an empty settings object re-applies
402
+ # default/auto behavior immediately and means the same thing on the
403
+ # next frontend boot.
404
+ restore_value = self._snapshot if self._snapshot is not None else {}
405
+ logger.info(
406
+ "Screenshot render changed the engine user's saved theme "
407
+ "(was %s, now %s); reporting for agent-side restore",
408
+ self._snapshot,
409
+ current,
410
+ )
411
+ # Order matters: report the condition that actually governs. The
412
+ # identity block is unconditional -- with a fallback credential the
413
+ # restore is refused whether or not Read Only Mode is on -- while the
414
+ # read-only block lifts when the flag is turned off. Testing
415
+ # read-only first told a reader in BOTH states to turn the flag off,
416
+ # advice that unblocks nothing when the credential is unproven.
417
+ if not self.credential_is_engine:
418
+ remedy = (
419
+ "This was observed with ha-mcp's own Home Assistant "
420
+ "credential rather than the engine's own token, so ha-mcp "
421
+ "cannot confirm which account it belongs to and will not act "
422
+ "on it. Restore it from that account's own session: Profile "
423
+ "> General in the Home Assistant UI."
424
+ )
425
+ elif _read_only_mode():
426
+ remedy = (
427
+ "Server Read Only Mode is on, so ha-mcp will not restore "
428
+ "it: captures keep running there, but every theme write is "
429
+ "blocked at call time. Restore it from that account's own "
430
+ "session, Profile > General in the Home Assistant UI, or "
431
+ "turn Read Only Mode off first."
432
+ )
433
+ else:
434
+ remedy = (
435
+ "To restore it, call ha_manage_theme(action="
436
+ f"'set_engine_theme', value={json.dumps(restore_value)}, "
437
+ f"expected_current={json.dumps(current)}) -- that guard "
438
+ "re-checks the stored theme immediately before writing and "
439
+ "skips the write if it changed."
440
+ )
441
+ self.warnings.append(
442
+ "The screenshot engine changed the saved frontend theme of the "
443
+ "account its token belongs to, which also changes that account's "
444
+ "live web and mobile sessions. This tool is read-only and will "
445
+ f"not change it back. {remedy} To stop this happening at all, "
446
+ "give the screenshot engine its own Home Assistant user and "
447
+ "long-lived token, so its writes land on an account nobody "
448
+ "looks at."
449
+ )
450
+
451
+
452
+ async def read_engine_theme(credential: EngineCredential) -> Any:
453
+ """Read the engine user's saved ``theme`` frontend user-data value."""
454
+ guard = ThemeGuard(credential=credential)
455
+ return await guard._read_theme()
456
+
457
+
458
+ class ThemeChangedError(RuntimeError):
459
+ """The saved theme is not what the caller expected to overwrite."""
460
+
461
+ def __init__(self, actual: Any) -> None:
462
+ super().__init__("saved theme no longer matches expected_current")
463
+ self.actual = actual
464
+
465
+
466
+ async def write_engine_theme(
467
+ credential: EngineCredential,
468
+ value: Any,
469
+ expected_current: Any = _UNSET,
470
+ *,
471
+ force: bool = False,
472
+ ) -> None:
473
+ """Write the engine user's saved ``theme`` frontend user-data value.
474
+
475
+ Only reached through ``ha_manage_theme``, which is annotated as a write.
476
+ Unless ``force`` is set the stored value is re-read
477
+ immediately before the write and the write is skipped on a mismatch. This
478
+ is best-effort, not atomic: ``frontend/set_user_data`` is an unconditional
479
+ ``async_set_item`` with no version or etag, so Home Assistant offers
480
+ nothing to compare against server-side and a change landing between the
481
+ re-read and the write is not caught. What it does catch is what actually
482
+ happens -- a delayed restore against a theme the user has since changed,
483
+ and a misresolved credential, whose account's theme will not match either.
484
+ """
485
+ guard = ThemeGuard(credential=credential)
486
+
487
+ async def _write() -> None:
488
+ async with guard._session() as ws:
489
+ if not force:
490
+ current = await ThemeGuard._fetch_theme(ws)
491
+ expected = None if expected_current is _UNSET else expected_current
492
+ if current != expected:
493
+ raise ThemeChangedError(current)
494
+ await ws.send_command(
495
+ "frontend/set_user_data",
496
+ key=THEME_USER_DATA_KEY,
497
+ value=value,
498
+ _wait_timeout=COMMAND_TIMEOUT_SECONDS,
499
+ )
500
+
501
+ await _write()
@@ -153,6 +153,19 @@ def _updates_write(args: dict[str, Any]) -> str | None:
153
153
  return f"action={action!r}"
154
154
 
155
155
 
156
+ def _theme_write(args: dict[str, Any]) -> str | None:
157
+ # Reads (allowed): listing installed themes and inspecting the screenshot
158
+ # engine account's saved per-user theme. The latter has no pure-read
159
+ # duplicate anywhere, and it is exactly what a caller needs in Read Only
160
+ # Mode: captures still make the engine persist a theme and still warn about
161
+ # it, so the value must stay inspectable even when restoring it is blocked.
162
+ # Both writes -- the backend default ('set') and the engine-account restore
163
+ # ('set_engine_theme') -- stay blocked. A missing action fails closed.
164
+ if args.get("action") in ("list", "get_engine_theme"):
165
+ return None
166
+ return f"action={args.get('action')!r}"
167
+
168
+
156
169
  def _radio_write(args: dict[str, Any]) -> str | None:
157
170
  action = args.get("action")
158
171
  # Reads (allowed): per-node diagnostics, the integration/network summary,
@@ -195,6 +208,11 @@ def _radio_write(args: dict[str, Any]) -> str | None:
195
208
  # against the real registered catalog at PR time, so the two sets
196
209
  # cannot drift apart silently.
197
210
  READ_ONLY_EXEMPT_TOOLS: dict[str, ReadOnlyExemption] = {
211
+ "ha_manage_theme": ReadOnlyExemption(
212
+ _theme_write,
213
+ "listing installed themes (action='list') and reading the screenshot "
214
+ "engine account's saved theme (action='get_engine_theme')",
215
+ ),
198
216
  "ha_manage_backup": ReadOnlyExemption(
199
217
  _backup_write,
200
218
  "listing, viewing, and diffing per-edit backups (scope='edits', "
@@ -196,16 +196,20 @@ class DashboardScreenshotTools:
196
196
  str | None,
197
197
  Field(
198
198
  description="Installed Home Assistant frontend theme name, "
199
- "applied to this render. The engine user's saved theme "
200
- "preference is restored after the capture (best effort)."
199
+ "applied to this render. The engine persists this on the "
200
+ "engine account's profile; this tool reports the change in "
201
+ "warnings but does not undo it (see ha_manage_theme "
202
+ "action='set_engine_theme')."
201
203
  ),
202
204
  ] = None,
203
205
  dark_mode: Annotated[
204
206
  bool,
205
207
  Field(
206
208
  description="Render the requested theme in dark mode, applied "
207
- "to this render. The engine user's saved theme preference is "
208
- "restored after the capture (best effort)."
209
+ "to this render. The engine persists this on the engine "
210
+ "account's profile; this tool reports the change in warnings "
211
+ "but does not undo it (see ha_manage_theme "
212
+ "action='set_engine_theme')."
209
213
  ),
210
214
  ] = False,
211
215
  language: Annotated[