From 2da37476a51aef971471fc6f60a89ebf9709070d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:55:14 -0700 Subject: [PATCH] fix: Home Assistant migration reads the platform config the gateway loader merged The left-core migration decided "this home used Home Assistant" with its own config reader: the first platforms.homeassistant block in config.yaml. The pre-extraction gateway enabled the adapter from more places: the legacy gateway.json base layer, the gateway.homeassistant shorthand, and nested gateway.platforms + top-level platforms blocks merged key by key. Those homes got no plugin and no notice, and lost Home Assistant on update. platform_configured_on() now asks the gateway loader itself: load_legacy_gateway_json + read_yaml_layers (managed overlay, ${VAR} expansion) + merge_platform_sections, then PlatformConfig.from_dict for the enabled flag, so the last explicit `enabled` keeps winning (config.yaml enabled: false over a gateway.json enable stays not-in-use). merge_platform_sections takes `also=` so the gateway. shorthand of a platform that left core counts before its plugin registers (Platform() no longer knows the name). --- gateway/config_loader.py | 8 ++-- hermes_cli/left_core_migration.py | 40 ++++++++++++-------- tests/hermes_cli/test_left_core_migration.py | 19 ++++++++++ 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/gateway/config_loader.py b/gateway/config_loader.py index 971e0a0d2b..5c52aacc28 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -136,7 +136,7 @@ def bridge_toplevel_keys(yaml_cfg: dict, gateway_section: Any, gw_data: dict) -> # --- platform sections ----------------------------------------------------------- -def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict) -> dict: +def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict, *, also: frozenset = frozenset()) -> dict: """Merge every place a platform block may live into ``gw_data["platforms"]`` and return it. Order (later wins on shared keys, ``extra`` deep-merged so gateway.json defaults survive): @@ -144,7 +144,8 @@ def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict) -> first so top-level config keeps precedence, matching the gateway.streaming fallback). An ``enabled`` key in any block sets the ``_enabled_explicit`` marker consumed by the env pass. Top-level adapter keys (``gateway.api_server.port: 8642``) reach ``extra`` in - ``PlatformConfig.from_dict``. + ``PlatformConfig.from_dict``. *also* names platforms whose ``gateway.`` shorthand counts + even while no adapter is registered (a platform that left core, read before its plugin loads). """ platforms_data = _dict_slot(gw_data, "platforms") @@ -167,7 +168,8 @@ def merge_platform_sections(yaml_cfg: dict, gateway_cfg: Any, gw_data: dict) -> nested_gateway = gateway_cfg if isinstance(gateway_cfg, dict) else {} merge(nested_gateway.get("platforms")) merge(yaml_cfg.get("platforms")) - merge({k: v for k, v in nested_gateway.items() if k != "platforms" and isinstance(v, dict) and _is_platform_name(k)}) + merge({k: v for k, v in nested_gateway.items() + if k != "platforms" and isinstance(v, dict) and (k in also or _is_platform_name(k))}) return platforms_data diff --git a/hermes_cli/left_core_migration.py b/hermes_cli/left_core_migration.py index 0cb243e4ed..e31ea965da 100644 --- a/hermes_cli/left_core_migration.py +++ b/hermes_cli/left_core_migration.py @@ -39,15 +39,27 @@ def _read_config(home: Path) -> dict: return data if isinstance(data, dict) else {} -def _platform_block(config: dict, name: str) -> dict: - """``platforms.`` (either nesting, like gateway/config_loader.py).""" - gateway = config.get("gateway") - gateway = gateway if isinstance(gateway, dict) else {} - for section in (config.get("platforms"), gateway.get("platforms")): - block = section.get(name) if isinstance(section, dict) else None - if isinstance(block, dict): - return block - return {} +def platform_configured_on(home: Path, name: str) -> bool: + """Platform *name* is on in *home*'s config files as the gateway loader reads them: legacy + ``gateway.json`` base layer, then config.yaml's ``gateway.platforms`` / ``platforms`` / + ``gateway.`` blocks merged in the loader's order (managed overlay and ``${VAR}`` applied), + so the last explicit ``enabled`` wins. A stored token with no ``enabled`` anywhere counts too. + Env credentials are the caller's to check. Read-only; works before the platform's plugin loads.""" + from gateway import config_loader + from gateway.config import PlatformConfig + gw_data = config_loader.load_legacy_gateway_json(home) + gw_data = gw_data if isinstance(gw_data, dict) else {} + try: + yaml_cfg = config_loader.read_yaml_layers(home) + except Exception: # malformed config.yaml: the gateway falls back to gateway.json alone too + yaml_cfg = {} + yaml_cfg = yaml_cfg if isinstance(yaml_cfg, dict) else {} + block = config_loader.merge_platform_sections( + yaml_cfg, yaml_cfg.get("gateway"), gw_data, also=frozenset({name})).get(name) + if not isinstance(block, dict): + return False + platform = PlatformConfig.from_dict(block) + return platform.enabled or ("enabled" not in block and bool(str(platform.token or "").strip())) def _toolset_listed(config: dict, names: frozenset[str]) -> bool: @@ -60,8 +72,8 @@ def _toolset_listed(config: dict, names: frozenset[str]) -> bool: def homeassistant_in_use(home: Path, *, process_env: bool = False) -> bool: """What made core run Home Assistant for *home*: ``HASS_TOKEN`` in its ``.env`` (it enabled both - the gateway platform and the tools), ``platforms.homeassistant`` enabled or holding a token in - config.yaml, or the ``homeassistant`` / ``hermes-homeassistant`` toolset selected for a + the gateway platform and the tools), the platform on in its gateway config + (:func:`platform_configured_on`), or the ``homeassistant`` / ``hermes-homeassistant`` toolset selected for a platform. *process_env* (the active home at startup only) also counts a ``HASS_TOKEN`` the process received from its environment (systemd unit, Docker, shell export).""" from agent.secret_scope import load_env_file @@ -74,11 +86,9 @@ def homeassistant_in_use(home: Path, *, process_env: bool = False) -> bool: return True except Exception: pass - config = _read_config(home) - block = _platform_block(config, "homeassistant") - if block.get("enabled") is True or (block.get("enabled") is not False and str(block.get("token") or "").strip()): + if platform_configured_on(home, "homeassistant"): return True - return _toolset_listed(config, frozenset({"homeassistant", "hermes-homeassistant"})) + return _toolset_listed(_read_config(home), frozenset({"homeassistant", "hermes-homeassistant"})) @dataclass(frozen=True) diff --git a/tests/hermes_cli/test_left_core_migration.py b/tests/hermes_cli/test_left_core_migration.py index 8722df9821..b06912d03d 100644 --- a/tests/hermes_cli/test_left_core_migration.py +++ b/tests/hermes_cli/test_left_core_migration.py @@ -56,6 +56,25 @@ def test_homeassistant_not_in_use(tmp_path, env, config): assert lcm.homeassistant_in_use(_home(tmp_path, env=env, config=config)) is False +_JSON_ON = '{"platforms": {"homeassistant": {"enabled": true, "token": "t"}}}' + + +@pytest.mark.parametrize(("gateway_json", "config", "in_use"), [ + (_JSON_ON, "", True), + ("", "gateway:\n homeassistant:\n enabled: true\n token: t\n", True), + ("", "gateway:\n platforms:\n homeassistant:\n enabled: true\n token: t\n" + "platforms:\n homeassistant:\n extra:\n url: http://ha.local\n", True), + (_JSON_ON, "platforms:\n homeassistant:\n enabled: false\n", False), +]) +def test_in_use_reads_the_platform_config_the_gateway_loader_merged(tmp_path, gateway_json, config, in_use): + """legacy gateway.json, gateway. shorthand and nested+top-level blocks enabled core's + adapter; the last explicit ``enabled`` (here config.yaml over gateway.json) still wins.""" + home = _home(tmp_path, config=config) + if gateway_json: + (home / "gateway.json").write_text(gateway_json, encoding="utf-8") + assert lcm.homeassistant_in_use(home) is in_use + + def test_process_env_token_counts_only_for_the_active_home(tmp_path, monkeypatch): home = _home(tmp_path) monkeypatch.setenv("HASS_TOKEN", "from-systemd")