Merge remote-tracking branch 'origin/dev' into web-server-offline-hint

# Conflicts:
#	esphome/components/web_server/__init__.py
This commit is contained in:
J. Nick Koston
2026-09-14 10:47:19 -05:00
286 changed files with 6919 additions and 1224 deletions
+148 -1
View File
@@ -1,4 +1,5 @@
import importlib
import io
import json
import logging
from pathlib import Path
@@ -20,6 +21,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S2,
VARIANT_ESP32S3,
)
from esphome.components.substitutions import do_substitution_pass
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DAY,
@@ -65,7 +67,13 @@ from esphome.core import (
)
from esphome.schema_extractors import SCHEMA_EXTRACT
from esphome.util import Registry
from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base
from esphome.yaml_util import (
ESPHomeDataBase,
SensitiveStr,
load_yaml,
make_data_base,
parse_yaml,
)
def test_check_not_templatable__invalid():
@@ -1394,6 +1402,35 @@ def test_entity_metadata_visibility_hints() -> None:
assert web["web_server"].visibility is advanced
def test_with_visibility_remarks_keys() -> None:
"""``with_visibility`` re-marks the named keys, preserving each field's
default and validator, without touching the other keys or the input schema.
"""
base = cv.Schema(
{
cv.Optional("a", default=7): cv.int_,
cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string,
}
)
promoted = cv.with_visibility(base, cv.Visibility.UI, "a")
pm = {str(k): k for k in promoted.schema}
assert pm["a"].visibility is cv.Visibility.UI # re-marked
assert pm["a"].default() == 7 # default preserved
assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched
assert promoted({}) == {"a": 7} # validator/default still applied
# The input schema is left untouched (no shared-marker mutation).
assert {str(k): k for k in base.schema}["a"].visibility is None
def test_with_visibility_unknown_key_raises() -> None:
"""A key not present in the schema is a typo — fail at build time."""
base = cv.Schema({cv.Optional("a"): cv.int_})
with pytest.raises(ValueError, match="not in schema"):
cv.with_visibility(base, cv.Visibility.UI, "nope")
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)
@@ -3145,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None:
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]:
"""Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value."""
package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor"
(package_dir / "assets").mkdir(parents=True)
(package_dir / "assets" / "ui.js").write_text("js\n")
(package_dir / "device.yaml").write_text(f"path: {path}\n")
return package_dir, load_yaml(package_dir / "device.yaml")["path"]
def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None:
"""A package's own asset path resolves against the package file when the config dir lacks it."""
package_dir, value = _package_value(setup_core)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__resolves_a_substituted_path_against_the_use_site(
setup_core: Path,
) -> None:
package_dir, _ = _package_value(setup_core)
(package_dir / "device.yaml").write_text(
"substitutions:\n ui: assets/ui.js\npath: ${ui}\n"
)
config = do_substitution_pass(load_yaml(package_dir / "device.yaml"))
assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js"
def test_file__result_is_absolute_for_a_relative_document(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A document loaded by a cwd-relative path still yields an absolute result."""
package_dir, _ = _package_value(setup_core)
monkeypatch.chdir(setup_core)
value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"]
result = cv.file_(value)
assert result.is_absolute()
assert result == package_dir / "assets" / "ui.js"
def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core)
(setup_core / "assets" / "ui.js").mkdir(parents=True)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__miss_names_the_declaring_document(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets/other.js")
with pytest.raises(Invalid, match="Could not find file") as excinfo:
cv.file_(value)
assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value)
def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint(
setup_core: Path,
) -> None:
(setup_core / "sub").mkdir()
(setup_core / "device.yaml").write_text("path: assets/other.js\n")
value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"]
with pytest.raises(Invalid) as excinfo:
cv.file_(value)
assert "Also looked" not in str(excinfo.value)
def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets")
with pytest.raises(Invalid, match="is not a file") as excinfo:
cv.file_(value)
assert str(package_dir / "assets") in str(excinfo.value)
def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None:
_, value = _package_value(setup_core)
(setup_core / "assets").mkdir()
(setup_core / "assets" / "ui.js").write_text("local\n")
assert cv.file_(value) == setup_core / "assets" / "ui.js"
def test_file__declared_in_an_in_memory_document_is_not_resolved(
setup_core: Path,
) -> None:
"""A value whose source document isn't on disk falls through to the config-dir error."""
value = parse_yaml(Path("<unicode string>"), io.StringIO("path: assets/ui.js\n"))[
"path"
]
with pytest.raises(Invalid, match="Could not find file"):
cv.file_(value)
def test_directory_resolves_relative_to_the_declaring_document(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core, "assets")
assert cv.directory(value) == package_dir / "assets"
def test_file__missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("partitions.csv")
+45
View File
@@ -1,5 +1,7 @@
"""Tests for the coroutine module."""
import contextvars
import pytest
from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority
@@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None:
# Check execution order
assert execution_order == ["core", "custom", "diagnostics"]
def test_context_isolated_between_interleaved_tasks() -> None:
"""Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
seen: dict[str, str] = {}
def task_a():
my_var.set("a")
yield # suspend so task_b can run before task_a resumes
seen["a"] = my_var.get()
def task_b():
my_var.set("b")
yield
seen["b"] = my_var.get()
loop = FakeEventLoop()
loop.add_job(task_a)
loop.add_job(task_b)
loop.flush_tasks()
assert seen == {"a": "a", "b": "b"}
def test_context_inherits_ambient_value_at_schedule_time() -> None:
"""Test that a job sees whatever contextvar value was set before it was scheduled."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
token = my_var.set("ambient")
seen: dict[str, str] = {}
def task():
seen["value"] = my_var.get()
yield
try:
loop = FakeEventLoop()
loop.add_job(task)
loop.flush_tasks()
finally:
my_var.reset(token)
assert seen == {"value": "ambient"}
+79
View File
@@ -85,6 +85,15 @@ class TestCallExpression:
assert actual == 'my_function<int32_t, float>(1, "2", false)'
class TestStaticCastExpression:
def test_str(self):
target = cg.StaticCastExpression(ct.bool_, 42)
actual = str(target)
assert actual == "static_cast<bool>(42)"
class TestStructInitializer:
def test_str(self):
target = cg.StructInitializer(
@@ -229,6 +238,76 @@ class TestLambdaExpression:
)
class TestCallLambda:
"""Tests for the call_lambda() function."""
def test_call_lambda__return_expression_casts_to_return_type(self):
"""A lambda body that is just a return statement reduces to the
expression, cast to the lambda's return type."""
lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.StaticCastExpression)
assert str(result) == "static_cast<bool>(foo + 1)"
def test_call_lambda__return_expression_with_class_return_type_no_cast(self):
"""A class return type is not cast, since static_cast doesn't apply
to arbitrary class types."""
mock_class = cg.MockObjClass("foo::Bar", parents=())
lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.RawExpression)
assert str(result) == "get_bar()"
def test_call_lambda__no_return_with_parameters_calls_with_names(self):
"""A multi-statement lambda with parameters is called with the
parameter names as arguments."""
lamb = cg.LambdaExpression(
("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_
)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == (
"[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)"
)
def test_call_lambda__no_return_type_raises(self):
"""Calling a lambda with no declared return type is a developer
error: call_lambda is only for value-returning lambdas."""
lamb = cg.LambdaExpression(("do_something();",), (), "=")
with pytest.raises(AssertionError):
cg.call_lambda(lamb)
def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement(
self,
):
"""A body that merely starts with the substring "return" (e.g. a call
to a function named returnValue()) must not be mistaken for a return
statement -- the match requires a word boundary after "return"."""
lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == "[=]() -> bool {\n returnValue();\n}()"
def test_call_lambda__no_return_no_parameters_calls_with_no_args(self):
"""A multi-statement lambda without parameters is called with no
arguments."""
lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_)
result = cg.call_lambda(lamb)
assert isinstance(result, cg.CallExpression)
assert str(result) == "[]() -> bool {\n do_something();\n}()"
class TestLiterals:
@pytest.mark.parametrize(
"target, expected",
+25
View File
@@ -187,6 +187,31 @@ def test_slot_counter_emits_requested_count() -> None:
assert _define_value("TEST_SLOT_COUNT") == "2"
def test_slot_counter_keyed_emits_largest_count() -> None:
"""Keyed requests size storage every key declares at the same capacity:
the define is the busiest key's count, not the total over all keys."""
request = ch.slot_counter("TEST_SLOT_COUNT_KEYED")
request("rx_a")
request("rx_a")
request("rx_a")
request("rx_b")
assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3
ch.CORE.flush_tasks()
assert _define_value("TEST_SLOT_COUNT_KEYED") == "3"
def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None:
"""A keyed and an unkeyed request for one define cannot be sized together."""
request = ch.slot_counter("TEST_SLOT_COUNT_MIXED")
request("rx_a")
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"):
request()
unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2")
unkeyed()
with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"):
unkeyed("rx_a")
def test_slot_counter_without_requests_emits_nothing() -> None:
"""No requests, no job, no define — the guarded storage compiles out."""
ch.slot_counter("TEST_SLOT_COUNT_UNUSED")
+3
View File
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
in caplog.text
)
# The data phase timeout must outlast the device's 105 s data timeout
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
assert espota2.DATA_PHASE_TIMEOUT > 105.0
@pytest.mark.usefixtures("mock_time")
+2 -2
View File
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1]
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec)))
)
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"]