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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user