ha-mcp-dev 7.9.0.dev739__tar.gz → 7.9.0.dev740__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.9.0.dev739/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.9.0.dev740}/PKG-INFO +1 -1
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/pyproject.toml +1 -1
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/__init__.py +6 -0
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/base.py +183 -0
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/matter.py +223 -0
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/thread.py +161 -0
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/zigbee.py +381 -0
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/radio/zwave.py +374 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_mcp_component.py +17 -2
- ha_mcp_dev-7.9.0.dev740/src/ha_mcp/tools/tools_radio.py +199 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_registry.py +45 -2
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_system.py +114 -2
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp_dev.egg-info/SOURCES.txt +7 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/LICENSE +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/README.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/setup.cfg +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/handlers.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/middleware.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/model.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/persistence.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/read_only.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/settings_ui/settings.html +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/settings_ui/settings.js +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_deep.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.9.0.dev739 → ha_mcp_dev-7.9.0.dev740}/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.9.0.
|
|
7
|
+
version = "7.9.0.dev740"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Per-radio handler modules for the ``ha_manage_radio`` tool.
|
|
2
|
+
|
|
3
|
+
Each radio (Z-Wave, Zigbee/ZHA, Matter, Thread) gets its own handler module so
|
|
4
|
+
no single file spans every protocol (keeps modules focused per AGENTS.md). The
|
|
5
|
+
``tools_radio`` module wires them together behind one MCP tool.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Shared contract and helpers for ``ha_manage_radio`` per-radio handlers.
|
|
2
|
+
|
|
3
|
+
A handler module exposes:
|
|
4
|
+
|
|
5
|
+
- ``SUPPORTED``: ``dict[action_name, ActionSpec]`` describing every action it
|
|
6
|
+
implements, used for validation, the destructive-confirm gate, and building
|
|
7
|
+
actionable "unsupported action" errors.
|
|
8
|
+
- ``async def handle(client, action, args) -> dict``: execute one action.
|
|
9
|
+
|
|
10
|
+
Handlers send raw WebSocket commands through the REST client's
|
|
11
|
+
``send_websocket_message`` bridge (same path the ``ha_get_device`` enrichers
|
|
12
|
+
use), or call services via ``call_service`` for the operations that are
|
|
13
|
+
service-only (most ZHA writes, plus ``zwave_js.ping`` and ``update.install``
|
|
14
|
+
firmware installs).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ...errors import ErrorCode, create_error_response
|
|
24
|
+
from ..helpers import raise_tool_error
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ActionSpec:
|
|
31
|
+
"""Metadata for one radio action.
|
|
32
|
+
|
|
33
|
+
summary: one-line description (surfaced in unsupported-action errors).
|
|
34
|
+
destructive: requires ``confirm=True`` before it will run.
|
|
35
|
+
long_running: starts an operation that completes out-of-band (inclusion,
|
|
36
|
+
rebuild routes, firmware) — the result documents how to follow up.
|
|
37
|
+
required: parameter names that must be present in ``args`` (or supplied as
|
|
38
|
+
the top-level ``device_id``).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
summary: str
|
|
42
|
+
destructive: bool = False
|
|
43
|
+
long_running: bool = False
|
|
44
|
+
required: tuple[str, ...] = field(default_factory=tuple)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ok(radio: str, action: str, **data: Any) -> dict[str, Any]:
|
|
48
|
+
"""Build the standard success envelope for a radio action."""
|
|
49
|
+
return {"success": True, "radio": radio, "action": action, **data}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require(args: dict[str, Any], spec: ActionSpec, radio: str, action: str) -> None:
|
|
53
|
+
"""Raise VALIDATION_INVALID_PARAMETER if any required arg is missing/empty."""
|
|
54
|
+
missing = [k for k in spec.required if args.get(k) in (None, "")]
|
|
55
|
+
if missing:
|
|
56
|
+
raise_tool_error(
|
|
57
|
+
create_error_response(
|
|
58
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
59
|
+
f"{radio}/{action} requires: {', '.join(missing)}",
|
|
60
|
+
context={"radio": radio, "action": action, "missing": missing},
|
|
61
|
+
suggestions=[f"Pass {m} (in params or as device_id)" for m in missing],
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def ws_call(
|
|
67
|
+
client: Any, ws_type: str, *, context: dict[str, Any] | None = None, **fields: Any
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Send a WebSocket command and return its ``result``; raise on failure.
|
|
70
|
+
|
|
71
|
+
Mirrors the ``send_websocket_message`` usage in the ``ha_get_device``
|
|
72
|
+
enrichers. Raises ToolError (SERVICE_CALL_FAILED) when HA reports the
|
|
73
|
+
command failed, attaching the command type and any caller context.
|
|
74
|
+
"""
|
|
75
|
+
message = {"type": ws_type, **{k: v for k, v in fields.items() if v is not None}}
|
|
76
|
+
result = await client.send_websocket_message(message)
|
|
77
|
+
if not result.get("success"):
|
|
78
|
+
raise_tool_error(
|
|
79
|
+
create_error_response(
|
|
80
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
81
|
+
result.get("error", f"WebSocket command '{ws_type}' failed"),
|
|
82
|
+
context={"ws_type": ws_type, **(context or {})},
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return result.get("result")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def resolve_entry_id(client: Any, domain: str) -> str | None:
|
|
89
|
+
"""Return the config entry_id for a single-instance integration ``domain``.
|
|
90
|
+
|
|
91
|
+
Uses ``config_entries/get`` (underscore form; the slash form is rejected as
|
|
92
|
+
"Unknown command"). Returns None when the integration is not configured.
|
|
93
|
+
"""
|
|
94
|
+
entries = await ws_call(client, "config_entries/get", context={"domain": domain})
|
|
95
|
+
for entry in entries or []:
|
|
96
|
+
if entry.get("domain") == domain:
|
|
97
|
+
entry_id = entry.get("entry_id")
|
|
98
|
+
return str(entry_id) if entry_id is not None else None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def resolve_update_entity(
|
|
103
|
+
client: Any, device_id: Any, *, platform: str | None = None
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Return the device's ``update.*`` firmware entity_id from the registry.
|
|
106
|
+
|
|
107
|
+
Filters the ``update.*`` entities tied to ``device_id``, preferring the given
|
|
108
|
+
``platform`` (e.g. "matter", "zha", "zwave_js") when a device exposes more
|
|
109
|
+
than one. Raises ENTITY_NOT_FOUND when the device exposes no update entity.
|
|
110
|
+
"""
|
|
111
|
+
entities = await ws_call(
|
|
112
|
+
client, "config/entity_registry/list", context={"device_id": device_id}
|
|
113
|
+
)
|
|
114
|
+
candidates = [
|
|
115
|
+
e
|
|
116
|
+
for e in (entities or [])
|
|
117
|
+
if e.get("device_id") == device_id
|
|
118
|
+
and str(e.get("entity_id", "")).startswith("update.")
|
|
119
|
+
]
|
|
120
|
+
if platform is not None:
|
|
121
|
+
for entity in candidates:
|
|
122
|
+
if entity.get("platform") == platform:
|
|
123
|
+
return str(entity["entity_id"])
|
|
124
|
+
if candidates:
|
|
125
|
+
return str(candidates[0]["entity_id"])
|
|
126
|
+
raise_tool_error(
|
|
127
|
+
create_error_response(
|
|
128
|
+
ErrorCode.ENTITY_NOT_FOUND,
|
|
129
|
+
f"No update entity found for device {device_id}; no firmware update is available",
|
|
130
|
+
context={"device_id": device_id},
|
|
131
|
+
suggestions=[
|
|
132
|
+
"Firmware updates appear only when the device exposes an update.* entity",
|
|
133
|
+
"Check ha_get_device for an 'update.' entity on this device",
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
raise AssertionError # py/mixed-returns terminal: raise_tool_error is NoReturn
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def integration_not_found(radio: str, domain: str) -> dict[str, Any]:
|
|
141
|
+
"""Standard degraded payload when an integration/config-entry is absent.
|
|
142
|
+
|
|
143
|
+
For read-only actions (``network_status``) only — reporting the absent
|
|
144
|
+
integration as a graceful ``success: True`` degradation is correct there.
|
|
145
|
+
Write/management actions must call ``integration_required`` instead so an
|
|
146
|
+
unconfigured integration surfaces as an error rather than a silent no-op.
|
|
147
|
+
"""
|
|
148
|
+
return {
|
|
149
|
+
"success": True,
|
|
150
|
+
"radio": radio,
|
|
151
|
+
"available": False,
|
|
152
|
+
"warnings": [f"{domain} integration is not configured on this Home Assistant"],
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def integration_required(radio: str, domain: str) -> None:
|
|
157
|
+
"""Raise a ToolError when a write action's integration/config-entry is absent.
|
|
158
|
+
|
|
159
|
+
The write-action counterpart to ``integration_not_found``: management actions
|
|
160
|
+
must not report a no-op as ``success: True``, so this raises instead of
|
|
161
|
+
degrading to an ``available: False`` payload.
|
|
162
|
+
"""
|
|
163
|
+
raise_tool_error(
|
|
164
|
+
create_error_response(
|
|
165
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
166
|
+
f"{domain} integration is not configured on this Home Assistant; "
|
|
167
|
+
f"cannot run this {radio} management action.",
|
|
168
|
+
context={"radio": radio, "domain": domain, "available": False},
|
|
169
|
+
suggestions=[f"Install/configure the {domain} integration first"],
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def confirm_required(radio: str, action: str) -> None:
|
|
175
|
+
"""Raise the ToolError for a destructive action lacking confirm=True."""
|
|
176
|
+
raise_tool_error(
|
|
177
|
+
create_error_response(
|
|
178
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
179
|
+
f"{radio}/{action} is destructive; pass confirm=True to proceed",
|
|
180
|
+
context={"radio": radio, "action": action, "destructive": True},
|
|
181
|
+
suggestions=["Re-run with confirm=True once you intend the change"],
|
|
182
|
+
)
|
|
183
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Matter radio handler for ``ha_manage_radio``.
|
|
2
|
+
|
|
3
|
+
Wraps the Home Assistant ``matter/*`` WebSocket API: per-node diagnostics and
|
|
4
|
+
ping (read), commissioning / multi-admin share-out / fabric removal / interview
|
|
5
|
+
(write), and credential provisioning. All commands are single request/response
|
|
6
|
+
or fire-and-return; Matter has no interactive PIN handshake (unlike Z-Wave S2).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ...errors import ErrorCode, create_error_response
|
|
14
|
+
from ..helpers import raise_tool_error
|
|
15
|
+
from .base import (
|
|
16
|
+
ActionSpec,
|
|
17
|
+
integration_not_found,
|
|
18
|
+
ok,
|
|
19
|
+
resolve_entry_id,
|
|
20
|
+
resolve_update_entity,
|
|
21
|
+
ws_call,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SUPPORTED: dict[str, ActionSpec] = {
|
|
25
|
+
"diagnostics": ActionSpec(
|
|
26
|
+
"Per-node Matter diagnostics: network type (wifi/thread), availability, "
|
|
27
|
+
"IPs, node type, active fabrics.",
|
|
28
|
+
required=("device_id",),
|
|
29
|
+
),
|
|
30
|
+
"network_status": ActionSpec("Matter fabric / integration summary."),
|
|
31
|
+
"ping": ActionSpec(
|
|
32
|
+
"Actively ping a Matter node at its known IP addresses.",
|
|
33
|
+
required=("device_id",),
|
|
34
|
+
),
|
|
35
|
+
"commission": ActionSpec(
|
|
36
|
+
"Commission a Matter device from a setup code (QR or manual pairing code).",
|
|
37
|
+
long_running=True,
|
|
38
|
+
required=("code",),
|
|
39
|
+
),
|
|
40
|
+
"commission_on_network": ActionSpec(
|
|
41
|
+
"Commission an already-networked Matter device by its PIN.",
|
|
42
|
+
long_running=True,
|
|
43
|
+
required=("pin",),
|
|
44
|
+
),
|
|
45
|
+
"share_out": ActionSpec(
|
|
46
|
+
"Open a commissioning window and return a pairing code so another "
|
|
47
|
+
"controller can add this device (multi-admin).",
|
|
48
|
+
required=("device_id",),
|
|
49
|
+
),
|
|
50
|
+
"interview": ActionSpec(
|
|
51
|
+
"Re-interview a Matter node to refresh its endpoints/clusters.",
|
|
52
|
+
long_running=True,
|
|
53
|
+
required=("device_id",),
|
|
54
|
+
),
|
|
55
|
+
"remove_fabric": ActionSpec(
|
|
56
|
+
"Remove ANOTHER controller's fabric from a Matter node (pass "
|
|
57
|
+
"params.fabric_index; see active_fabrics in diagnostics). To detach the "
|
|
58
|
+
"device from Home Assistant itself, delete it with ha_remove_device.",
|
|
59
|
+
destructive=True,
|
|
60
|
+
required=("device_id",),
|
|
61
|
+
),
|
|
62
|
+
"set_thread": ActionSpec(
|
|
63
|
+
"Provide Thread operational credentials used for the next commissioning.",
|
|
64
|
+
required=("thread_operation_dataset",),
|
|
65
|
+
),
|
|
66
|
+
"set_wifi_credentials": ActionSpec(
|
|
67
|
+
"Provide WiFi credentials used for the next commissioning.",
|
|
68
|
+
required=("network_name", "password"),
|
|
69
|
+
),
|
|
70
|
+
"firmware_update": ActionSpec(
|
|
71
|
+
"Install an available OTA firmware update for this Matter node.",
|
|
72
|
+
long_running=True,
|
|
73
|
+
required=("device_id",),
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def handle(client: Any, action: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
+
"""Execute one Matter action (validation/confirm already applied by caller)."""
|
|
80
|
+
device_id = args.get("device_id")
|
|
81
|
+
|
|
82
|
+
if action == "diagnostics":
|
|
83
|
+
diag = await ws_call(
|
|
84
|
+
client,
|
|
85
|
+
"matter/node_diagnostics",
|
|
86
|
+
device_id=device_id,
|
|
87
|
+
context={"device_id": device_id},
|
|
88
|
+
)
|
|
89
|
+
# Upstream NodeDiagnostics misspells the IP field "ip_adresses" (one d);
|
|
90
|
+
# normalize so callers see the same "ip_addresses" key ha_get_device
|
|
91
|
+
# surfaces. Copy first so the upstream/result dict is not mutated.
|
|
92
|
+
if isinstance(diag, dict) and "ip_adresses" in diag:
|
|
93
|
+
diag = dict(diag)
|
|
94
|
+
diag["ip_addresses"] = diag.pop("ip_adresses")
|
|
95
|
+
return ok("matter", "diagnostics", diagnostics=diag)
|
|
96
|
+
|
|
97
|
+
if action == "ping":
|
|
98
|
+
reachability = await ws_call(
|
|
99
|
+
client,
|
|
100
|
+
"matter/ping_node",
|
|
101
|
+
device_id=device_id,
|
|
102
|
+
context={"device_id": device_id},
|
|
103
|
+
)
|
|
104
|
+
return ok("matter", "ping", reachability=reachability)
|
|
105
|
+
|
|
106
|
+
if action == "network_status":
|
|
107
|
+
entry_id = await resolve_entry_id(client, "matter")
|
|
108
|
+
if not entry_id:
|
|
109
|
+
return integration_not_found("matter", "matter")
|
|
110
|
+
return ok(
|
|
111
|
+
"matter",
|
|
112
|
+
"network_status",
|
|
113
|
+
config_entry_id=entry_id,
|
|
114
|
+
note=(
|
|
115
|
+
"Matter exposes health per node; call action='diagnostics' with a "
|
|
116
|
+
"device_id for node-level network type, availability and fabrics."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if action == "commission":
|
|
121
|
+
result = await ws_call(
|
|
122
|
+
client,
|
|
123
|
+
"matter/commission",
|
|
124
|
+
code=args.get("code"),
|
|
125
|
+
network_only=bool(args.get("network_only", False)),
|
|
126
|
+
)
|
|
127
|
+
return ok("matter", "commission", result=result, long_running=True)
|
|
128
|
+
|
|
129
|
+
if action == "commission_on_network":
|
|
130
|
+
result = await ws_call(
|
|
131
|
+
client,
|
|
132
|
+
"matter/commission_on_network",
|
|
133
|
+
pin=args.get("pin"),
|
|
134
|
+
ip_addr=args.get("ip_addr"),
|
|
135
|
+
)
|
|
136
|
+
return ok("matter", "commission_on_network", result=result, long_running=True)
|
|
137
|
+
|
|
138
|
+
if action == "share_out":
|
|
139
|
+
commissioning = await ws_call(
|
|
140
|
+
client,
|
|
141
|
+
"matter/open_commissioning_window",
|
|
142
|
+
device_id=device_id,
|
|
143
|
+
context={"device_id": device_id},
|
|
144
|
+
)
|
|
145
|
+
# Returns setup_pin_code, setup_manual_code, setup_qr_code.
|
|
146
|
+
return ok("matter", "share_out", commissioning=commissioning)
|
|
147
|
+
|
|
148
|
+
if action == "interview":
|
|
149
|
+
result = await ws_call(
|
|
150
|
+
client,
|
|
151
|
+
"matter/interview_node",
|
|
152
|
+
device_id=device_id,
|
|
153
|
+
context={"device_id": device_id},
|
|
154
|
+
)
|
|
155
|
+
return ok("matter", "interview", result=result, long_running=True)
|
|
156
|
+
|
|
157
|
+
if action == "remove_fabric":
|
|
158
|
+
diag = await ws_call(
|
|
159
|
+
client,
|
|
160
|
+
"matter/node_diagnostics",
|
|
161
|
+
device_id=device_id,
|
|
162
|
+
context={"device_id": device_id},
|
|
163
|
+
)
|
|
164
|
+
active = (diag or {}).get("active_fabric_index")
|
|
165
|
+
fabric_index = args.get("fabric_index")
|
|
166
|
+
# remove_fabric removes ANOTHER controller's fabric. Removing HA's OWN
|
|
167
|
+
# active fabric over that fabric makes the node drop the session, so the
|
|
168
|
+
# Matter server aborts the confirmation (the removal still happens, but
|
|
169
|
+
# reports as an error). The clean "detach from HA" path is deleting the
|
|
170
|
+
# device, so refuse the own-fabric case with that guidance.
|
|
171
|
+
if fabric_index is None or fabric_index == active:
|
|
172
|
+
raise_tool_error(
|
|
173
|
+
create_error_response(
|
|
174
|
+
ErrorCode.VALIDATION_INVALID_PARAMETER,
|
|
175
|
+
"remove_fabric removes ANOTHER controller's fabric from a "
|
|
176
|
+
"node; pass params.fabric_index (see active_fabrics). To "
|
|
177
|
+
"detach this device from Home Assistant itself, delete the "
|
|
178
|
+
"device with ha_remove_device — removing HA's own active "
|
|
179
|
+
"fabric here is aborted by the Matter server.",
|
|
180
|
+
context={
|
|
181
|
+
"device_id": device_id,
|
|
182
|
+
"active_fabric_index": active,
|
|
183
|
+
"active_fabrics": (diag or {}).get("active_fabrics", []),
|
|
184
|
+
},
|
|
185
|
+
suggestions=[
|
|
186
|
+
"Pass params.fabric_index = the other controller's index",
|
|
187
|
+
"To remove Home Assistant, use ha_remove_device",
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
result = await ws_call(
|
|
192
|
+
client,
|
|
193
|
+
"matter/remove_matter_fabric",
|
|
194
|
+
device_id=device_id,
|
|
195
|
+
fabric_index=fabric_index,
|
|
196
|
+
context={"device_id": device_id, "fabric_index": fabric_index},
|
|
197
|
+
)
|
|
198
|
+
return ok("matter", "remove_fabric", result=result, fabric_index=fabric_index)
|
|
199
|
+
|
|
200
|
+
if action == "set_thread":
|
|
201
|
+
result = await ws_call(
|
|
202
|
+
client,
|
|
203
|
+
"matter/set_thread",
|
|
204
|
+
thread_operation_dataset=args.get("thread_operation_dataset"),
|
|
205
|
+
)
|
|
206
|
+
return ok("matter", "set_thread", result=result)
|
|
207
|
+
|
|
208
|
+
if action == "set_wifi_credentials":
|
|
209
|
+
result = await ws_call(
|
|
210
|
+
client,
|
|
211
|
+
"matter/set_wifi_credentials",
|
|
212
|
+
network_name=args.get("network_name"),
|
|
213
|
+
password=args.get("password"),
|
|
214
|
+
)
|
|
215
|
+
return ok("matter", "set_wifi_credentials", result=result)
|
|
216
|
+
|
|
217
|
+
if action == "firmware_update":
|
|
218
|
+
entity_id = await resolve_update_entity(client, device_id, platform="matter")
|
|
219
|
+
await client.call_service("update", "install", {"entity_id": entity_id})
|
|
220
|
+
return ok("matter", "firmware_update", entity_id=entity_id, long_running=True)
|
|
221
|
+
|
|
222
|
+
# Unreachable: dispatcher validates action against SUPPORTED first.
|
|
223
|
+
raise AssertionError(f"unhandled matter action: {action}")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Thread / OpenThread Border Router (OTBR) handler for ``ha_manage_radio``.
|
|
2
|
+
|
|
3
|
+
Thread is network-scoped, not per-device: operations target the Thread network
|
|
4
|
+
and its border router rather than an individual node. Border-router writes use
|
|
5
|
+
the ``otbr/*`` WebSocket API and take an OTBR ``extended_address`` — resolved
|
|
6
|
+
from ``otbr/info`` when the caller omits it, which covers the common
|
|
7
|
+
single-OTBR setup. Dataset listing and router discovery use the
|
|
8
|
+
integration-wide ``thread/*`` API. When no OTBR is configured the read-only
|
|
9
|
+
``network_status`` degrades to an ``available: False`` payload; the write
|
|
10
|
+
actions raise instead so an unconfigured integration is not a silent no-op.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .base import (
|
|
18
|
+
ActionSpec,
|
|
19
|
+
integration_not_found,
|
|
20
|
+
integration_required,
|
|
21
|
+
ok,
|
|
22
|
+
resolve_entry_id,
|
|
23
|
+
ws_call,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SUPPORTED: dict[str, ActionSpec] = {
|
|
27
|
+
"network_status": ActionSpec(
|
|
28
|
+
"OTBR border-router status keyed by extended address (channel, active "
|
|
29
|
+
"dataset TLVs, extended PAN id, border agent id)."
|
|
30
|
+
),
|
|
31
|
+
"list_datasets": ActionSpec(
|
|
32
|
+
"List stored Thread operational datasets (the preferred network + others)."
|
|
33
|
+
),
|
|
34
|
+
"discover_routers": ActionSpec(
|
|
35
|
+
"Start mDNS discovery of Thread border routers. Results arrive as an "
|
|
36
|
+
"out-of-band event stream; this kicks off the scan.",
|
|
37
|
+
long_running=True,
|
|
38
|
+
),
|
|
39
|
+
"create_network": ActionSpec(
|
|
40
|
+
"Form a brand-new Thread network on the OTBR. Factory-resets the border "
|
|
41
|
+
"router and replaces its current dataset. Targets the only OTBR when "
|
|
42
|
+
"extended_address is omitted.",
|
|
43
|
+
destructive=True,
|
|
44
|
+
),
|
|
45
|
+
"set_network": ActionSpec(
|
|
46
|
+
"Apply a stored dataset as the OTBR's active Thread network. Replaces "
|
|
47
|
+
"the current network, which can drop existing Thread devices.",
|
|
48
|
+
destructive=True,
|
|
49
|
+
required=("dataset_id",),
|
|
50
|
+
),
|
|
51
|
+
"set_channel": ActionSpec(
|
|
52
|
+
"Migrate the OTBR's Thread network to a different radio channel.",
|
|
53
|
+
destructive=True,
|
|
54
|
+
required=("channel",),
|
|
55
|
+
),
|
|
56
|
+
"add_dataset": ActionSpec(
|
|
57
|
+
"Import a Thread operational dataset (hex TLV) from a named source.",
|
|
58
|
+
required=("source", "tlv"),
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _resolve_extended_address(client: Any, args: dict[str, Any]) -> str | None:
|
|
64
|
+
"""Return the target OTBR extended address.
|
|
65
|
+
|
|
66
|
+
Uses the caller-supplied value, else the first (typically only) border
|
|
67
|
+
router reported by ``otbr/info``. Returns None when no OTBR is present.
|
|
68
|
+
"""
|
|
69
|
+
addr = args.get("extended_address")
|
|
70
|
+
if addr:
|
|
71
|
+
return str(addr)
|
|
72
|
+
info = await ws_call(client, "otbr/info")
|
|
73
|
+
return next(iter(info or {}), None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def handle(client: Any, action: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
"""Execute one Thread/OTBR action (validation/confirm applied by caller)."""
|
|
78
|
+
if action == "network_status":
|
|
79
|
+
entry_id = await resolve_entry_id(client, "otbr")
|
|
80
|
+
if not entry_id:
|
|
81
|
+
return integration_not_found("thread", "otbr")
|
|
82
|
+
info = await ws_call(client, "otbr/info")
|
|
83
|
+
return ok(
|
|
84
|
+
"thread",
|
|
85
|
+
"network_status",
|
|
86
|
+
config_entry_id=entry_id,
|
|
87
|
+
border_routers=info,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if action == "list_datasets":
|
|
91
|
+
datasets = await ws_call(client, "thread/list_datasets")
|
|
92
|
+
return ok("thread", "list_datasets", datasets=datasets)
|
|
93
|
+
|
|
94
|
+
if action == "discover_routers":
|
|
95
|
+
# Subscription command: it streams discovered routers as events. Kick
|
|
96
|
+
# off the scan and report it started; consumers watch the event stream.
|
|
97
|
+
await ws_call(client, "thread/discover_routers")
|
|
98
|
+
return ok(
|
|
99
|
+
"thread",
|
|
100
|
+
"discover_routers",
|
|
101
|
+
long_running=True,
|
|
102
|
+
note=(
|
|
103
|
+
"Router discovery started; results are delivered as an event "
|
|
104
|
+
"stream, not in this response."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if action == "add_dataset":
|
|
109
|
+
result = await ws_call(
|
|
110
|
+
client,
|
|
111
|
+
"thread/add_dataset_tlv",
|
|
112
|
+
source=args.get("source"),
|
|
113
|
+
tlv=args.get("tlv"),
|
|
114
|
+
)
|
|
115
|
+
return ok("thread", "add_dataset", result=result)
|
|
116
|
+
|
|
117
|
+
# Remaining actions (create_network / set_network / set_channel) are writes
|
|
118
|
+
# targeting a specific OTBR border router; an absent OTBR must error, not
|
|
119
|
+
# degrade to a no-op success.
|
|
120
|
+
extended_address = await _resolve_extended_address(client, args)
|
|
121
|
+
if not extended_address:
|
|
122
|
+
integration_required("thread", "otbr")
|
|
123
|
+
ctx = {"extended_address": extended_address}
|
|
124
|
+
|
|
125
|
+
if action == "create_network":
|
|
126
|
+
result = await ws_call(
|
|
127
|
+
client,
|
|
128
|
+
"otbr/create_network",
|
|
129
|
+
extended_address=extended_address,
|
|
130
|
+
context=ctx,
|
|
131
|
+
)
|
|
132
|
+
return ok(
|
|
133
|
+
"thread", "create_network", result=result, extended_address=extended_address
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if action == "set_network":
|
|
137
|
+
result = await ws_call(
|
|
138
|
+
client,
|
|
139
|
+
"otbr/set_network",
|
|
140
|
+
extended_address=extended_address,
|
|
141
|
+
dataset_id=args.get("dataset_id"),
|
|
142
|
+
context=ctx,
|
|
143
|
+
)
|
|
144
|
+
return ok(
|
|
145
|
+
"thread", "set_network", result=result, extended_address=extended_address
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if action == "set_channel":
|
|
149
|
+
result = await ws_call(
|
|
150
|
+
client,
|
|
151
|
+
"otbr/set_channel",
|
|
152
|
+
extended_address=extended_address,
|
|
153
|
+
channel=args.get("channel"),
|
|
154
|
+
context=ctx,
|
|
155
|
+
)
|
|
156
|
+
return ok(
|
|
157
|
+
"thread", "set_channel", result=result, extended_address=extended_address
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Unreachable: dispatcher validates action against SUPPORTED first.
|
|
161
|
+
raise AssertionError(f"unhandled thread action: {action}")
|