mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:55:05 +00:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
204 lines
7.1 KiB
Python
204 lines
7.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
|
|
|
|
import pytest
|
|
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"
|