ha-mcp-dev 8.3.0.dev2413__tar.gz → 8.3.0.dev2415__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ha_mcp_dev-8.3.0.dev2413/src/ha_mcp_dev.egg-info → ha_mcp_dev-8.3.0.dev2415}/PKG-INFO +1 -1
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/pyproject.toml +1 -1
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/capture.py +14 -19
- ha_mcp_dev-8.3.0.dev2415/src/ha_mcp/dashboard_screenshot/theme_guard.py +501 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/read_only.py +18 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_dashboard_screenshot.py +8 -4
- ha_mcp_dev-8.3.0.dev2415/src/ha_mcp/tools/tools_themes.py +401 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- ha_mcp_dev-8.3.0.dev2413/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -234
- ha_mcp_dev-8.3.0.dev2413/src/ha_mcp/tools/tools_themes.py +0 -192
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/LICENSE +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/MANIFEST.in +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/README.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/setup.cfg +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_rich_compat.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/requirements.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/LICENSE +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/MANIFEST.sha256 +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/VENDORED +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/__main__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/connection.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/messages.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/router.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/asyncio/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/auth.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/cli.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/connection.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/datastructures.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/exceptions.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/extensions/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/extensions/base.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/extensions/permessage_deflate.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/frames.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/headers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/http11.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/imports.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/auth.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/exceptions.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/framing.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/handshake.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/http.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/protocol.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/legacy/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/protocol.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/proxy.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/py.typed +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/streams.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/connection.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/messages.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/router.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/sync/utils.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/connection.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/messages.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/router.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/trio/utils.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/typing.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/uri.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/utils.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_vendor/websockets/version.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/auth/oidc_compat.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/hacs_auto_refresh.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/editing.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/redaction.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/renamed_tools.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_backups.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_handlers_tools.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_i18n.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_persistence.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_theme.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/README.md +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/cs.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/de.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/en.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/eo.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/es.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/fr.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/it.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/ko.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/nl.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/pl.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/ru.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/sv.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/locales/zh-Hans.json +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/strict_bps.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/best_practice_result.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/best_practice_variables.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/bulk_selector.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_api.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_config_entries.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_config_reads.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_devices.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_registries.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_flow_form.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_flow_menu.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_flow_walker.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_identity.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_reconfigure.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/config_entry_reload_watch.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/error_log_parsing.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/hacs_registration.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/integration_reconfigure.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/renamed_tool_middleware.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_assist_pipeline.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_security_policy.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/config_write_lock.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/entity_membership.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/visibility/enforcement.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/visibility/persistence.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/visibility/resolver.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/tests/__init__.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/tests/test_constants.py +0 -0
- {ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "8.3.0.
|
|
7
|
+
version = "8.3.0.dev2415"
|
|
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
|
{ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/dashboard_screenshot/capture.py
RENAMED
|
@@ -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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
#
|
|
895
|
-
#
|
|
896
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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', "
|
{ha_mcp_dev-8.3.0.dev2413 → ha_mcp_dev-8.3.0.dev2415}/src/ha_mcp/tools/tools_dashboard_screenshot.py
RENAMED
|
@@ -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
|
|
200
|
-
"
|
|
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
|
|
208
|
-
"
|
|
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[
|