mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 16:00:23 +00:00
Merge branch 'dev' into codex/pr15983-idf-reproducible
This commit is contained in:
@@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path)
|
||||
line, col = int(match.group(1)), int(match.group(2))
|
||||
assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})"
|
||||
assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})"
|
||||
|
||||
|
||||
def test_substitute_package_definition_vars_preserved_literally() -> None:
|
||||
"""``vars:`` blocks in remote-package files are not substituted prematurely.
|
||||
|
||||
Variable references inside ``vars:`` may resolve to substitutions
|
||||
contributed by sibling packages that have not yet been loaded, so they
|
||||
must be passed through untouched and resolved later by the package YAML.
|
||||
"""
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_REF: "main",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "common/somefile.yaml",
|
||||
CONF_VARS: {"pin": "${PIN}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
# Note: PIN is intentionally NOT in the context — it is meant to
|
||||
# be resolved later, when the package YAML is processed.
|
||||
result = _substitute_package_definition(pkg, ContextVars())
|
||||
|
||||
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
|
||||
|
||||
|
||||
def test_substitute_package_definition_other_fields_still_substituted() -> None:
|
||||
"""Marking ``vars:`` literal does not stop substitution of url/ref/path."""
|
||||
ctx = ContextVars({"branch": "release", "org": "esphome"})
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/${org}/firmware",
|
||||
CONF_REF: "${branch}",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "common/sensor.yaml",
|
||||
CONF_VARS: {"pin": "${PIN}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
result = _substitute_package_definition(pkg, ctx)
|
||||
|
||||
assert result[CONF_URL] == "https://github.com/esphome/firmware"
|
||||
assert result[CONF_REF] == "release"
|
||||
# vars passed through unchanged
|
||||
assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"}
|
||||
|
||||
|
||||
def test_substitute_package_definition_without_vars_unaffected() -> None:
|
||||
"""Files entries without a ``vars:`` block continue to work."""
|
||||
ctx = ContextVars({"branch": "main"})
|
||||
pkg = {
|
||||
CONF_URL: "https://github.com/esphome/firmware",
|
||||
CONF_REF: "${branch}",
|
||||
CONF_FILES: [
|
||||
{CONF_PATH: "file1.yaml"},
|
||||
"file2.yaml",
|
||||
],
|
||||
}
|
||||
result = _substitute_package_definition(pkg, ctx)
|
||||
|
||||
assert result[CONF_REF] == "main"
|
||||
assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"}
|
||||
assert result[CONF_FILES][1] == "file2.yaml"
|
||||
|
||||
|
||||
@patch("esphome.yaml_util.load_yaml")
|
||||
@patch("pathlib.Path.is_file")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_vars_resolved_against_sibling_package_substitutions(
|
||||
mock_clone_or_update, mock_is_file, mock_load_yaml
|
||||
) -> None:
|
||||
"""A ``vars:`` reference in one remote package can resolve to a
|
||||
substitution defined in a sibling remote package.
|
||||
|
||||
A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a
|
||||
lower-priority package's ``files: -> vars:`` references that substitution.
|
||||
Because packages are processed highest-priority first and ``vars:`` is now
|
||||
preserved literally during package-definition processing, the substitution
|
||||
is resolved correctly when the package YAML itself is loaded.
|
||||
"""
|
||||
mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock())
|
||||
mock_is_file.return_value = True
|
||||
|
||||
# Two YAML files mocked from the "remote" repo:
|
||||
# - platform.yaml exports a substitution ``SENSOR_PIN``
|
||||
# - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to
|
||||
# ``${SENSOR_PIN}`` and resolved against the merged substitutions).
|
||||
mock_load_yaml.side_effect = [
|
||||
# Order matches reverse-priority traversal (highest priority first).
|
||||
OrderedDict(
|
||||
{
|
||||
CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"},
|
||||
}
|
||||
),
|
||||
OrderedDict(
|
||||
{
|
||||
CONF_SENSOR: [
|
||||
{
|
||||
CONF_PLATFORM: TEST_SENSOR_PLATFORM_1,
|
||||
CONF_NAME: TEST_SENSOR_NAME_1,
|
||||
"pin": "${pin}",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
config = {
|
||||
CONF_PACKAGES: {
|
||||
"special_sensor": {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_FILES: [
|
||||
{
|
||||
CONF_PATH: "sensor.yaml",
|
||||
CONF_VARS: {"pin": "${SENSOR_PIN}"},
|
||||
},
|
||||
],
|
||||
CONF_REFRESH: "1d",
|
||||
},
|
||||
"platform": {
|
||||
CONF_URL: "https://github.com/esphome/non-existant-repo",
|
||||
CONF_FILES: ["platform.yaml"],
|
||||
CONF_REFRESH: "1d",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
remote_receiver:
|
||||
id: rf_receiver
|
||||
pin: ${rx_pin}
|
||||
|
||||
# Test radio_frequency platform with receiver
|
||||
radio_frequency:
|
||||
# RF 900MHz receiver
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_900_rx
|
||||
name: "RF 900 Receiver"
|
||||
frequency: 900 MHz
|
||||
remote_receiver_id: rf_receiver
|
||||
|
||||
# RF receiver (no frequency specified)
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_rx
|
||||
name: "RF Receiver"
|
||||
remote_receiver_id: rf_receiver
|
||||
@@ -0,0 +1,19 @@
|
||||
remote_transmitter:
|
||||
id: rf_transmitter
|
||||
pin: ${tx_pin}
|
||||
carrier_duty_percent: 100%
|
||||
|
||||
# Test radio_frequency platform with transmitter
|
||||
radio_frequency:
|
||||
# RF 433MHz transmitter
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_433_tx
|
||||
name: "RF 433 Transmitter"
|
||||
frequency: 433 MHz
|
||||
remote_transmitter_id: rf_transmitter
|
||||
|
||||
# RF transmitter (no frequency specified)
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_tx
|
||||
name: "RF Transmitter"
|
||||
remote_transmitter_id: rf_transmitter
|
||||
@@ -0,0 +1,7 @@
|
||||
network:
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rx: !include common-rx.yaml
|
||||
tx: !include common-tx.yaml
|
||||
@@ -29,3 +29,8 @@ output:
|
||||
|
||||
rtttl:
|
||||
output: rtttl_output
|
||||
on_finished_playback:
|
||||
- then:
|
||||
- logger.log: "Playback finished 1"
|
||||
- then:
|
||||
- logger.log: "Playback finished 2"
|
||||
|
||||
@@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None:
|
||||
handler.close.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_appends_no_states_when_set() -> None:
|
||||
"""Test --no-states is appended when no_states is truthy in the message."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
json_message = {
|
||||
"configuration": "device.yaml",
|
||||
"port": "OTA",
|
||||
"no_states": True,
|
||||
}
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message)
|
||||
|
||||
assert cmd == [
|
||||
"esphome",
|
||||
"logs",
|
||||
"device.yaml",
|
||||
"--device",
|
||||
"OTA",
|
||||
"--no-states",
|
||||
]
|
||||
handler.build_device_command.assert_awaited_once_with(["logs"], json_message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_omits_no_states_when_missing() -> None:
|
||||
"""Test --no-states is not added when no_states is absent from the message."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(
|
||||
handler, {"configuration": "device.yaml", "port": "OTA"}
|
||||
)
|
||||
|
||||
assert "--no-states" not in cmd
|
||||
assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_esphome_logs_handler_omits_no_states_when_false() -> None:
|
||||
"""Test --no-states is not added when no_states is explicitly False."""
|
||||
handler = Mock(spec=web_server.EsphomeLogsHandler)
|
||||
handler.build_device_command = AsyncMock(
|
||||
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
|
||||
)
|
||||
|
||||
cmd = await web_server.EsphomeLogsHandler.build_command(
|
||||
handler,
|
||||
{"configuration": "device.yaml", "port": "OTA", "no_states": False},
|
||||
)
|
||||
|
||||
assert "--no-states" not in cmd
|
||||
|
||||
|
||||
def _make_auth_handler(auth_header: str | None = None) -> Mock:
|
||||
"""Create a mock handler with the given Authorization header."""
|
||||
handler = Mock()
|
||||
|
||||
@@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None:
|
||||
assert determine_jobs.should_run_benchmarks() is True
|
||||
|
||||
|
||||
def test_should_run_benchmarks_host_platform_change() -> None:
|
||||
"""Test benchmarks trigger on host platform changes.
|
||||
|
||||
Benchmarks build and run on the host platform, so changes to its
|
||||
millis()/micros()/etc. implementations affect every benchmark.
|
||||
"""
|
||||
for host_file in [
|
||||
"esphome/components/host/core.cpp",
|
||||
"esphome/components/host/__init__.py",
|
||||
]:
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[host_file]):
|
||||
assert determine_jobs.should_run_benchmarks() is True, (
|
||||
f"Expected benchmarks to run for {host_file}"
|
||||
)
|
||||
|
||||
|
||||
def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
||||
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
||||
for infra_file in [
|
||||
|
||||
@@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import DocumentLocation, DocumentRange, EsphomeError
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base
|
||||
from esphome.yaml_util import (
|
||||
ESPHomeDataBase,
|
||||
ESPLiteralValue,
|
||||
format_path,
|
||||
make_data_base,
|
||||
make_literal,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj():
|
||||
obj = _located("${var}", "main.yaml", 0, 0)
|
||||
result = format_path([], obj)
|
||||
assert result == "In: in main.yaml 1:1"
|
||||
|
||||
|
||||
def test_make_literal_wraps_dict() -> None:
|
||||
"""A dict is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
value = {"key": "${var}"}
|
||||
result = make_literal(value)
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"key": "${var}"}
|
||||
|
||||
|
||||
def test_make_literal_wraps_list() -> None:
|
||||
"""A list is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
value = ["${var}", "plain"]
|
||||
result = make_literal(value)
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert isinstance(result, list)
|
||||
assert result == ["${var}", "plain"]
|
||||
|
||||
|
||||
def test_make_literal_wraps_string() -> None:
|
||||
"""A string is wrapped so it becomes an ESPLiteralValue instance."""
|
||||
result = make_literal("${var}")
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
assert result == "${var}"
|
||||
|
||||
|
||||
def test_make_literal_returns_already_wrapped_value_unchanged() -> None:
|
||||
"""Wrapping a value that is already an ESPLiteralValue returns it as-is."""
|
||||
value = make_literal({"key": "value"})
|
||||
assert isinstance(value, ESPLiteralValue)
|
||||
result = make_literal(value)
|
||||
assert result is value
|
||||
|
||||
|
||||
def test_make_literal_returns_none_unchanged() -> None:
|
||||
"""Values whose class cannot be augmented (e.g. ``None``) are returned as-is."""
|
||||
result = make_literal(None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_make_literal_blocks_substitution() -> None:
|
||||
"""A value wrapped with make_literal is skipped by the substitution pass."""
|
||||
value = make_literal({"pin": "${PIN}"})
|
||||
result = substitutions.substitute(
|
||||
value,
|
||||
path=[],
|
||||
parent_context=substitutions.ContextVars(),
|
||||
strict_undefined=False,
|
||||
)
|
||||
# The literal block must remain untouched, even though the variable is
|
||||
# undefined in the context.
|
||||
assert result == {"pin": "${PIN}"}
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
|
||||
Reference in New Issue
Block a user