ha-mcp-dev 7.12.3.dev1877__tar.gz → 7.12.3.dev1882__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ha_mcp_dev-7.12.3.dev1877/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.12.3.dev1882}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/pyproject.toml +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/rest_client.py +12 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/websocket_client.py +24 -22
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/value_sources.py +17 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/server.py +16 -0
- ha_mcp_dev-7.12.3.dev1882/src/ha_mcp/tools/component_api.py +267 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/config_entry_flow.py +177 -38
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_deep.py +127 -11
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_entities.py +94 -33
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_overview.py +40 -16
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_scenes.py +21 -8
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_config_automations.py +43 -20
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_config_helpers.py +293 -6
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_config_scenes.py +48 -26
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_config_scripts.py +58 -34
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_integrations.py +202 -48
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_search.py +995 -190
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/visibility/persistence.py +24 -2
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/visibility/resolver.py +84 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/setup.cfg +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.12.3.
|
|
7
|
+
version = "7.12.3.dev1882"
|
|
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
|
|
@@ -86,8 +86,20 @@ class HomeAssistantCommandError(HomeAssistantError):
|
|
|
86
86
|
``_classify_exception``'s match dispatch; classification then falls
|
|
87
87
|
through to ``_classify_by_message`` for pattern matching on the
|
|
88
88
|
error message.
|
|
89
|
+
|
|
90
|
+
``code`` carries HA's structured WebSocket error code (the ``code`` field
|
|
91
|
+
of the response ``error`` object — e.g. ``"unknown_command"``,
|
|
92
|
+
``"invalid_format"``) when available, so routing decisions can key off the
|
|
93
|
+
stable code rather than pattern-matching the human-readable message. It
|
|
94
|
+
defaults to ``None`` for exceptions raised without a code (older raise
|
|
95
|
+
sites, string error payloads, or direct construction in tests), which
|
|
96
|
+
keeps the single-positional-argument constructor backward-compatible.
|
|
89
97
|
"""
|
|
90
98
|
|
|
99
|
+
def __init__(self, message: str, code: str | None = None):
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.code = code
|
|
102
|
+
|
|
91
103
|
|
|
92
104
|
class HomeAssistantCommandTimeout(HomeAssistantError):
|
|
93
105
|
"""WebSocket ``send_command`` timed out waiting for HA's response.
|
{ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1882}/src/ha_mcp/client/websocket_client.py
RENAMED
|
@@ -40,6 +40,20 @@ logger = logging.getLogger(__name__)
|
|
|
40
40
|
MAX_WS_MESSAGE_BYTES = 64 * 1024 * 1024
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _extract_ws_error(error: Any) -> tuple[str, str | None]:
|
|
44
|
+
"""Split an HA WebSocket ``error`` payload into ``(message, code)``.
|
|
45
|
+
|
|
46
|
+
HA replies to a failed command with ``{"error": {"code": ..., "message":
|
|
47
|
+
...}}``. Dict payloads yield both fields; a bare string (or other shape)
|
|
48
|
+
yields ``str(error)`` as the message and ``None`` for the code. The code is
|
|
49
|
+
threaded onto ``HomeAssistantCommandError`` so callers route on the stable
|
|
50
|
+
``code`` (e.g. ``unknown_command``) instead of the message text.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(error, dict):
|
|
53
|
+
return error.get("message", str(error)), error.get("code")
|
|
54
|
+
return str(error), None
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
class WebSocketConnectionState:
|
|
44
58
|
"""Encapsulates mutable state used by the WebSocket client."""
|
|
45
59
|
|
|
@@ -632,13 +646,10 @@ class HomeAssistantWebSocketClient:
|
|
|
632
646
|
# Process standard Home Assistant WebSocket response
|
|
633
647
|
if response.get("type") == "result":
|
|
634
648
|
if response.get("success") is False:
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if isinstance(error, dict)
|
|
639
|
-
else str(error)
|
|
649
|
+
error_msg, error_code = _extract_ws_error(response.get("error", {}))
|
|
650
|
+
raise HomeAssistantCommandError(
|
|
651
|
+
f"Command failed: {error_msg}", error_code
|
|
640
652
|
)
|
|
641
|
-
raise HomeAssistantCommandError(f"Command failed: {error_msg}")
|
|
642
653
|
|
|
643
654
|
# Return success response according to HA WebSocket format
|
|
644
655
|
return {
|
|
@@ -715,13 +726,8 @@ class HomeAssistantWebSocketClient:
|
|
|
715
726
|
|
|
716
727
|
if not result_response.get("success"):
|
|
717
728
|
self.cancel_event_response(message_id)
|
|
718
|
-
|
|
719
|
-
error_msg
|
|
720
|
-
error.get("message", str(error))
|
|
721
|
-
if isinstance(error, dict)
|
|
722
|
-
else str(error)
|
|
723
|
-
)
|
|
724
|
-
raise HomeAssistantCommandError(f"Command failed: {error_msg}")
|
|
729
|
+
error_msg, error_code = _extract_ws_error(result_response.get("error", {}))
|
|
730
|
+
raise HomeAssistantCommandError(f"Command failed: {error_msg}", error_code)
|
|
725
731
|
|
|
726
732
|
try:
|
|
727
733
|
event_response = await asyncio.wait_for(event_future, timeout=wait_timeout)
|
|
@@ -787,11 +793,10 @@ class HomeAssistantWebSocketClient:
|
|
|
787
793
|
if response.get("type") == "result" and response.get("success"):
|
|
788
794
|
return message_id
|
|
789
795
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
796
|
+
error_msg, error_code = _extract_ws_error(response.get("error", {}))
|
|
797
|
+
raise HomeAssistantCommandError(
|
|
798
|
+
f"subscribe_events failed: {error_msg}", error_code
|
|
793
799
|
)
|
|
794
|
-
raise HomeAssistantCommandError(f"subscribe_events failed: {error_msg}")
|
|
795
800
|
|
|
796
801
|
async def unsubscribe_events(self, subscription_id: int) -> None:
|
|
797
802
|
"""Release a subscription previously returned by ``subscribe_events``.
|
|
@@ -885,12 +890,9 @@ class HomeAssistantWebSocketClient:
|
|
|
885
890
|
return message_id, queue
|
|
886
891
|
|
|
887
892
|
self._state.unregister_subscription_queue(message_id)
|
|
888
|
-
|
|
889
|
-
error_msg = (
|
|
890
|
-
error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
891
|
-
)
|
|
893
|
+
error_msg, error_code = _extract_ws_error(response.get("error", {}))
|
|
892
894
|
raise HomeAssistantCommandError(
|
|
893
|
-
f"subscribe_command({command_type!r}) failed: {error_msg}"
|
|
895
|
+
f"subscribe_command({command_type!r}) failed: {error_msg}", error_code
|
|
894
896
|
)
|
|
895
897
|
|
|
896
898
|
async def unsubscribe_command(
|
|
@@ -40,7 +40,7 @@ VALUE_SOURCE_REGISTRY: dict[tuple[str, str], str] = {
|
|
|
40
40
|
("ha_get_history", "args.entity_ids"): "ha_entities",
|
|
41
41
|
("ha_remove_entity", "args.entity_id"): "ha_entities",
|
|
42
42
|
("ha_get_entity_exposure", "args.entity_id"): "ha_entities",
|
|
43
|
-
("
|
|
43
|
+
("ha_set_integration", "args.entry_id"): "ha_config_entries",
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
# Fetched-value TTL cache so the UI can click through path options
|
|
@@ -171,8 +171,24 @@ async def _fetch_ha_entities(client: Any, params: dict[str, str]) -> list[str]:
|
|
|
171
171
|
return out
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
async def _fetch_ha_config_entries(client: Any, _params: dict[str, str]) -> list[str]:
|
|
175
|
+
entries = await client._request("GET", "/config/config_entries/entry")
|
|
176
|
+
if not isinstance(entries, list):
|
|
177
|
+
logger.warning(
|
|
178
|
+
"ha_config_entries: config entry list returned unexpected shape %s",
|
|
179
|
+
type(entries).__name__,
|
|
180
|
+
)
|
|
181
|
+
return []
|
|
182
|
+
return [
|
|
183
|
+
e["entry_id"]
|
|
184
|
+
for e in entries
|
|
185
|
+
if isinstance(e, dict) and isinstance(e.get("entry_id"), str)
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
174
189
|
_FETCHERS: dict[str, Callable[[Any, dict[str, str]], Awaitable[list[str]]]] = {
|
|
175
190
|
"ha_domains": _fetch_ha_domains,
|
|
176
191
|
"ha_services": _fetch_ha_services,
|
|
177
192
|
"ha_entities": _fetch_ha_entities,
|
|
193
|
+
"ha_config_entries": _fetch_ha_config_entries,
|
|
178
194
|
}
|
|
@@ -728,6 +728,22 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
|
|
|
728
728
|
"For routing guidance and the full allowlist, see "
|
|
729
729
|
"ha_get_skill_guide."
|
|
730
730
|
),
|
|
731
|
+
"ha_search": (
|
|
732
|
+
"Search Home Assistant for entities (by name, domain, or area) AND "
|
|
733
|
+
"inside automation/script/scene/helper/dashboard configs, in one "
|
|
734
|
+
"call. Returns tagged buckets — `entities` plus per-config-type "
|
|
735
|
+
"lists — paginated per-surface and combined "
|
|
736
|
+
"(`has_more`/`next_offset`).\n\n"
|
|
737
|
+
"Config-body search is skipped when domain/area/state filters signal "
|
|
738
|
+
"entity-only intent; a warning names the skip — pass `search_types` "
|
|
739
|
+
"to force it.\n\n"
|
|
740
|
+
"`partial: True` means results are NOT exhaustive: a surface failed "
|
|
741
|
+
"or the config branch lost data. Empty buckets with `partial: True` "
|
|
742
|
+
'mean "search failed", not "no results" — see `partial_reason` '
|
|
743
|
+
"(also mirrored into `warnings`). Do not treat a partial response as "
|
|
744
|
+
"complete.\n\n"
|
|
745
|
+
"For parameters, schema, and examples, see ha_get_skill_guide."
|
|
746
|
+
),
|
|
731
747
|
}
|
|
732
748
|
|
|
733
749
|
# Description overrides that REPLACE the original description for BM25.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Capability gate for the ``ha_mcp_tools`` custom-component WebSocket API.
|
|
2
|
+
|
|
3
|
+
The custom component (``custom_components/ha_mcp_tools/``, manifest 1.1.0+)
|
|
4
|
+
registers in-process WebSocket commands under the ``ha_mcp_tools/`` namespace
|
|
5
|
+
that let the server answer read queries (search, config-get, overview, ...)
|
|
6
|
+
from HA's live registries in a single round-trip, instead of the multi-fetch
|
|
7
|
+
REST/WS pipelines the server uses today.
|
|
8
|
+
|
|
9
|
+
Because the component ships over HACS on its own release cadence, a given
|
|
10
|
+
install may be at any version — old (no WS surface at all), new (full surface),
|
|
11
|
+
or somewhere in between. This module negotiates that with a single cached
|
|
12
|
+
``ha_mcp_tools/info`` probe per client:
|
|
13
|
+
|
|
14
|
+
- ``info`` enumerates the ``capabilities`` the running component actually
|
|
15
|
+
registered. The server checks ``component_supports(caps, "<capability>")``
|
|
16
|
+
before routing a tool through the component, so a capability that a released
|
|
17
|
+
component hasn't shipped yet simply never gets used — no version lockstep.
|
|
18
|
+
- If ``info`` itself is ``unknown_command``, the component predates the WS
|
|
19
|
+
surface entirely; caps are cached as ``None`` and every consumer falls back
|
|
20
|
+
to its legacy path.
|
|
21
|
+
|
|
22
|
+
This mirrors the caller-token cache in ``tools_filesystem.py``: weak-keyed by
|
|
23
|
+
client so multi-client / OAuth setups each negotiate independently and the
|
|
24
|
+
entry self-evicts when a client is garbage-collected.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
import weakref
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ..client.rest_client import (
|
|
37
|
+
HomeAssistantCommandError,
|
|
38
|
+
HomeAssistantCommandTimeout,
|
|
39
|
+
HomeAssistantConnectionError,
|
|
40
|
+
)
|
|
41
|
+
from ..client.websocket_client import get_websocket_client
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# The WS command every 1.1.0+ component registers; probing it does double duty
|
|
46
|
+
# (existence check + capability enumeration — see module docstring).
|
|
47
|
+
INFO_COMMAND = "ha_mcp_tools/info"
|
|
48
|
+
|
|
49
|
+
# HA's structured error code for a command no handler is registered for. Routing
|
|
50
|
+
# keys off this (via the ``code`` attribute threaded onto
|
|
51
|
+
# ``HomeAssistantCommandError``) rather than matching the message text.
|
|
52
|
+
UNKNOWN_COMMAND_CODE = "unknown_command"
|
|
53
|
+
|
|
54
|
+
# The wire-format generation this server speaks. A probe advertising any other
|
|
55
|
+
# ``schema_version`` is treated as no-caps (see ``get_component_caps``): the
|
|
56
|
+
# routing gate can't trust command payloads shaped for a different generation.
|
|
57
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
58
|
+
|
|
59
|
+
# Negative (None) caps entries expire after this many seconds of monotonic
|
|
60
|
+
# time, so a component installed / upgraded mid-session is re-probed and
|
|
61
|
+
# adopted instead of being pinned to "absent" for the whole process lifetime
|
|
62
|
+
# (the cache key is the process-lifetime REST client — see ``_CAPS_CACHE``).
|
|
63
|
+
# Positive entries never expire on this timer; they are dropped only by
|
|
64
|
+
# ``invalidate_caps`` (a supposedly-supported command coming back
|
|
65
|
+
# ``unknown_command``).
|
|
66
|
+
_NEGATIVE_CACHE_TTL_S = 300.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _monotonic() -> float:
|
|
70
|
+
"""Monotonic clock read, isolated so tests can advance it deterministically."""
|
|
71
|
+
return time.monotonic()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ComponentCaps:
|
|
76
|
+
"""Snapshot of what the running ``ha_mcp_tools`` component can serve.
|
|
77
|
+
|
|
78
|
+
``capabilities`` is the routing gate — the set of ``ha_mcp_tools/*``
|
|
79
|
+
commands the component actually registered. ``schema_version`` guards the
|
|
80
|
+
wire-format generation of those commands (a consumer needing a reshaped
|
|
81
|
+
payload checks ``schema_version >= N``). ``component_version`` and
|
|
82
|
+
``limits`` are advisory (display / body-size caps).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
schema_version: int
|
|
86
|
+
component_version: str
|
|
87
|
+
capabilities: frozenset[str]
|
|
88
|
+
limits: dict[str, Any]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Weak-keyed by client so the negotiated caps self-evict when the client is
|
|
92
|
+
# garbage-collected. The key is the process-lifetime REST client, NOT the WS
|
|
93
|
+
# connection: an HA restart drops the socket but reuses the same pooled client,
|
|
94
|
+
# so a positive entry persists for the whole process (dropped only by
|
|
95
|
+
# ``invalidate_caps`` on an ``unknown_command``). A ``None`` value is a cached
|
|
96
|
+
# *negative* ("probed, no usable WS surface"); absence means "not yet probed".
|
|
97
|
+
# Negatives carry an expiry in ``_NEGATIVE_CACHE_TS`` so a component installed /
|
|
98
|
+
# upgraded mid-session is eventually re-probed instead of pinned absent forever.
|
|
99
|
+
_CAPS_CACHE: weakref.WeakKeyDictionary[Any, ComponentCaps | None] = (
|
|
100
|
+
weakref.WeakKeyDictionary()
|
|
101
|
+
)
|
|
102
|
+
# Monotonic timestamp a negative entry was stored, keyed by the same client.
|
|
103
|
+
# Only populated for ``None`` values; positive entries never appear here.
|
|
104
|
+
_NEGATIVE_CACHE_TS: weakref.WeakKeyDictionary[Any, float] = weakref.WeakKeyDictionary()
|
|
105
|
+
_CAPS_LOCKS: weakref.WeakKeyDictionary[Any, asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_caps_lock(client: Any) -> asyncio.Lock:
|
|
109
|
+
"""Per-client lock so concurrent first-callers probe ``info`` exactly once."""
|
|
110
|
+
lock = _CAPS_LOCKS.get(client)
|
|
111
|
+
if lock is None:
|
|
112
|
+
lock = asyncio.Lock()
|
|
113
|
+
_CAPS_LOCKS[client] = lock
|
|
114
|
+
return lock
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _live_cache_entry(client: Any) -> tuple[bool, ComponentCaps | None]:
|
|
118
|
+
"""Return ``(hit, caps)`` for a still-valid cache entry, else ``(False, None)``.
|
|
119
|
+
|
|
120
|
+
A positive entry is a hit for the process lifetime. A negative (``None``)
|
|
121
|
+
entry is a hit only within ``_NEGATIVE_CACHE_TTL_S`` of when it was stored
|
|
122
|
+
(monotonic clock); once that window lapses it reports a miss so the caller
|
|
123
|
+
re-probes and can adopt a component that appeared mid-session.
|
|
124
|
+
"""
|
|
125
|
+
if client not in _CAPS_CACHE:
|
|
126
|
+
return False, None
|
|
127
|
+
cached = _CAPS_CACHE[client]
|
|
128
|
+
if cached is not None:
|
|
129
|
+
return True, cached
|
|
130
|
+
stored_at = _NEGATIVE_CACHE_TS.get(client)
|
|
131
|
+
if stored_at is not None and (_monotonic() - stored_at) < _NEGATIVE_CACHE_TTL_S:
|
|
132
|
+
return True, None
|
|
133
|
+
return False, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _store_caps(client: Any, caps: ComponentCaps | None) -> None:
|
|
137
|
+
"""Cache ``caps`` for ``client``, stamping a negative with the current clock."""
|
|
138
|
+
_CAPS_CACHE[client] = caps
|
|
139
|
+
if caps is None:
|
|
140
|
+
_NEGATIVE_CACHE_TS[client] = _monotonic()
|
|
141
|
+
else:
|
|
142
|
+
_NEGATIVE_CACHE_TS.pop(client, None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_caps(response: Any) -> ComponentCaps | None:
|
|
146
|
+
"""Map an ``ha_mcp_tools/info`` response into ``ComponentCaps``.
|
|
147
|
+
|
|
148
|
+
Returns ``None`` for a malformed payload — the command responded, so this
|
|
149
|
+
is still a stable negative worth caching, just not a usable capability set.
|
|
150
|
+
"""
|
|
151
|
+
result = response.get("result") if isinstance(response, dict) else None
|
|
152
|
+
if not isinstance(result, dict):
|
|
153
|
+
return None
|
|
154
|
+
raw_caps = result.get("capabilities")
|
|
155
|
+
capabilities = (
|
|
156
|
+
frozenset(c for c in raw_caps if isinstance(c, str))
|
|
157
|
+
if isinstance(raw_caps, list)
|
|
158
|
+
else frozenset()
|
|
159
|
+
)
|
|
160
|
+
raw_limits = result.get("limits")
|
|
161
|
+
try:
|
|
162
|
+
schema_version = int(result.get("schema_version", 0) or 0)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
schema_version = 0
|
|
165
|
+
return ComponentCaps(
|
|
166
|
+
schema_version=schema_version,
|
|
167
|
+
component_version=str(result.get("component_version", "")),
|
|
168
|
+
capabilities=capabilities,
|
|
169
|
+
limits=raw_limits if isinstance(raw_limits, dict) else {},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def get_component_caps(client: Any) -> ComponentCaps | None:
|
|
174
|
+
"""Return the cached (or freshly probed) capabilities of the component.
|
|
175
|
+
|
|
176
|
+
One ``ha_mcp_tools/info`` probe per client, cached. ``None`` means "no
|
|
177
|
+
usable component WS surface" — the caller falls back to its legacy path.
|
|
178
|
+
|
|
179
|
+
Cache-on-failure semantics follow the error taxonomy:
|
|
180
|
+
|
|
181
|
+
- ``HomeAssistantCommandError`` (``info`` is ``unknown_command`` on an old
|
|
182
|
+
component, or the handler raised): cache ``None`` with an expiry. The
|
|
183
|
+
negative is re-probed after ``_NEGATIVE_CACHE_TTL_S`` so a component
|
|
184
|
+
installed / upgraded mid-session (the REST client — the cache key — is not
|
|
185
|
+
recreated on an HA restart) is eventually adopted instead of pinned absent.
|
|
186
|
+
- ``HomeAssistantConnectionError`` / ``HomeAssistantCommandTimeout`` (WS
|
|
187
|
+
down or slow): do **not** cache — re-probe on the next call once the
|
|
188
|
+
connection recovers. The consuming tool's legacy path will surface the
|
|
189
|
+
transport failure on its own.
|
|
190
|
+
- No credentials on the client (a bare test double with no ``base_url`` /
|
|
191
|
+
``token``): nothing to probe; return ``None`` without caching.
|
|
192
|
+
- Any other unexpected exception: logged at debug, returns ``None`` without
|
|
193
|
+
caching (a transient runtime fault re-probes on the next call).
|
|
194
|
+
|
|
195
|
+
A probe whose ``schema_version`` is not ``SUPPORTED_SCHEMA_VERSION`` is
|
|
196
|
+
treated as no-caps (cached negative, logged once): the server can't trust
|
|
197
|
+
command payloads shaped for a different wire-format generation.
|
|
198
|
+
"""
|
|
199
|
+
hit, caps = _live_cache_entry(client)
|
|
200
|
+
if hit:
|
|
201
|
+
return caps
|
|
202
|
+
|
|
203
|
+
async with _get_caps_lock(client):
|
|
204
|
+
hit, caps = _live_cache_entry(client)
|
|
205
|
+
if hit:
|
|
206
|
+
return caps
|
|
207
|
+
|
|
208
|
+
base_url = getattr(client, "base_url", None)
|
|
209
|
+
token = getattr(client, "token", None)
|
|
210
|
+
if not base_url or not token:
|
|
211
|
+
# Not a credentialed HA client — no WS connection to negotiate over.
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
ws = await get_websocket_client(url=base_url, token=token)
|
|
216
|
+
response = await ws.send_command(INFO_COMMAND)
|
|
217
|
+
except HomeAssistantCommandError:
|
|
218
|
+
# Old component (unknown_command) or an info handler bug: the
|
|
219
|
+
# component can't serve WS commands. Cache the negative.
|
|
220
|
+
_store_caps(client, None)
|
|
221
|
+
return None
|
|
222
|
+
except (HomeAssistantConnectionError, HomeAssistantCommandTimeout):
|
|
223
|
+
logger.debug(
|
|
224
|
+
"%s probe skipped: WS unavailable", INFO_COMMAND, exc_info=True
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
except Exception:
|
|
228
|
+
logger.debug("%s probe failed unexpectedly", INFO_COMMAND, exc_info=True)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
caps = _parse_caps(response)
|
|
232
|
+
if caps is not None and caps.schema_version != SUPPORTED_SCHEMA_VERSION:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"ha_mcp_tools component schema_version %s unsupported "
|
|
235
|
+
"(server supports %s); routing no commands through the component",
|
|
236
|
+
caps.schema_version,
|
|
237
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
238
|
+
)
|
|
239
|
+
caps = None
|
|
240
|
+
_store_caps(client, caps)
|
|
241
|
+
return caps
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def component_supports(caps: ComponentCaps | None, capability: str) -> bool:
|
|
245
|
+
"""Return True when the component advertised ``capability``."""
|
|
246
|
+
return caps is not None and capability in caps.capabilities
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_unknown_command(exc: Exception) -> bool:
|
|
250
|
+
"""Return True when ``exc`` is HA's ``unknown_command`` rejection.
|
|
251
|
+
|
|
252
|
+
Keys off the structured ``code`` threaded onto ``HomeAssistantCommandError``
|
|
253
|
+
(never the message text), so a downgraded component that drops a command
|
|
254
|
+
routes cleanly to the legacy fallback.
|
|
255
|
+
"""
|
|
256
|
+
return getattr(exc, "code", None) == UNKNOWN_COMMAND_CODE
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def invalidate_caps(client: Any) -> None:
|
|
260
|
+
"""Drop the cached caps so the next call re-probes ``ha_mcp_tools/info``.
|
|
261
|
+
|
|
262
|
+
Called when a command believed to be supported comes back
|
|
263
|
+
``unknown_command`` (e.g. the component was downgraded mid-session), so the
|
|
264
|
+
stale positive doesn't keep routing to a dead command.
|
|
265
|
+
"""
|
|
266
|
+
_CAPS_CACHE.pop(client, None)
|
|
267
|
+
_NEGATIVE_CACHE_TS.pop(client, None)
|