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.<platform> shorthand of a platform that
left core counts before its plugin registers (Platform() no longer knows the name).
This commit is contained in:
teknium1
2026-10-03 18:55:14 -07:00
committed by Teknium
parent 8e3b5b5446
commit 2da37476a5
3 changed files with 49 additions and 18 deletions

View File

@@ -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.<platform>`` 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

View File

@@ -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.<name>`` (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.<name>`` 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)

View File

@@ -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.<platform> 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")