ha-mcp-dev 7.12.3.dev1877__tar.gz → 7.12.3.dev1879__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.dev1879}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/pyproject.toml +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/client/rest_client.py +12 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/client/websocket_client.py +24 -22
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/server.py +16 -0
- ha_mcp_dev-7.12.3.dev1879/src/ha_mcp/tools/component_api.py +267 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_deep.py +127 -11
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_entities.py +94 -33
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_overview.py +40 -16
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_scenes.py +21 -8
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_config_automations.py +43 -20
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_config_helpers.py +293 -6
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_config_scenes.py +48 -26
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_config_scripts.py +58 -34
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_search.py +995 -190
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/visibility/persistence.py +24 -2
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/visibility/resolver.py +84 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/setup.cfg +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/llm_exposure.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/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.dev1879}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_dev.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/visibility/model.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/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.dev1879"
|
|
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.dev1879}/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(
|
|
@@ -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)
|
{ha_mcp_dev-7.12.3.dev1877 → ha_mcp_dev-7.12.3.dev1879}/src/ha_mcp/tools/smart_search/_deep.py
RENAMED
|
@@ -41,6 +41,9 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
41
41
|
exact_match: bool = True,
|
|
42
42
|
config_time_budget: float | None = None,
|
|
43
43
|
ctx: Context | None = None,
|
|
44
|
+
*,
|
|
45
|
+
prefetched_states: list[dict[str, Any]] | None = None,
|
|
46
|
+
prefetched_registry: Any = None,
|
|
44
47
|
) -> dict[str, Any]:
|
|
45
48
|
"""
|
|
46
49
|
Deep search across automation, script, scene, helper, and dashboard
|
|
@@ -58,6 +61,17 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
58
61
|
include_config: Include full config in results (default: False)
|
|
59
62
|
concurrency_limit: Max concurrent API calls for config fetching
|
|
60
63
|
exact_match: Use exact substring matching (default: True). Set False for fuzzy.
|
|
64
|
+
prefetched_states: Pre-fetched ``get_states()`` list shared by the
|
|
65
|
+
ha_search orchestrator when both search branches run; ``None``
|
|
66
|
+
means fetch here.
|
|
67
|
+
prefetched_registry: Pre-fetched ``config/entity_registry/list``
|
|
68
|
+
response threaded to the scene registry walk *and* the helper
|
|
69
|
+
name-staleness map. ``None`` means the scene walk fetches it
|
|
70
|
+
itself; the helper branch degrades to storage-name matching
|
|
71
|
+
(it never fetches the registry on its own — see
|
|
72
|
+
``_deep_search_helpers``). The orchestrator only hands one down
|
|
73
|
+
when both the entity and config-body branches run, so direct
|
|
74
|
+
``deep_search``/``ha_deep_search`` callers get ``None`` here.
|
|
61
75
|
|
|
62
76
|
Returns:
|
|
63
77
|
Dictionary with search results grouped by type
|
|
@@ -87,8 +101,14 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
87
101
|
message="fetching entity states",
|
|
88
102
|
)
|
|
89
103
|
|
|
90
|
-
# Fetch all entities once at the beginning to avoid repeated calls
|
|
91
|
-
|
|
104
|
+
# Fetch all entities once at the beginning to avoid repeated calls.
|
|
105
|
+
# The ha_search orchestrator may hand us a snapshot it already fetched
|
|
106
|
+
# for the entity branch so the two branches share one /api/states.
|
|
107
|
+
all_entities = (
|
|
108
|
+
prefetched_states
|
|
109
|
+
if prefetched_states is not None
|
|
110
|
+
else await self.client.get_states()
|
|
111
|
+
)
|
|
92
112
|
phase_done = 1
|
|
93
113
|
await safe_progress(
|
|
94
114
|
ctx,
|
|
@@ -192,6 +212,7 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
192
212
|
query_lower,
|
|
193
213
|
exact_match,
|
|
194
214
|
config_time_budget=config_time_budget,
|
|
215
|
+
prefetched_registry=prefetched_registry,
|
|
195
216
|
)
|
|
196
217
|
phase_done += 1
|
|
197
218
|
await safe_progress(
|
|
@@ -206,7 +227,11 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
206
227
|
results["helpers"],
|
|
207
228
|
helper_failed,
|
|
208
229
|
) = await self._deep_search_helpers(
|
|
209
|
-
query_lower,
|
|
230
|
+
query_lower,
|
|
231
|
+
exact_match,
|
|
232
|
+
semaphore,
|
|
233
|
+
include_config,
|
|
234
|
+
prefetched_registry=prefetched_registry,
|
|
210
235
|
)
|
|
211
236
|
phase_done += 1
|
|
212
237
|
await safe_progress(
|
|
@@ -572,12 +597,56 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
572
597
|
]
|
|
573
598
|
return matches, skipped_count, failed_count, yaml_skipped_count, timeout_count
|
|
574
599
|
|
|
600
|
+
@staticmethod
|
|
601
|
+
def _build_helper_registry_map(
|
|
602
|
+
prefetched_registry: Any,
|
|
603
|
+
helper_types: set[str],
|
|
604
|
+
) -> dict[str, tuple[str, str | None]]:
|
|
605
|
+
"""Map each input_* helper's storage ``unique_id`` to current ``(entity_id, name)``.
|
|
606
|
+
|
|
607
|
+
Derived from the entity-registry snapshot the ha_search orchestrator
|
|
608
|
+
already fetched — never fetched here, so the helper branch adds no
|
|
609
|
+
request (see ``_deep_search_helpers``). A UI rename updates only the
|
|
610
|
+
entity registry, so this lets helper scoring match a helper's CURRENT
|
|
611
|
+
entity_id and name while the ``<type>/list`` storage record still
|
|
612
|
+
carries its creation-time slug/name (issue #1794). For a storage helper
|
|
613
|
+
the registry ``unique_id`` equals the ``<type>/list`` record ``id`` and
|
|
614
|
+
``platform`` equals the helper domain (e.g. ``input_boolean``); the
|
|
615
|
+
current name is the registry ``name`` (user override) falling back to
|
|
616
|
+
``original_name`` — left ``None`` when both are absent so the caller
|
|
617
|
+
keeps the storage name. Entries with no usable ``unique_id`` /
|
|
618
|
+
``entity_id``, a non-input_* platform, or a non-dict shape are skipped.
|
|
619
|
+
|
|
620
|
+
Returns ``{}`` when no snapshot was handed down (direct ``deep_search``
|
|
621
|
+
callers) or the snapshot is a soft failure — the caller then degrades
|
|
622
|
+
to storage-name-only matching.
|
|
623
|
+
"""
|
|
624
|
+
if not (
|
|
625
|
+
isinstance(prefetched_registry, dict) and prefetched_registry.get("success")
|
|
626
|
+
):
|
|
627
|
+
return {}
|
|
628
|
+
out: dict[str, tuple[str, str | None]] = {}
|
|
629
|
+
for entry in prefetched_registry.get("result") or []:
|
|
630
|
+
if not isinstance(entry, dict):
|
|
631
|
+
continue
|
|
632
|
+
if entry.get("platform") not in helper_types:
|
|
633
|
+
continue
|
|
634
|
+
uid = entry.get("unique_id")
|
|
635
|
+
entity_id = entry.get("entity_id")
|
|
636
|
+
if not uid or not entity_id:
|
|
637
|
+
continue
|
|
638
|
+
current_name = entry.get("name") or entry.get("original_name")
|
|
639
|
+
out[uid] = (entity_id, current_name)
|
|
640
|
+
return out
|
|
641
|
+
|
|
575
642
|
async def _search_helper_type(
|
|
576
643
|
self,
|
|
577
644
|
helper_type: str,
|
|
578
645
|
query_lower: str,
|
|
579
646
|
exact_match: bool,
|
|
580
647
|
semaphore: asyncio.Semaphore,
|
|
648
|
+
*,
|
|
649
|
+
registry_by_uid: dict[str, tuple[str, str | None]] | None = None,
|
|
581
650
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
582
651
|
"""Fetch one input_* helper type via WS list and return query matches.
|
|
583
652
|
|
|
@@ -589,7 +658,20 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
589
658
|
swallowed to an empty list: otherwise the caller cannot tell a real
|
|
590
659
|
zero-match from a partial backend outage (helpers run on every
|
|
591
660
|
default ``ha_search`` call).
|
|
661
|
+
|
|
662
|
+
``registry_by_uid`` maps a helper's storage ``unique_id`` to its
|
|
663
|
+
CURRENT ``(entity_id, name)`` from the entity registry (built by
|
|
664
|
+
``_deep_search_helpers`` from the orchestrator's registry snapshot). A
|
|
665
|
+
UI rename updates only the registry, so the ``<type>/list`` record
|
|
666
|
+
still carries the creation-time entity_id slug and name (issue #1794);
|
|
667
|
+
when the map has an entry we score and emit the current values so a
|
|
668
|
+
search for the renamed name/entity_id matches. The storage name is
|
|
669
|
+
still scored (and the storage body still searched below), so config
|
|
670
|
+
references and un-renamed helpers never regress. ``None`` (direct
|
|
671
|
+
``deep_search`` callers with no snapshot) degrades to the storage
|
|
672
|
+
values — today's behaviour.
|
|
592
673
|
"""
|
|
674
|
+
registry_by_uid = registry_by_uid or {}
|
|
593
675
|
async with semaphore:
|
|
594
676
|
try:
|
|
595
677
|
resp = await self.client.send_websocket_message(
|
|
@@ -605,18 +687,32 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
605
687
|
matches: list[dict[str, Any]] = []
|
|
606
688
|
for helper in resp.get("result", []):
|
|
607
689
|
helper_id = helper.get("id", "")
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
690
|
+
storage_name = helper.get("name", helper_id)
|
|
691
|
+
# Prefer the registry's CURRENT entity_id + name after a UI
|
|
692
|
+
# rename; fall back to the storage-derived values when this
|
|
693
|
+
# helper isn't in the snapshot (or no snapshot was handed
|
|
694
|
+
# down at all).
|
|
695
|
+
reg_entity_id, reg_name = registry_by_uid.get(
|
|
696
|
+
helper_id, (None, None)
|
|
697
|
+
)
|
|
698
|
+
entity_id = reg_entity_id or f"{helper_type}.{helper_id}"
|
|
699
|
+
display_name = reg_name or storage_name
|
|
700
|
+
|
|
701
|
+
# Score the query against every name the helper answers to —
|
|
702
|
+
# its current (registry) name and its storage name — so
|
|
703
|
+
# neither the rename nor the pre-rename value can hide it.
|
|
704
|
+
name_match_score = max(
|
|
705
|
+
self.fuzzy_searcher._calculate_entity_score(
|
|
706
|
+
entity_id, candidate, helper_type, query_lower
|
|
707
|
+
)
|
|
708
|
+
for candidate in {display_name, storage_name}
|
|
613
709
|
)
|
|
614
710
|
config_match_score = self._search_in_dict(
|
|
615
711
|
helper, query_lower, exact_match
|
|
616
712
|
)
|
|
617
713
|
total_score, threshold, match_in_name = self._score_deep_match(
|
|
618
714
|
entity_id,
|
|
619
|
-
|
|
715
|
+
display_name,
|
|
620
716
|
name_match_score,
|
|
621
717
|
config_match_score,
|
|
622
718
|
query_lower,
|
|
@@ -628,7 +724,7 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
628
724
|
{
|
|
629
725
|
"entity_id": entity_id,
|
|
630
726
|
"helper_type": helper_type,
|
|
631
|
-
"name":
|
|
727
|
+
"name": display_name,
|
|
632
728
|
"score": total_score,
|
|
633
729
|
"match_in_name": match_in_name,
|
|
634
730
|
"match_in_config": config_match_score >= threshold,
|
|
@@ -646,6 +742,8 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
646
742
|
exact_match: bool,
|
|
647
743
|
semaphore: asyncio.Semaphore,
|
|
648
744
|
include_config: bool,
|
|
745
|
+
*,
|
|
746
|
+
prefetched_registry: Any = None,
|
|
649
747
|
) -> tuple[list[dict[str, Any]], int]:
|
|
650
748
|
"""Deep-search helpers: parallel input_* WS lists plus flow-based helpers.
|
|
651
749
|
|
|
@@ -662,6 +760,14 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
662
760
|
``ha_search`` call, so silent failures here mean the caller cannot
|
|
663
761
|
tell "no helpers match" from "helper backend partially down" —
|
|
664
762
|
surfaced via ``partial: True``.
|
|
763
|
+
|
|
764
|
+
``prefetched_registry`` is the orchestrator's already-fetched
|
|
765
|
+
``config/entity_registry/list`` response, reused (never re-fetched
|
|
766
|
+
here — this path adds no request) to map each input_* helper's storage
|
|
767
|
+
``unique_id`` to its CURRENT entity_id + name so a UI rename doesn't
|
|
768
|
+
hide the helper from a search for its new name (#1794). ``None`` on the
|
|
769
|
+
direct ``deep_search``/``ha_deep_search`` path yields an empty map and
|
|
770
|
+
storage-name-only matching.
|
|
665
771
|
"""
|
|
666
772
|
helper_types = [
|
|
667
773
|
"input_boolean",
|
|
@@ -672,11 +778,21 @@ class DeepSearchMixin(SceneSearchMixin):
|
|
|
672
778
|
"input_button",
|
|
673
779
|
]
|
|
674
780
|
|
|
781
|
+
registry_by_uid = self._build_helper_registry_map(
|
|
782
|
+
prefetched_registry, set(helper_types)
|
|
783
|
+
)
|
|
784
|
+
|
|
675
785
|
results: list[dict[str, Any]] = []
|
|
676
786
|
failed_type_count = 0
|
|
677
787
|
type_results = await asyncio.gather(
|
|
678
788
|
*[
|
|
679
|
-
self._search_helper_type(
|
|
789
|
+
self._search_helper_type(
|
|
790
|
+
ht,
|
|
791
|
+
query_lower,
|
|
792
|
+
exact_match,
|
|
793
|
+
semaphore,
|
|
794
|
+
registry_by_uid=registry_by_uid,
|
|
795
|
+
)
|
|
680
796
|
for ht in helper_types
|
|
681
797
|
],
|
|
682
798
|
return_exceptions=True,
|