mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 10:06:03 +00:00
259 lines
9.1 KiB
Python
259 lines
9.1 KiB
Python
"""Unit tests for ``esphome.components.dashboard_import.import_config``.
|
|
|
|
Locks the YAML shape that ``import_config`` materialises on disk for
|
|
adopted factory firmware. Both the legacy dashboard and the new
|
|
device-builder backend (esphome/device-builder) call this function
|
|
during the adoption flow and depend on the output's ``esphome.name``
|
|
/ ``packages:`` keys to route subsequent compile + flash operations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests as req
|
|
import yaml as pyyaml
|
|
|
|
from esphome.components.dashboard_import import import_config
|
|
|
|
|
|
def _load_plain_yaml(path: Path) -> dict:
|
|
"""Load YAML without invoking ESPHome's ``CORE``-aware loader.
|
|
|
|
``esphome.yaml_util.load_yaml`` resolves ``!include`` /
|
|
``!secret`` against ``CORE.config_path`` which isn't set in
|
|
these tests. We're only asserting on plain key/value structure,
|
|
so ``pyyaml.load`` with a custom loader subclassing
|
|
``pyyaml.SafeLoader`` (and empty fallbacks for the secret/include
|
|
tags) is enough.
|
|
"""
|
|
|
|
class _Loader(pyyaml.SafeLoader):
|
|
pass
|
|
|
|
_Loader.add_constructor("!secret", lambda loader, node: f"!secret {node.value}")
|
|
_Loader.add_constructor("!include", lambda loader, node: f"!include {node.value}")
|
|
|
|
return pyyaml.load(path.read_text(encoding="utf-8"), Loader=_Loader)
|
|
|
|
|
|
def test_basic_import_writes_expected_yaml_shape(tmp_path: Path) -> None:
|
|
"""A minimal Wi-Fi import emits the substitutions / packages / esphome triad.
|
|
|
|
These three top-level blocks are the contract: substitutions
|
|
holds the device-specific name, packages pulls in the upstream
|
|
firmware via the import URL, and esphome.name interpolates from
|
|
substitutions. Anything that depends on this output (frontend
|
|
config viewer, follow-up edits, version checks) reads those
|
|
keys directly.
|
|
"""
|
|
yaml_path = tmp_path / "kitchen.yaml"
|
|
|
|
import_config(
|
|
path=str(yaml_path),
|
|
name="kitchen",
|
|
friendly_name="Kitchen",
|
|
project_name="acme.kitchen-light",
|
|
import_url="github://acme/firmware/kitchen.yaml@main",
|
|
)
|
|
|
|
assert yaml_path.exists()
|
|
config = _load_plain_yaml(yaml_path)
|
|
|
|
assert config["substitutions"] == {
|
|
"name": "kitchen",
|
|
"friendly_name": "Kitchen",
|
|
}
|
|
assert config["packages"] == {
|
|
"acme.kitchen-light": "github://acme/firmware/kitchen.yaml@main"
|
|
}
|
|
assert config["esphome"] == {
|
|
"name": "${name}",
|
|
"name_add_mac_suffix": False,
|
|
"friendly_name": "${friendly_name}",
|
|
}
|
|
|
|
|
|
def test_import_appends_wifi_config_when_network_is_wifi(tmp_path: Path) -> None:
|
|
"""Wi-Fi devices get a ``wifi:`` block templated with secrets references.
|
|
|
|
Adopted Wi-Fi devices need a ``wifi:`` section so they can
|
|
actually connect on the user's LAN — the boilerplate references
|
|
``!secret wifi_ssid`` / ``!secret wifi_password`` so the
|
|
user's existing secrets file plugs in. Devices on other
|
|
networks (Ethernet) shouldn't get the Wi-Fi block.
|
|
"""
|
|
yaml_path = tmp_path / "kitchen.yaml"
|
|
import_config(
|
|
path=str(yaml_path),
|
|
name="kitchen",
|
|
friendly_name=None,
|
|
project_name="acme.kitchen-light",
|
|
import_url="github://acme/firmware/kitchen.yaml@main",
|
|
)
|
|
contents = yaml_path.read_text()
|
|
assert "wifi:" in contents
|
|
assert "!secret wifi_ssid" in contents
|
|
assert "!secret wifi_password" in contents
|
|
|
|
|
|
def test_import_omits_wifi_block_for_ethernet_network(tmp_path: Path) -> None:
|
|
"""Ethernet devices get no ``wifi:`` block — caller wires Ethernet separately.
|
|
|
|
The ``network`` parameter exists specifically so non-Wi-Fi
|
|
devices (PoE / Ethernet, etc.) skip the Wi-Fi templating —
|
|
otherwise their generated YAML would carry an unused ``wifi:``
|
|
section the user has to clean up by hand.
|
|
"""
|
|
yaml_path = tmp_path / "olimex-poe.yaml"
|
|
import_config(
|
|
path=str(yaml_path),
|
|
name="olimex-poe",
|
|
friendly_name=None,
|
|
project_name="acme.poe-monitor",
|
|
import_url="github://acme/firmware/poe.yaml@main",
|
|
network="ethernet",
|
|
)
|
|
contents = yaml_path.read_text()
|
|
assert "wifi:" not in contents
|
|
|
|
|
|
def test_import_with_encryption_writes_api_key(tmp_path: Path) -> None:
|
|
"""``encryption=True`` generates a fresh Noise PSK in the api block.
|
|
|
|
Used during the adoption flow when the device-builder UI
|
|
explicitly opts the new device into encrypted API. Each
|
|
invocation must produce a fresh 32-byte PSK base64-encoded into
|
|
the YAML; subsequent compiles and the dashboard's encryption
|
|
indicator both read it from there.
|
|
"""
|
|
yaml_path_1 = tmp_path / "a.yaml"
|
|
yaml_path_2 = tmp_path / "b.yaml"
|
|
|
|
import_config(
|
|
path=str(yaml_path_1),
|
|
name="a",
|
|
friendly_name=None,
|
|
project_name="acme.dev",
|
|
import_url="github://acme/firmware/dev.yaml@main",
|
|
encryption=True,
|
|
)
|
|
import_config(
|
|
path=str(yaml_path_2),
|
|
name="b",
|
|
friendly_name=None,
|
|
project_name="acme.dev",
|
|
import_url="github://acme/firmware/dev.yaml@main",
|
|
encryption=True,
|
|
)
|
|
|
|
config_1 = _load_plain_yaml(yaml_path_1)
|
|
config_2 = _load_plain_yaml(yaml_path_2)
|
|
assert "api" in config_1 and "encryption" in config_1["api"]
|
|
key_1 = config_1["api"]["encryption"]["key"]
|
|
key_2 = config_2["api"]["encryption"]["key"]
|
|
# Fresh per-call PSK, not a hardcoded value.
|
|
assert key_1 != key_2
|
|
# Base64-encoded 32 bytes → length 44 with one trailing `=`.
|
|
assert len(key_1) == 44
|
|
|
|
|
|
def test_import_without_friendly_name_omits_friendly_substitution(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""``friendly_name=None`` skips the friendly_name substitution.
|
|
|
|
Some imported configs don't carry a friendly name. The output
|
|
shouldn't pretend they do — the substitutions block must omit
|
|
``friendly_name`` so the dashboard renders blank rather than
|
|
the literal substitution token.
|
|
"""
|
|
yaml_path = tmp_path / "noname.yaml"
|
|
import_config(
|
|
path=str(yaml_path),
|
|
name="noname",
|
|
friendly_name=None,
|
|
project_name="acme.dev",
|
|
import_url="github://acme/firmware/dev.yaml@main",
|
|
)
|
|
config = _load_plain_yaml(yaml_path)
|
|
assert config["substitutions"] == {"name": "noname"}
|
|
assert "friendly_name" not in config["esphome"]
|
|
|
|
|
|
def test_import_refuses_to_overwrite_existing_yaml(tmp_path: Path) -> None:
|
|
"""An already-present file raises rather than clobbering the user's edits.
|
|
|
|
Both the legacy dashboard and device-builder rely on the
|
|
``FileExistsError`` to surface a "config already exists" message
|
|
instead of silently destroying user data.
|
|
"""
|
|
yaml_path = tmp_path / "existing.yaml"
|
|
yaml_path.write_text("# user's hand-edited config\n", encoding="utf-8")
|
|
|
|
with pytest.raises(FileExistsError):
|
|
import_config(
|
|
path=str(yaml_path),
|
|
name="existing",
|
|
friendly_name=None,
|
|
project_name="acme.dev",
|
|
import_url="github://acme/firmware/dev.yaml@main",
|
|
)
|
|
# Original content survives unchanged.
|
|
assert yaml_path.read_text() == "# user's hand-edited config\n"
|
|
|
|
|
|
def _full_config_kwargs(yaml_path: Path) -> dict:
|
|
return {
|
|
"path": str(yaml_path),
|
|
"name": "kitchen",
|
|
"friendly_name": None,
|
|
"project_name": "acme.kitchen-light",
|
|
"import_url": "github://acme/firmware/kitchen.yaml@main?full_config",
|
|
}
|
|
|
|
|
|
def test_full_config_import_fetches_and_writes_contents(tmp_path: Path) -> None:
|
|
yaml_path = tmp_path / "kitchen.yaml"
|
|
resp = MagicMock(text="esphome:\n name: orig\n")
|
|
with patch(
|
|
"esphome.components.dashboard_import.http_request", return_value=resp
|
|
) as mock_req:
|
|
import_config(**_full_config_kwargs(yaml_path))
|
|
assert yaml_path.read_text() == "esphome:\n name: orig\n"
|
|
assert mock_req.call_args[0][0] == "GET"
|
|
|
|
|
|
def test_full_config_import_retries_transient_errors(tmp_path: Path) -> None:
|
|
"""The fetch goes through the shared retry policy: a transient network
|
|
error is retried instead of failing the adoption immediately."""
|
|
yaml_path = tmp_path / "kitchen.yaml"
|
|
resp = MagicMock(text="esphome:\n name: orig\n")
|
|
with (
|
|
patch(
|
|
"esphome.components.dashboard_import.http_request",
|
|
side_effect=[req.ConnectionError("reset"), resp],
|
|
),
|
|
patch("esphome.net_retry.time.sleep") as mock_sleep,
|
|
):
|
|
import_config(**_full_config_kwargs(yaml_path))
|
|
assert yaml_path.exists()
|
|
mock_sleep.assert_called_once_with(2)
|
|
|
|
|
|
def test_full_config_import_wraps_permanent_errors_in_value_error(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""device-builder depends on the ValueError contract for fetch failures."""
|
|
resp = MagicMock()
|
|
resp.raise_for_status.side_effect = req.HTTPError(
|
|
"404", response=MagicMock(status_code=404)
|
|
)
|
|
with (
|
|
patch("esphome.components.dashboard_import.http_request", return_value=resp),
|
|
pytest.raises(ValueError, match="Error while fetching"),
|
|
):
|
|
import_config(**_full_config_kwargs(tmp_path / "kitchen.yaml"))
|