From 3cbbc5d555c3431e20145c80700749545fe367de Mon Sep 17 00:00:00 2001 From: Koan Date: Fri, 5 Jun 2026 10:10:14 +0000 Subject: [PATCH 1/2] Resolve secrets.yaml with ESPHome loader for MQTT broker secrets The MQTT coordinator parsed secrets.yaml with the plain SafeLoader, which rejects ESPHome tags and merge keys. The documented HA-shared pattern `<<: !include ../secrets.yaml` raised a ConstructorError on every poll, spamming "Could not parse secrets.yaml" and leaving broker secrets unresolved. Fall back to ESPHome's own loader (already used for device YAML) when the fast path fails, so includes and merge keys resolve. --- .../controllers/_device_mqtt_coordinator.py | 20 +++++++- tests/test_mqtt_monitor.py | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/esphome_device_builder/controllers/_device_mqtt_coordinator.py b/esphome_device_builder/controllers/_device_mqtt_coordinator.py index 8043e8192..2671411b4 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 @@ -300,11 +302,25 @@ def _load_secrets(config_dir: Path) -> dict[str, Any]: # ``_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 {} + # 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: + _LOGGER.warning("Could not parse secrets.yaml — MQTT broker secrets unavailable") + return None + return data if isinstance(data, dict) else None + + 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 9759e66d3..3e1dc5b17 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], From 7b300e42da8ac88e8540d2eef326b70b48252558 Mon Sep 17 00:00:00 2001 From: Koan Date: Fri, 5 Jun 2026 16:10:23 +0000 Subject: [PATCH 2/2] Catch OSError and non-dict result in secrets.yaml loader fallback --- .../controllers/_device_mqtt_coordinator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome_device_builder/controllers/_device_mqtt_coordinator.py b/esphome_device_builder/controllers/_device_mqtt_coordinator.py index 2671411b4..629b5e21e 100644 --- a/esphome_device_builder/controllers/_device_mqtt_coordinator.py +++ b/esphome_device_builder/controllers/_device_mqtt_coordinator.py @@ -301,7 +301,7 @@ 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: + 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 @@ -315,10 +315,13 @@ 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: + 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 if isinstance(data, dict) else None + return data def _safe_mtime(path: Path) -> float: