diff --git a/esphome_device_builder/controllers/_device_mqtt_coordinator.py b/esphome_device_builder/controllers/_device_mqtt_coordinator.py index 8043e819..629b5e21 100644 --- a/esphome_device_builder/controllers/_device_mqtt_coordinator.py +++ b/esphome_device_builder/controllers/_device_mqtt_coordinator.py @@ -16,6 +16,8 @@ from typing import Any import yaml +from esphome import yaml_util +from esphome.core import EsphomeError from ..helpers.device_yaml import load_device_yaml from ..helpers.yaml import FastestSafeLoader @@ -299,12 +301,29 @@ def _load_secrets(config_dir: Path) -> dict[str, Any]: # equivalent of SafeLoader. Same noqa rationale as the # ``_TolerantYamlLoader`` call above. data = yaml.load(f, Loader=FastestSafeLoader) # noqa: S506 - except yaml.YAMLError: - _LOGGER.warning("Could not parse secrets.yaml — MQTT broker secrets unavailable") - return {} + except (yaml.YAMLError, OSError): + # A plain secrets.yaml is key/value scalars the fast loader + # handles. ESPHome tags (``!include`` / ``!secret``) and merge + # keys are not — the documented HA-shared + # ``<<: !include ../secrets.yaml`` pattern lands here. Retry with + # ESPHome's loader, which resolves them the way a compile does. + data = _load_secrets_via_esphome(secrets_path) return data if isinstance(data, dict) else {} +def _load_secrets_via_esphome(secrets_path: Path) -> dict[str, Any] | None: + """Load *secrets_path* with ESPHome's loader (``!include`` / merge keys).""" + try: + data = yaml_util.load_yaml(secrets_path) + except (EsphomeError, yaml.YAMLError, OSError): + _LOGGER.warning("Could not parse secrets.yaml — MQTT broker secrets unavailable") + return None + if not isinstance(data, dict): + _LOGGER.warning("Could not parse secrets.yaml — MQTT broker secrets unavailable") + return None + return data + + def _safe_mtime(path: Path) -> float: """Return *path*'s mtime, or ``0.0`` when the file is missing.""" try: diff --git a/tests/test_mqtt_monitor.py b/tests/test_mqtt_monitor.py index 9759e66d..3e1dc5b1 100644 --- a/tests/test_mqtt_monitor.py +++ b/tests/test_mqtt_monitor.py @@ -302,6 +302,52 @@ async def test_coordinator_resolves_secrets_from_secrets_yaml( assert stub_monitor.instances[0].broker.password == "shh" +async def test_coordinator_resolves_secrets_via_included_secrets_file( + tmp_path: Path, + stub_monitor: type[_RecordingMonitor], + caplog: pytest.LogCaptureFixture, +) -> None: + # secrets.yaml pulls in a shared file via the merge-key + + # ``!include`` pattern (HA-shared secrets); the plain SafeLoader + # rejects it, so the ESPHome-loader fallback must resolve it. + (tmp_path / "shared.yaml").write_text("mqtt_broker: 10.0.0.9\nmqtt_pw: shh\n") + (tmp_path / "secrets.yaml").write_text("<<: !include shared.yaml\n") + devices = [ + _write_device( + tmp_path, + "alpha", + "mqtt:\n broker: !secret mqtt_broker\n password: !secret mqtt_pw\n", + ) + ] + coord = _make_coordinator(tmp_path, devices) + with caplog.at_level("WARNING"): + await coord.reconcile() + assert coord.active_brokers == 1 + assert stub_monitor.instances[0].broker.host == "10.0.0.9" + assert stub_monitor.instances[0].broker.password == "shh" + assert "Could not parse secrets.yaml" not in caplog.text + + +async def test_coordinator_warns_when_secrets_unparseable_by_both_loaders( + tmp_path: Path, + stub_monitor: type[_RecordingMonitor], + caplog: pytest.LogCaptureFixture, +) -> None: + (tmp_path / "secrets.yaml").write_text("<<: !include does_not_exist.yaml\n") + devices = [ + _write_device( + tmp_path, + "alpha", + "mqtt:\n broker: !secret mqtt_broker\n", + ) + ] + coord = _make_coordinator(tmp_path, devices) + with caplog.at_level("WARNING"): + await coord.reconcile() + assert coord.active_brokers == 0 + assert "Could not parse secrets.yaml" in caplog.text + + async def test_coordinator_resolves_broker_pulled_in_via_packages( tmp_path: Path, stub_monitor: type[_RecordingMonitor],