ha-mcp-dev 7.5.0.dev575__tar.gz → 7.5.0.dev576__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.5.0.dev575/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev576}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/__main__.py +87 -1
- ha_mcp_dev-7.5.0.dev576/src/ha_mcp/config.py +530 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/settings_ui.py +836 -64
- ha_mcp_dev-7.5.0.dev576/src/ha_mcp/stdio_settings_sidecar.py +773 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_filesystem.py +21 -15
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_mcp_component.py +69 -53
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_search.py +62 -18
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- ha_mcp_dev-7.5.0.dev575/src/ha_mcp/config.py +0 -263
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/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.5.0.
|
|
7
|
+
version = "7.5.0.dev576"
|
|
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"
|
|
@@ -83,7 +83,9 @@ class OAuthProxyClient:
|
|
|
83
83
|
logger.error(
|
|
84
84
|
f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}"
|
|
85
85
|
)
|
|
86
|
-
raise HomeAssistantAuthError(
|
|
86
|
+
raise HomeAssistantAuthError(
|
|
87
|
+
"No Home Assistant credentials in OAuth token claims"
|
|
88
|
+
)
|
|
87
89
|
|
|
88
90
|
ha_token = claims["ha_token"]
|
|
89
91
|
|
|
@@ -627,9 +629,92 @@ def main() -> None:
|
|
|
627
629
|
_setup_logging(settings.log_level)
|
|
628
630
|
_log_startup_version()
|
|
629
631
|
|
|
632
|
+
# Spawn the persistent settings UI sidecar (issue #863). The sidecar
|
|
633
|
+
# is a detached subprocess so the settings page stays reachable even
|
|
634
|
+
# when this stdio process is SIGTERM'd or idle-killed by the client.
|
|
635
|
+
# Best-effort: failure logs a warning but doesn't block MCP startup.
|
|
636
|
+
_maybe_spawn_settings_sidecar()
|
|
637
|
+
|
|
630
638
|
_run_entrypoint(_run_with_graceful_shutdown(), "Server")
|
|
631
639
|
|
|
632
640
|
|
|
641
|
+
def _maybe_spawn_settings_sidecar() -> None:
|
|
642
|
+
"""Dump tool metadata cache + spawn the stdio settings UI sidecar.
|
|
643
|
+
|
|
644
|
+
Split out of ``main()`` to keep the entrypoint readable. The cache
|
|
645
|
+
dump uses a one-off ``asyncio.run`` because ``_get_tool_metadata``
|
|
646
|
+
is async; this happens before the main stdio loop so there's no
|
|
647
|
+
nested-loop conflict with ``_run_entrypoint``'s own ``asyncio.run``.
|
|
648
|
+
|
|
649
|
+
Performance: the dump constructs the full FastMCP server, which is
|
|
650
|
+
heavy. Skip it (and the server build) when there's nothing to spawn
|
|
651
|
+
for — sidecar disabled or already alive. Warm restarts that already
|
|
652
|
+
have a sidecar pay zero cold-start tax from this path.
|
|
653
|
+
"""
|
|
654
|
+
from ha_mcp.settings_ui import (
|
|
655
|
+
_get_tool_metadata,
|
|
656
|
+
dump_tool_metadata_cache,
|
|
657
|
+
)
|
|
658
|
+
from ha_mcp.stdio_settings_sidecar import (
|
|
659
|
+
_existing_sidecar_alive,
|
|
660
|
+
_is_disabled,
|
|
661
|
+
maybe_spawn,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# Cheap gates first; skip the heavy metadata dump when the sidecar
|
|
665
|
+
# would be a no-op anyway. Any condition that makes maybe_spawn()
|
|
666
|
+
# short-circuit also makes the dump pointless (the running sidecar
|
|
667
|
+
# already has a cache from a prior parent startup; a disabled
|
|
668
|
+
# sidecar never reads one).
|
|
669
|
+
if _is_disabled() or _existing_sidecar_alive():
|
|
670
|
+
try:
|
|
671
|
+
maybe_spawn()
|
|
672
|
+
except Exception as e:
|
|
673
|
+
logger.warning(
|
|
674
|
+
"Failed to invoke maybe_spawn no-op path (%s)",
|
|
675
|
+
type(e).__name__,
|
|
676
|
+
exc_info=True,
|
|
677
|
+
)
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
metadata = asyncio.run(_get_tool_metadata(_get_server()))
|
|
682
|
+
dumped = dump_tool_metadata_cache(metadata)
|
|
683
|
+
# Log a deliberate one-liner so users debugging an empty
|
|
684
|
+
# settings page can see whether the parent's dump succeeded
|
|
685
|
+
# by grepping the stdio process output (which Claude Desktop
|
|
686
|
+
# surfaces in its MCP server log panel).
|
|
687
|
+
logger.info(
|
|
688
|
+
"Tool metadata cache: %d tools dumped, write %s",
|
|
689
|
+
len(metadata),
|
|
690
|
+
"succeeded" if dumped else "FAILED",
|
|
691
|
+
)
|
|
692
|
+
except Exception as e:
|
|
693
|
+
# Cache dump is best-effort — the sidecar falls back to an empty
|
|
694
|
+
# tools list rather than blocking stdio startup. Include the
|
|
695
|
+
# exception class in the warning so ops can distinguish
|
|
696
|
+
# server-init failures (Pydantic ValidationError) from cache I/O
|
|
697
|
+
# (OSError) from event-loop issues (RuntimeError).
|
|
698
|
+
logger.warning(
|
|
699
|
+
"Failed to dump tool metadata cache (%s)",
|
|
700
|
+
type(e).__name__,
|
|
701
|
+
exc_info=True,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
maybe_spawn()
|
|
706
|
+
except Exception as e:
|
|
707
|
+
# Spawn failures already log inside maybe_spawn(); the bare
|
|
708
|
+
# except here is a defense-in-depth guard for any unexpected
|
|
709
|
+
# path (e.g. import error in the sidecar module). Settings UI
|
|
710
|
+
# is advisory — never let it block MCP startup.
|
|
711
|
+
logger.warning(
|
|
712
|
+
"Failed to spawn settings UI sidecar (%s)",
|
|
713
|
+
type(e).__name__,
|
|
714
|
+
exc_info=True,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
633
718
|
def main_dev() -> None:
|
|
634
719
|
"""Run server with DEBUG logging enabled (for ha-mcp-dev package)."""
|
|
635
720
|
import os
|
|
@@ -875,6 +960,7 @@ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) ->
|
|
|
875
960
|
register_browser_landing(mcp, path)
|
|
876
961
|
|
|
877
962
|
from ha_mcp.settings_ui import register_settings_routes
|
|
963
|
+
|
|
878
964
|
register_settings_routes(mcp, _server, secret_path=path)
|
|
879
965
|
|
|
880
966
|
tools = await mcp.list_tools()
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Home Assistant MCP Server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
# Load environment variables from .env file with HAMCP_ENV_FILE support
|
|
9
|
+
# Use absolute path to ensure .env is found regardless of cwd
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from pydantic import Field, field_validator
|
|
14
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
|
+
|
|
16
|
+
from ha_mcp._version import get_version
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_PACKAGE_VERSION = get_version()
|
|
21
|
+
|
|
22
|
+
project_root = Path(__file__).parent.parent.parent
|
|
23
|
+
|
|
24
|
+
# Demo environment token - use HOMEASSISTANT_TOKEN="demo" to connect to the public demo
|
|
25
|
+
# Demo server: https://ha-mcp-demo-server.qc-h.net (login: mcp/mcp, resets weekly)
|
|
26
|
+
DEMO_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxOTE5ZTZlMTVkYjI0Mzk2YTQ4YjFiZTI1MDM1YmU2YSIsImlhdCI6MTc1NzI4OTc5NiwiZXhwIjoyMDcyNjQ5Nzk2fQ.Yp9SSAjm2gvl9Xcu96FFxS8SapHxWAVzaI0E3cD9xac"
|
|
27
|
+
|
|
28
|
+
# OAuth mode sentinel values — when these are present, HA credentials come from OAuth tokens
|
|
29
|
+
OAUTH_MODE_URL = "http://oauth-mode"
|
|
30
|
+
OAUTH_MODE_TOKEN = "oauth-mode-token"
|
|
31
|
+
|
|
32
|
+
# Support for different environment files via HAMCP_ENV_FILE
|
|
33
|
+
env_file = os.getenv("HAMCP_ENV_FILE", ".env")
|
|
34
|
+
env_path = project_root / env_file
|
|
35
|
+
|
|
36
|
+
# Load the specified environment file (silently, since env vars may come from other sources)
|
|
37
|
+
if env_path.exists():
|
|
38
|
+
load_dotenv(env_path)
|
|
39
|
+
else:
|
|
40
|
+
# Fallback to default .env
|
|
41
|
+
default_env_path = project_root / ".env"
|
|
42
|
+
if default_env_path.exists():
|
|
43
|
+
load_dotenv(default_env_path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Settings(BaseSettings):
|
|
47
|
+
"""Application settings loaded from environment variables."""
|
|
48
|
+
|
|
49
|
+
# Home Assistant connection
|
|
50
|
+
# In OAuth mode, these are optional and provided per-request
|
|
51
|
+
homeassistant_url: str = Field(default=OAUTH_MODE_URL, alias="HOMEASSISTANT_URL")
|
|
52
|
+
homeassistant_token: str = Field(
|
|
53
|
+
default=OAUTH_MODE_TOKEN, alias="HOMEASSISTANT_TOKEN"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Server configuration
|
|
57
|
+
timeout: int = Field(30, alias="HA_TIMEOUT")
|
|
58
|
+
max_retries: int = Field(3, alias="HA_MAX_RETRIES")
|
|
59
|
+
|
|
60
|
+
# False = skip TLS verification (self-signed / hostname mismatch). Trusted networks only.
|
|
61
|
+
verify_ssl: bool = Field(True, alias="HA_VERIFY_SSL")
|
|
62
|
+
|
|
63
|
+
# Tool configuration
|
|
64
|
+
fuzzy_threshold: int = Field(60, alias="FUZZY_THRESHOLD")
|
|
65
|
+
entity_search_limit: int = Field(20, alias="ENTITY_SEARCH_LIMIT")
|
|
66
|
+
|
|
67
|
+
# Backup tool configuration
|
|
68
|
+
backup_hint: str = Field("normal", alias="BACKUP_HINT")
|
|
69
|
+
|
|
70
|
+
# WebSocket configuration (essential for async operations)
|
|
71
|
+
enable_websocket: bool = Field(True, alias="ENABLE_WEBSOCKET")
|
|
72
|
+
|
|
73
|
+
# Development/Debug configuration
|
|
74
|
+
debug: bool = Field(False, alias="DEBUG")
|
|
75
|
+
log_level: str = Field("INFO", alias="LOG_LEVEL")
|
|
76
|
+
|
|
77
|
+
# MCP Server configuration
|
|
78
|
+
mcp_server_name: str = Field("ha-mcp", alias="MCP_SERVER_NAME")
|
|
79
|
+
mcp_server_version: str = Field(
|
|
80
|
+
default=_PACKAGE_VERSION, alias="MCP_SERVER_VERSION"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Environment configuration
|
|
84
|
+
environment: str = Field("development", alias="ENVIRONMENT")
|
|
85
|
+
|
|
86
|
+
# Tool filtering - comma-separated list of module names to enable
|
|
87
|
+
# Special values: "all" (default), "automation" (automation-related tools only)
|
|
88
|
+
# Examples: "tools_config_automations,tools_config_scripts,tools_traces"
|
|
89
|
+
enabled_tool_modules: str = Field("all", alias="ENABLED_TOOL_MODULES")
|
|
90
|
+
|
|
91
|
+
# Dashboard partial update tools (python_transform, find_card)
|
|
92
|
+
# These are token-efficient alternatives to full config replacement.
|
|
93
|
+
# Disable when using clients with programmatic tool use (future).
|
|
94
|
+
enable_dashboard_partial_tools: bool = Field(
|
|
95
|
+
True, alias="ENABLE_DASHBOARD_PARTIAL_TOOLS"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Tool search transform — replaces the full tool catalog with a unified
|
|
99
|
+
# BM25 search tool and categorized call proxies (read/write/delete).
|
|
100
|
+
# Dramatically reduces idle context token usage for LLMs.
|
|
101
|
+
enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")
|
|
102
|
+
|
|
103
|
+
# Managed YAML config editing — allows ha_config_set_yaml to add,
|
|
104
|
+
# replace, or remove top-level keys in configuration.yaml and package
|
|
105
|
+
# files. Disabled by default; only for YAML-only features with no UI/API path.
|
|
106
|
+
enable_yaml_config_editing: bool = Field(False, alias="ENABLE_YAML_CONFIG_EDITING")
|
|
107
|
+
|
|
108
|
+
# Seed values for tool visibility (comma-separated tool names).
|
|
109
|
+
# Used as initial config when no tool_config.json exists.
|
|
110
|
+
# The web settings UI (/settings) is the primary interface for managing these.
|
|
111
|
+
disabled_tools: str = Field("", alias="DISABLED_TOOLS")
|
|
112
|
+
pinned_tools: str = Field("", alias="PINNED_TOOLS")
|
|
113
|
+
|
|
114
|
+
# Max results returned by ha_search_tools. Pydantic enforces the
|
|
115
|
+
# 2-10 range; the addon-dev schema also uses ``int(2,10)?`` so the
|
|
116
|
+
# supervisor UI rejects out-of-range values before they reach env vars.
|
|
117
|
+
tool_search_max_results: int = Field(
|
|
118
|
+
5, ge=2, le=10, alias="TOOL_SEARCH_MAX_RESULTS"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Lite docstrings — replace selected heavy tool descriptions with
|
|
122
|
+
# shorter variants that defer detailed guidance to the
|
|
123
|
+
# ``ha_get_skill_guide`` skill tool/resource.
|
|
124
|
+
# Reduces idle catalog token usage at the cost of relying on the LLM
|
|
125
|
+
# to actually consult the skill when it needs detail. Beta feature
|
|
126
|
+
# (issue #1062); a startup WARNING is emitted when enabled so
|
|
127
|
+
# env-var users see the trade-off in their logs.
|
|
128
|
+
enable_lite_docstrings: bool = Field(False, alias="ENABLE_LITE_DOCSTRINGS")
|
|
129
|
+
|
|
130
|
+
# Filesystem tools — read/write/delete/list under the HA config dir.
|
|
131
|
+
# Previously gated by a direct ``os.getenv`` call in
|
|
132
|
+
# ``tools/tools_filesystem.py`` so callers (and the settings UI)
|
|
133
|
+
# couldn't see it through ``Settings``. Promoted to a first-class
|
|
134
|
+
# Settings field so the same precedence path applies as for every
|
|
135
|
+
# other gated capability.
|
|
136
|
+
enable_filesystem_tools: bool = Field(False, alias="HAMCP_ENABLE_FILESYSTEM_TOOLS")
|
|
137
|
+
|
|
138
|
+
# Custom-component installer (``ha_install_mcp_tools``) — pulls the
|
|
139
|
+
# ``ha_mcp_tools`` integration into HACS. Same env-var-direct
|
|
140
|
+
# background as ``enable_filesystem_tools``; promoted for the same
|
|
141
|
+
# reason.
|
|
142
|
+
enable_custom_component_integration: bool = Field(
|
|
143
|
+
False, alias="HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Code Mode — sandboxed Python execution via pydantic-monty.
|
|
147
|
+
# Provides an "escape hatch" tool (ha_manage_custom_tool) that lets LLMs write
|
|
148
|
+
# custom one-off Python code when no existing tool covers the request.
|
|
149
|
+
# Disabled by default due to the inherent risk of LLM-generated code.
|
|
150
|
+
# Range bounds reject zero/negative values that would silently break the
|
|
151
|
+
# tool and clamp upper bounds at sane safety margins (5 min wall-clock,
|
|
152
|
+
# 256 MB memory, 10k recursion, 10k API/tool calls per execution).
|
|
153
|
+
enable_code_mode: bool = Field(False, alias="ENABLE_CODE_MODE")
|
|
154
|
+
code_mode_max_duration: float = Field(
|
|
155
|
+
30.0, ge=1.0, le=300.0, alias="CODE_MODE_MAX_DURATION"
|
|
156
|
+
)
|
|
157
|
+
code_mode_max_memory: int = Field(
|
|
158
|
+
10_485_760, ge=1_048_576, le=268_435_456, alias="CODE_MODE_MAX_MEMORY"
|
|
159
|
+
) # 10 MB default; 1 MB floor, 256 MB ceiling
|
|
160
|
+
code_mode_max_recursion: int = Field(
|
|
161
|
+
100, ge=1, le=10_000, alias="CODE_MODE_MAX_RECURSION"
|
|
162
|
+
)
|
|
163
|
+
code_mode_max_invocations: int = Field(
|
|
164
|
+
100, ge=1, le=10_000, alias="CODE_MODE_MAX_INVOCATIONS"
|
|
165
|
+
)
|
|
166
|
+
# Path to a JSON file for persisting saved custom tools across restarts.
|
|
167
|
+
# Empty string disables persistence (saved tools live in process memory
|
|
168
|
+
# and are lost on restart). The addon sets this to /data/saved_tools.json
|
|
169
|
+
# by default so saved tools survive addon restarts (the /data directory
|
|
170
|
+
# is mapped per-addon by Supervisor and is preserved across addon
|
|
171
|
+
# updates).
|
|
172
|
+
code_mode_saved_tools_path: str = Field("", alias="CODE_MODE_SAVED_TOOLS_PATH")
|
|
173
|
+
|
|
174
|
+
# Mirror the legacy ``os.getenv("FLAG", "").lower() in ("true", ...)``
|
|
175
|
+
# semantics for the two ex-direct-getenv flags: an empty env var
|
|
176
|
+
# value MUST be treated as False rather than raising
|
|
177
|
+
# ``ValidationError``. Pydantic v2's bool parser raises on ``""``
|
|
178
|
+
# which broke ``test_tools_filesystem.py::TestFeatureFlag::
|
|
179
|
+
# test_disabled_with_empty_string`` after the migration; this
|
|
180
|
+
# validator restores the contract callers rely on.
|
|
181
|
+
@field_validator(
|
|
182
|
+
"enable_filesystem_tools",
|
|
183
|
+
"enable_custom_component_integration",
|
|
184
|
+
mode="before",
|
|
185
|
+
)
|
|
186
|
+
@classmethod
|
|
187
|
+
def _empty_string_means_false(cls, v: object) -> object:
|
|
188
|
+
if isinstance(v, str) and not v.strip():
|
|
189
|
+
return False
|
|
190
|
+
return v
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def env_file_name(self) -> str:
|
|
194
|
+
"""Get the current environment file name."""
|
|
195
|
+
return os.getenv("HAMCP_ENV_FILE", ".env")
|
|
196
|
+
|
|
197
|
+
@field_validator("homeassistant_url")
|
|
198
|
+
@classmethod
|
|
199
|
+
def validate_homeassistant_url(cls, v: str) -> str:
|
|
200
|
+
"""Ensure URL is properly formatted."""
|
|
201
|
+
# Allow OAuth mode placeholder
|
|
202
|
+
if v == OAUTH_MODE_URL:
|
|
203
|
+
return v
|
|
204
|
+
if not v.startswith(("http://", "https://")):
|
|
205
|
+
raise ValueError("Home Assistant URL must start with http:// or https://")
|
|
206
|
+
return v.rstrip("/") # Remove trailing slash
|
|
207
|
+
|
|
208
|
+
@field_validator("homeassistant_token")
|
|
209
|
+
@classmethod
|
|
210
|
+
def validate_homeassistant_token(cls, v: str) -> str:
|
|
211
|
+
"""Ensure token is not empty. Use 'demo' for public demo environment."""
|
|
212
|
+
# Allow OAuth mode placeholder
|
|
213
|
+
if v == OAUTH_MODE_TOKEN:
|
|
214
|
+
return v
|
|
215
|
+
if not v or v == "your_long_lived_access_token_here":
|
|
216
|
+
raise ValueError("Home Assistant token must be provided")
|
|
217
|
+
# Replace "demo" with actual demo token for easy onboarding
|
|
218
|
+
if v.lower() == "demo":
|
|
219
|
+
return DEMO_TOKEN
|
|
220
|
+
return v
|
|
221
|
+
|
|
222
|
+
@field_validator("fuzzy_threshold")
|
|
223
|
+
@classmethod
|
|
224
|
+
def validate_fuzzy_threshold(cls, v: int) -> int:
|
|
225
|
+
"""Ensure fuzzy threshold is reasonable."""
|
|
226
|
+
if not 0 <= v <= 100:
|
|
227
|
+
raise ValueError("Fuzzy threshold must be between 0 and 100")
|
|
228
|
+
return v
|
|
229
|
+
|
|
230
|
+
@field_validator("log_level")
|
|
231
|
+
@classmethod
|
|
232
|
+
def validate_log_level(cls, v: str) -> str:
|
|
233
|
+
"""Ensure log level is valid."""
|
|
234
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
235
|
+
if v.upper() not in valid_levels:
|
|
236
|
+
raise ValueError(f"Log level must be one of {valid_levels}")
|
|
237
|
+
return v.upper()
|
|
238
|
+
|
|
239
|
+
@field_validator("backup_hint")
|
|
240
|
+
@classmethod
|
|
241
|
+
def validate_backup_hint(cls, v: str) -> str:
|
|
242
|
+
"""Ensure backup hint is valid."""
|
|
243
|
+
valid_hints = ["strong", "normal", "weak", "auto"]
|
|
244
|
+
if v.lower() not in valid_hints:
|
|
245
|
+
raise ValueError(f"Backup hint must be one of {valid_hints}")
|
|
246
|
+
return v.lower()
|
|
247
|
+
|
|
248
|
+
model_config = SettingsConfigDict(
|
|
249
|
+
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="allow"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_settings() -> Settings:
|
|
254
|
+
"""Get application settings."""
|
|
255
|
+
return Settings() # type: ignore[call-arg]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def validate_settings() -> tuple[bool, str | None]:
|
|
259
|
+
"""
|
|
260
|
+
Validate settings and return (is_valid, error_message).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
tuple: (True, None) if valid, (False, error_message) if invalid
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
settings = get_settings()
|
|
267
|
+
|
|
268
|
+
# Additional validation
|
|
269
|
+
if not settings.homeassistant_url:
|
|
270
|
+
return False, "Home Assistant URL is required"
|
|
271
|
+
|
|
272
|
+
if not settings.homeassistant_token:
|
|
273
|
+
return False, "Home Assistant token is required"
|
|
274
|
+
|
|
275
|
+
return True, None
|
|
276
|
+
except Exception as e:
|
|
277
|
+
return False, str(e)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Runtime-editable feature flags surfaced in the /settings web UI
|
|
281
|
+
# (issue #863). Each entry is (field_name, env_var_name, python_type).
|
|
282
|
+
# The web UI's /api/settings/features GET/POST endpoints iterate this
|
|
283
|
+
# tuple to advertise per-field origin (env / addon / file / default)
|
|
284
|
+
# and to validate incoming writes. Precedence: explicit env var beats
|
|
285
|
+
# the override file, addon mode (SUPERVISOR_TOKEN set) ignores the
|
|
286
|
+
# file entirely (start.py owns env vars from config.yaml in that
|
|
287
|
+
# mode), and the pydantic field default is the fallback.
|
|
288
|
+
FEATURE_FLAG_FIELDS: tuple[tuple[str, str, type], ...] = (
|
|
289
|
+
("enable_tool_search", "ENABLE_TOOL_SEARCH", bool),
|
|
290
|
+
("tool_search_max_results", "TOOL_SEARCH_MAX_RESULTS", int),
|
|
291
|
+
("enable_yaml_config_editing", "ENABLE_YAML_CONFIG_EDITING", bool),
|
|
292
|
+
("enable_lite_docstrings", "ENABLE_LITE_DOCSTRINGS", bool),
|
|
293
|
+
("enable_filesystem_tools", "HAMCP_ENABLE_FILESYSTEM_TOOLS", bool),
|
|
294
|
+
(
|
|
295
|
+
"enable_custom_component_integration",
|
|
296
|
+
"HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION",
|
|
297
|
+
bool,
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Override-file location is the same data dir that holds tool_config.json
|
|
302
|
+
# (resolved via ``utils.data_paths.get_data_dir`` — addon ``/data``,
|
|
303
|
+
# ``HA_MCP_CONFIG_DIR``, ``XDG_DATA_HOME``, or a tmpdir fallback).
|
|
304
|
+
# Imported lazily inside helpers to avoid a circular import at module
|
|
305
|
+
# load.
|
|
306
|
+
_FEATURE_FLAG_OVERRIDE_FILENAME = "feature_flags.json"
|
|
307
|
+
|
|
308
|
+
# Per-field validation bounds for non-bool fields. Only fields with
|
|
309
|
+
# range constraints need entries here; bools are handled by the
|
|
310
|
+
# coercion in ``_apply_feature_flag_overrides``. Mirrors the pydantic
|
|
311
|
+
# Field bounds on the same fields so a corrupt override file can't
|
|
312
|
+
# push values out of range.
|
|
313
|
+
_FEATURE_FLAG_INT_BOUNDS: dict[str, tuple[int, int]] = {
|
|
314
|
+
"tool_search_max_results": (2, 10),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_feature_flag_origin(env_name: str) -> str:
|
|
319
|
+
"""Return where the live value for ``env_name`` is sourced from.
|
|
320
|
+
|
|
321
|
+
Used by the web UI to label each feature-flag field with its
|
|
322
|
+
source and decide whether the field is editable from the web UI:
|
|
323
|
+
|
|
324
|
+
- ``"addon"``: running inside the HA add-on. ``start.py`` always
|
|
325
|
+
writes these env vars from ``config.yaml`` on every addon
|
|
326
|
+
start; the override file is ignored. Web UI edits are routed
|
|
327
|
+
through Supervisor ``/addons/self/options`` so ``config.yaml``
|
|
328
|
+
stays authoritative. (The current PR exposes flags read-only
|
|
329
|
+
in addon mode; routing addon edits through Supervisor is
|
|
330
|
+
tracked separately.)
|
|
331
|
+
- ``"env"``: env var explicitly set in the process environment
|
|
332
|
+
(includes values loaded from ``.env`` via ``load_dotenv`` at
|
|
333
|
+
module import — those land in ``os.environ`` and are
|
|
334
|
+
indistinguishable from ``docker -e`` / shell-set values,
|
|
335
|
+
which is intentional). Web UI shows the field read-only;
|
|
336
|
+
user must unset the env var to edit.
|
|
337
|
+
- ``"file"``: standalone deployment with a value persisted in
|
|
338
|
+
``<data_dir>/feature_flags.json``. Web UI edits update the
|
|
339
|
+
file in place.
|
|
340
|
+
- ``"default"``: no env var and no override file entry; the
|
|
341
|
+
pydantic field default applies. Web UI edits create the file.
|
|
342
|
+
"""
|
|
343
|
+
if os.environ.get("SUPERVISOR_TOKEN"):
|
|
344
|
+
return "addon"
|
|
345
|
+
if os.environ.get(env_name) is not None:
|
|
346
|
+
return "env"
|
|
347
|
+
field_name = next(
|
|
348
|
+
(fname for fname, ename, _ in FEATURE_FLAG_FIELDS if ename == env_name),
|
|
349
|
+
None,
|
|
350
|
+
)
|
|
351
|
+
if field_name is None:
|
|
352
|
+
return "default"
|
|
353
|
+
overrides = _read_feature_flag_override_file()
|
|
354
|
+
if field_name in overrides:
|
|
355
|
+
return "file"
|
|
356
|
+
return "default"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _read_feature_flag_override_file() -> dict[str, object]:
|
|
360
|
+
"""Return the contents of the feature-flag override file, or ``{}``.
|
|
361
|
+
|
|
362
|
+
Best-effort: a corrupt file MUST NOT break Settings loading. But
|
|
363
|
+
the failure modes split into two categories that need different
|
|
364
|
+
treatment:
|
|
365
|
+
|
|
366
|
+
* **Silent**: file does not exist. The override layer is opt-in;
|
|
367
|
+
a missing file is the normal "user has never edited" state and
|
|
368
|
+
should not log.
|
|
369
|
+
* **Loud (WARNING)**: file exists but is unreadable
|
|
370
|
+
(``PermissionError``, broken filesystem) or unparseable
|
|
371
|
+
(``JSONDecodeError``). The user toggled something, the UI said
|
|
372
|
+
"Saved", and the value is silently being ignored. Without a log
|
|
373
|
+
line they have no diagnostic; with one, the sidecar/server log
|
|
374
|
+
tells them exactly what to fix.
|
|
375
|
+
|
|
376
|
+
Data-dir resolution itself can raise (``RuntimeError`` when
|
|
377
|
+
``Path.home()`` cannot determine a home directory — typical of
|
|
378
|
+
pytest's ``patch.dict(os.environ, {}, clear=True)``), so the
|
|
379
|
+
``get_data_dir()`` call is inside the try/except too. That branch
|
|
380
|
+
is treated as silent: the user could not have created an override
|
|
381
|
+
file in a directory we cannot resolve.
|
|
382
|
+
"""
|
|
383
|
+
import json
|
|
384
|
+
from pathlib import Path
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
from .utils.data_paths import get_data_dir
|
|
388
|
+
|
|
389
|
+
path: Path = get_data_dir() / _FEATURE_FLAG_OVERRIDE_FILENAME
|
|
390
|
+
except (RuntimeError, OSError):
|
|
391
|
+
# Couldn't resolve the data dir at all — user has no override
|
|
392
|
+
# file by definition. Silent.
|
|
393
|
+
return {}
|
|
394
|
+
try:
|
|
395
|
+
raw = path.read_text()
|
|
396
|
+
except FileNotFoundError:
|
|
397
|
+
return {}
|
|
398
|
+
except OSError:
|
|
399
|
+
logger.warning(
|
|
400
|
+
"Feature-flag override file at %s exists but is unreadable; "
|
|
401
|
+
"falling back to defaults. Check filesystem permissions.",
|
|
402
|
+
path,
|
|
403
|
+
exc_info=True,
|
|
404
|
+
)
|
|
405
|
+
return {}
|
|
406
|
+
try:
|
|
407
|
+
data = json.loads(raw)
|
|
408
|
+
except ValueError:
|
|
409
|
+
logger.warning(
|
|
410
|
+
"Feature-flag override file at %s is not valid JSON; "
|
|
411
|
+
"falling back to defaults. Delete or fix the file to "
|
|
412
|
+
"re-enable persisted toggles.",
|
|
413
|
+
path,
|
|
414
|
+
)
|
|
415
|
+
return {}
|
|
416
|
+
if not isinstance(data, dict):
|
|
417
|
+
logger.warning(
|
|
418
|
+
"Feature-flag override file at %s is not a JSON object "
|
|
419
|
+
"(got %s); falling back to defaults.",
|
|
420
|
+
path,
|
|
421
|
+
type(data).__name__,
|
|
422
|
+
)
|
|
423
|
+
return {}
|
|
424
|
+
return data
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _apply_feature_flag_overrides(settings: "Settings") -> None:
|
|
428
|
+
"""Patch ``settings`` with values from the override file, in place.
|
|
429
|
+
|
|
430
|
+
Honors the "env var wins" contract: a field whose env var is set
|
|
431
|
+
in the process environment is never overwritten. Addon mode
|
|
432
|
+
short-circuits — ``start.py`` already wrote env vars from
|
|
433
|
+
``config.yaml`` and the override file is ignored. Range / type
|
|
434
|
+
coercion mirrors the pydantic Field bounds so a corrupt file
|
|
435
|
+
can't push values out of range; out-of-range or untypable
|
|
436
|
+
entries are logged at WARNING and skipped rather than crashing
|
|
437
|
+
every consumer of ``get_global_settings()``.
|
|
438
|
+
"""
|
|
439
|
+
if os.environ.get("SUPERVISOR_TOKEN"):
|
|
440
|
+
return
|
|
441
|
+
overrides = _read_feature_flag_override_file()
|
|
442
|
+
if not overrides:
|
|
443
|
+
return
|
|
444
|
+
for field_name, env_name, ftype in FEATURE_FLAG_FIELDS:
|
|
445
|
+
if os.environ.get(env_name) is not None:
|
|
446
|
+
continue
|
|
447
|
+
if field_name not in overrides:
|
|
448
|
+
continue
|
|
449
|
+
raw = overrides[field_name]
|
|
450
|
+
coerced: bool | int
|
|
451
|
+
if ftype is bool:
|
|
452
|
+
if not isinstance(raw, bool | int):
|
|
453
|
+
logger.warning(
|
|
454
|
+
"Override for %r is %s; expected bool — ignoring.",
|
|
455
|
+
field_name,
|
|
456
|
+
type(raw).__name__,
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
coerced = bool(raw)
|
|
460
|
+
elif ftype is int:
|
|
461
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
462
|
+
# Reject bool-typed ints (since ``bool`` subclasses
|
|
463
|
+
# ``int``, a stray ``true`` would otherwise coerce to
|
|
464
|
+
# ``1``).
|
|
465
|
+
logger.warning(
|
|
466
|
+
"Override for %r is %s; expected int — ignoring.",
|
|
467
|
+
field_name,
|
|
468
|
+
type(raw).__name__,
|
|
469
|
+
)
|
|
470
|
+
continue
|
|
471
|
+
coerced = int(raw)
|
|
472
|
+
bounds = _FEATURE_FLAG_INT_BOUNDS.get(field_name)
|
|
473
|
+
if bounds is not None and not (bounds[0] <= coerced <= bounds[1]):
|
|
474
|
+
logger.warning(
|
|
475
|
+
"Override for %r is %d, outside %d-%d — ignoring.",
|
|
476
|
+
field_name,
|
|
477
|
+
coerced,
|
|
478
|
+
bounds[0],
|
|
479
|
+
bounds[1],
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
else:
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
setattr(settings, field_name, coerced)
|
|
486
|
+
except Exception:
|
|
487
|
+
# Pydantic field validators may raise types other than the
|
|
488
|
+
# ones we expect (ValidationError is a ValueError subclass
|
|
489
|
+
# but mode='before' validators can route weird shapes
|
|
490
|
+
# through arbitrary code). Log at WARNING and skip rather
|
|
491
|
+
# than letting a malformed override crash every consumer of
|
|
492
|
+
# ``get_global_settings()``.
|
|
493
|
+
logger.warning(
|
|
494
|
+
"Override for %r could not be applied to Settings; ignoring.",
|
|
495
|
+
field_name,
|
|
496
|
+
exc_info=True,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# Global settings instance
|
|
501
|
+
_settings: Settings | None = None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def get_global_settings() -> Settings:
|
|
505
|
+
"""Get global settings instance (singleton pattern).
|
|
506
|
+
|
|
507
|
+
Applies feature-flag overrides from
|
|
508
|
+
``<data_dir>/feature_flags.json`` after pydantic construction so
|
|
509
|
+
web-UI edits take effect on the next ``get_global_settings()``
|
|
510
|
+
call after ``_reset_global_settings()`` is called by the POST
|
|
511
|
+
handler.
|
|
512
|
+
"""
|
|
513
|
+
global _settings
|
|
514
|
+
if _settings is None:
|
|
515
|
+
_settings = get_settings()
|
|
516
|
+
_apply_feature_flag_overrides(_settings)
|
|
517
|
+
return _settings
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _reset_global_settings() -> None:
|
|
521
|
+
"""Drop the cached settings singleton.
|
|
522
|
+
|
|
523
|
+
Test seam so suites that mutate env vars can force a re-read
|
|
524
|
+
without reaching into module-private state. Also used by the
|
|
525
|
+
feature-flag settings POST handler to publish a freshly edited
|
|
526
|
+
override file value to runtime consumers (``get_global_settings``
|
|
527
|
+
is the only documented read path).
|
|
528
|
+
"""
|
|
529
|
+
global _settings
|
|
530
|
+
_settings = None
|