Merge remote-tracking branch 'origin/dev' into store-yaml-firmware

# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
#	tests/integration/conftest.py
#	tests/unit_tests/test_yaml_util.py
This commit is contained in:
J. Nick Koston
2026-09-14 11:07:43 -05:00
2889 changed files with 129953 additions and 28736 deletions
@@ -0,0 +1,382 @@
"""Invariant tests for esphome/components/api/api.proto and its generated code.
These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition
(API 1.15) against regressions that protoc-based codegen would not catch on
its own, without requiring protoc to be installed at test time:
* script/api_protobuf/api_protobuf.py skips any field marked
`[deprecated = true]` completely -- it generates no C++ for it at all, so
the device silently stops sending that value. Six DeviceInfoResponse fields
were superseded by DeviceCapabilitiesResponse but must keep being sent for
backward compatibility with clients older than API 1.15. If a future edit
"tidies up" by marking one of them deprecated, this file breaks that field
for every existing client with nothing else in CI noticing.
* Field numbers are the wire protocol, not the field names. Renaming a field
is harmless; renumbering it is a silent breaking change, because an old
client still decodes by number. This file pins the field number of each of
the six superseded DeviceInfoResponse fields and of every field on the new
DeviceCapabilitiesResponse/BluetoothProxyCapabilities/
VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a
well-intentioned reshuffle of api.proto gets caught here instead of on a
device in the field.
* Message wire ids must be unique, and the new capabilities RPC must stay
authenticated-only.
Group A below asserts on the checked-in generated files (api_pb2.h /
api_pb2.cpp), since "the field is present in the generated C++" is exactly
equivalent to "the device still sends it". Group B parses api.proto as plain
text (no protoc). Group C checks the advertised API minor version.
"""
from __future__ import annotations
from pathlib import Path
import re
import esphome
API_DIR = Path(esphome.__file__).parent / "components" / "api"
PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8")
HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8")
CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8")
API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8")
# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse
# as of API 1.15 but must still be generated (and therefore still sent) for
# backward compatibility with older clients.
SUPERSEDED_FIELDS: dict[str, int] = {
"bluetooth_proxy_feature_flags": 15,
"voice_assistant_feature_flags": 17,
"bluetooth_mac_address": 18,
"zwave_proxy_feature_flags": 23,
"zwave_home_id": 24,
"serial_proxies": 25,
}
# Field numbers on the new capability messages. These are a frozen wire
# contract from the moment they ship: an old client decodes a sub-message
# field purely by number, so renumbering any of these -- even without
# touching a name -- silently corrupts what every already-deployed client
# reads. Keyed by message name so the next capability sub-message is a
# data-only addition here.
NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"DeviceCapabilitiesResponse": {
"bluetooth_proxy": 1,
"voice_assistant": 2,
"zwave_proxy": 3,
"serial_proxies": 4,
},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
},
"VoiceAssistantCapabilities": {
"feature_flags": 1,
},
"ZWaveProxyCapabilities": {
"feature_flags": 1,
"home_id": 2,
},
}
# Fields that are genuinely dead and are expected to carry `deprecated=true`.
# Used to prove the deprecated-detection logic below actually detects
# deprecation rather than trivially passing.
GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = (
"legacy_bluetooth_proxy_version",
"legacy_voice_assistant_version",
)
DEPRECATED_FIELD_TRAP = (
"script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = "
"true]` completely, generating no C++ for them at all. Marking this field "
"deprecated would silently stop the device from ever sending it, breaking "
"every existing client that still reads it from DeviceInfoResponse."
)
def _extract_braced_region(text: str, anchor_pattern: str) -> str:
"""Return the region of `text` starting at the first match of
`anchor_pattern` up to the matching closing brace (inclusive), using
brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop
inside a function body) don't cause a premature stop.
"""
anchor_match = re.search(anchor_pattern, text)
if anchor_match is None:
raise AssertionError(f"could not find a match for {anchor_pattern!r}")
start = anchor_match.start()
open_brace = text.index("{", start)
depth = 0
for i in range(open_brace, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}")
def _extract_class_body(header_text: str, class_name: str) -> str:
"""Return the body of a generated C++ class, scoped so a field name that
also happens to exist on some other class cannot satisfy the assertion.
"""
return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b")
def _extract_function_body(cpp_text: str, qualified_name: str) -> str:
"""Return the body of a generated `Class::method(...)` definition."""
return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(")
def _extract_proto_message(proto_text: str, message_name: str) -> str:
"""Return the body of a top-level `message Name { ... }` block from the
.proto source. Proto message bodies here contain no nested `{`/`}` of
their own (options use parens, not braces), so a non-greedy match up to
the first line that is just `}` is sufficient and keeps the parsing
simple.
"""
match = re.search(
rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}",
proto_text,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `message {message_name}` in api.proto")
return match.group(1)
def _extract_rpc_body(proto_text: str, rpc_name: str) -> str:
"""Return the option body of an `rpc name (...) returns (...) { ... }`
declaration from the APIConnection service, robust to it being written
on one line (`{}`) or spread across several with options inside.
"""
match = re.search(
rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}",
proto_text,
re.DOTALL,
)
if match is None:
raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto")
return match.group(1)
def _field_declaration_line(message_body: str, field_name: str) -> str:
"""Return the single source line declaring `field_name` inside a proto
message body (all fields here are declared on one line).
"""
for line in message_body.splitlines():
if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line):
return line
raise AssertionError(
f"could not find a field declaration for {field_name!r} in the given message body"
)
# ==================== Group A: generated files ====================
def test_superseded_device_info_fields_still_declared_in_header() -> None:
"""Each superseded field must still be a real member of DeviceInfoResponse
in api_pb2.h -- not merely present somewhere in the file. Several of these
names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an
unscoped substring search over the whole header would pass even if the
field were removed from DeviceInfoResponse.
"""
class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
assert re.search(rf"\b{field_name}\b", class_body), (
f"{field_name} is missing from the DeviceInfoResponse class body in "
f"api_pb2.h. {DEPRECATED_FIELD_TRAP}"
)
def test_superseded_device_info_fields_still_encoded_and_sized() -> None:
"""Each superseded field must still be touched by DeviceInfoResponse's
generated encode() and calculate_size(), i.e. it is still put on the wire.
"""
encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode")
size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size")
for field_name in SUPERSEDED_FIELDS:
assert f"this->{field_name}" in encode_body, (
f"DeviceInfoResponse::encode() no longer references {field_name}. "
f"{DEPRECATED_FIELD_TRAP}"
)
assert f"this->{field_name}" in size_body, (
f"DeviceInfoResponse::calculate_size() no longer references "
f"{field_name}. {DEPRECATED_FIELD_TRAP}"
)
def test_new_capability_classes_present_in_header() -> None:
"""The new response message and its capability sub-messages must exist as
generated classes.
"""
for class_name in (
"DeviceCapabilitiesResponse",
"BluetoothProxyCapabilities",
"VoiceAssistantCapabilities",
"ZWaveProxyCapabilities",
):
assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), (
f"expected a generated class named {class_name} in api_pb2.h"
)
# ==================== Group B: api.proto source text ====================
def test_all_message_ids_are_unique() -> None:
"""Every `option (id) = N;` in api.proto must be unique. Two messages
sharing a wire id would make the client and server misinterpret each
other's messages -- nothing else currently checks this.
"""
ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)]
assert ids, "did not find any `option (id) = N;` declarations in api.proto"
duplicates = sorted({value for value in ids if ids.count(value) > 1})
assert not duplicates, (
f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each "
"message must have a unique wire id."
)
def test_device_capabilities_request_has_id_149() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`"
assert int(match.group(1)) == 149, (
f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_device_capabilities_response_has_id_150() -> None:
body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`"
assert int(match.group(1)) == 150, (
f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_z_wave_proxy_request_response_has_id_151() -> None:
body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`"
assert int(match.group(1)) == 151, (
f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
them (see module docstring). The second half of this test proves the
deprecated-detection itself works: two genuinely dead fields
(legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must
still be detected as deprecated, so the first half isn't vacuously true.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name in SUPERSEDED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" not in line, (
f"{field_name} in DeviceInfoResponse is marked deprecated in "
f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}"
)
for field_name in GENUINELY_DEPRECATED_FIELDS:
line = _field_declaration_line(body, field_name)
assert "deprecated" in line, (
f"expected {field_name} to still carry `deprecated=true` in "
f"api.proto ({line.strip()!r}). If this fails, the deprecated "
"detection used above is broken, and the sibling assertion that "
"the superseded fields are NOT deprecated is not testing anything."
)
def test_superseded_fields_keep_their_wire_numbers() -> None:
"""Each superseded field must stay on the field number recorded in
SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field
number, so renumbering one of these -- even without touching its name --
would make an old client read a completely different value out of the
wire, with nothing else in CI noticing.
"""
body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse")
for field_name, field_number in SUPERSEDED_FIELDS.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} in DeviceInfoResponse is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field numbers "
"are the wire protocol -- renumbering this field silently breaks "
"every existing client that still decodes DeviceInfoResponse by "
"the old numbering."
)
def test_capability_message_fields_keep_their_wire_numbers() -> None:
"""Every field on DeviceCapabilitiesResponse and its three capability
sub-messages must stay on the field number recorded in
NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but
the moment a device ships with them, their field numbers are a frozen
wire contract -- a client decodes a sub-message field purely by number,
so a later "cleanup" that renumbers one of these would silently corrupt
what every already-deployed client reads, with nothing else in CI
noticing.
"""
for message_name, fields in NEW_CAPABILITY_FIELDS.items():
body = _extract_proto_message(PROTO_TEXT, message_name)
for field_name, field_number in fields.items():
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), (
f"{field_name} on {message_name} is no longer declared at "
f"field number {field_number} ({line.strip()!r}). Field "
"numbers are the wire protocol -- renumbering this field "
"silently breaks every existing client that decodes this "
"message by the old numbering."
)
def test_device_capabilities_rpc_requires_authentication() -> None:
"""The `device_capabilities` RPC must not set
`option (needs_authentication) = false;` (or set it to anything at all).
Leaving it unset makes it inherit needs_authentication = true, keeping
capability data behind authentication (and encryption, when configured).
"""
body = _extract_rpc_body(PROTO_TEXT, "device_capabilities")
assert "needs_authentication" not in body, (
"rpc device_capabilities sets a `needs_authentication` option in "
"api.proto. It must stay unset so it inherits needs_authentication = "
"true; otherwise device capability data could be requested over an "
"unauthenticated connection."
)
# ==================== Group C: advertised API version ====================
def test_api_version_minor_is_at_least_15() -> None:
"""Clients gate sending DeviceCapabilitiesRequest on seeing
api_version >= 1.15 in HelloResponse. Regressing api_version_minor below
15 would make every client believe capabilities are unsupported even
though the RPC exists, so this must never go backwards. Use >= rather
than == so the next unrelated minor-version bump doesn't need to touch
this test.
"""
match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT)
assert match is not None, (
"could not find `resp.api_version_minor = N;` in api_connection.cpp"
)
minor = int(match.group(1))
assert minor >= 15, (
f"api_version_minor is {minor}, but device_capabilities requires "
"clients to see api_version >= 1.15 in HelloResponse before they will "
"ever request it."
)
@@ -0,0 +1,109 @@
"""Unit tests for script/api_protobuf/api_protobuf.py generator logic.
ci-api-proto.yml only checks that the committed output matches what the
generator currently produces, so a semantic regression in the generator would
be committed and matched without anything failing. These tests pin the
semantics directly.
"""
from __future__ import annotations
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf"))
from api_protobuf import ( # noqa: E402
MAX_MESSAGE_ID,
_make_ifdef_line,
get_varint64_ifdef,
validate_message_id,
)
from google.protobuf import descriptor_pb2 # noqa: E402
def _file_with_messages(
*messages: tuple[str, int, bool],
) -> descriptor_pb2.FileDescriptorProto:
"""Build a FileDescriptorProto with one single-field message per entry.
Each entry is (message_name, field_type, deprecated).
"""
file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto")
for name, field_type, deprecated in messages:
msg = file_desc.message_type.add(name=name)
field = msg.field.add(name="value", number=1, type=field_type)
field.options.deprecated = deprecated
return file_desc
UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64
INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64
SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64
UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32
FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64
def test_no_varint64_fields() -> None:
file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False))
assert get_varint64_ifdef(file_desc, {}) == (False, None)
@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64])
def test_single_guard_is_kept(field_type: int) -> None:
file_desc = _file_with_messages(("A", field_type, False))
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X")
def test_two_guards_emit_the_union() -> None:
# The regression this pins: multiple guards used to collapse to
# unconditional, pulling 64-bit varint support into unrelated builds.
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
guards = {"A": "USE_X", "B": "USE_Y"}
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
def test_union_is_sorted_for_deterministic_output() -> None:
file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False))
guards = {"B": "USE_Y", "A": "USE_X"}
assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y")
def test_any_unconditional_message_wins() -> None:
file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False))
assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None)
def test_deprecated_fields_and_messages_are_ignored() -> None:
file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False))
file_desc.message_type[1].options.deprecated = True
assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None)
def test_make_ifdef_line_simple_identifier() -> None:
assert _make_ifdef_line("USE_X") == "#ifdef USE_X"
def test_make_ifdef_line_union_wraps_each_identifier() -> None:
# The second half of the varint64 union guard: compound conditions must
# become #if defined(A) || defined(B), never #ifdef of the raw string.
assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)"
def test_make_ifdef_line_conjunction_and_negation() -> None:
assert (
_make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)"
)
def test_message_id_at_maximum_is_accepted() -> None:
# 16383 is the largest ID whose plaintext type varint fits the 2 bytes
# budgeted in HEADER_PADDING.
validate_message_id(MAX_MESSAGE_ID, "MaxMessage")
def test_message_id_above_maximum_is_rejected() -> None:
with pytest.raises(ValueError, match="exceeds the plaintext"):
validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage")
@@ -1,168 +0,0 @@
"""Tests for esphome.components.api.client."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
assert mock_process.called
assert processor.backtrace_state is False
def test_decoder_swallows_platform_handler_error() -> None:
"""The same protection must apply to the platform-specific handler."""
config = {"esphome": {"name": "test"}}
def platform_handler(_config, _line, _state):
raise EsphomeError("no idedata")
processor = api_client._LogLineProcessor(config, platform_handler)
processor.process_line("PC: 0x4010496e")
assert processor.backtrace_state is False
def test_decoder_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
"""
config = {"esphome": {"name": "test"}}
with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()):
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
warnings = [r.message for r in caplog.records if r.levelname == "WARNING"]
assert any("build artifacts not found locally" in m for m in warnings)
assert not any("()" in m for m in warnings)
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to the toolchain; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
processor.process_line("BT1: 0x401049aa")
assert mock_process.call_count == 1
def test_decoder_threads_backtrace_state() -> None:
"""When decoding succeeds, backtrace_state is threaded across calls."""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32, "process_stacktrace", side_effect=[True, False]
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line(">>>stack>>>")
assert processor.backtrace_state is True
processor.process_line("<<<stack<<<")
assert processor.backtrace_state is False
assert not mock_process.call_args_list[0].args[-1]
assert mock_process.call_args_list[1].args[-1]
def test_decoder_uses_platform_handler_when_provided() -> None:
"""The platform handler is preferred over the generic one."""
config = {"esphome": {"name": "test"}}
calls: list[tuple[object, str, bool]] = []
def platform_handler(cfg, line, state):
calls.append((cfg, line, state))
return True
processor = api_client._LogLineProcessor(config, platform_handler)
with patch.object(esp32, "process_stacktrace") as mock_generic:
processor.process_line("BT0: 0x4010496e")
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,64 @@
"""Tests for the bme68x_bsec2 prefetch extraction."""
from __future__ import annotations
from pathlib import Path
from esphome.components import bme68x_bsec2 as bsec
from esphome.loader import get_component
def test_prefetch_applies_defaults(setup_core: Path) -> None:
[files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}]))
assert len(files) == 1
assert "bme680_iaq_33v_3s_28d" in files[0].url
assert files[0].path == bsec._compute_local_file_path(files[0].url)
def test_prefetch_normalizes_enum_case(setup_core: Path) -> None:
[files] = list(
bsec.PREFETCH_FILES(
[
{
"model": "BME688",
"sample_rate": "ulp",
"supply_voltage": "1.8v",
"algorithm_output": "REGRESSION",
"operating_age": "4D",
}
]
)
)
assert len(files) == 1
assert "bme688_reg_18v_300s_4d" in files[0].url
def test_prefetch_skips_unknown_values(setup_core: Path) -> None:
entries = [
{"model": "bme999"},
{"model": "bme680", "sample_rate": "TURBO"},
{"model": "bme680", "algorithm_output": "psychic"},
{},
]
assert list(bsec.PREFETCH_FILES(entries)) == [[]]
def test_prefetch_matches_validator_url(setup_core: Path) -> None:
"""The hook's URL equals _compute_url over the validated config shape."""
validated = {
"model": "bme688",
"operating_age": "28d",
"sample_rate": "LP",
"supply_voltage": "3.3V",
"algorithm_output": "classification",
}
[files] = list(bsec.PREFETCH_FILES([dict(validated)]))
assert files[0].url == bsec._compute_url(validated)
def test_hook_is_wired_to_the_user_facing_domain() -> None:
"""The i2c domain (the only user-facing one) exposes the hook."""
component = get_component("bme68x_bsec2_i2c")
assert component is not None
assert component.prefetch_files is bsec.PREFETCH_FILES
@@ -0,0 +1,72 @@
"""Tests for the esp32 sdkconfig write and its toolchain-gated clean."""
from __future__ import annotations
import os
from pathlib import Path
import time
from unittest.mock import patch
import pytest
from esphome.components.esp32 import _write_sdkconfig
from esphome.components.esp32.const import KEY_SDKCONFIG_OPTIONS
from esphome.const import KEY_CORE, KEY_ESP32, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE
from esphome.espidf.toolchain import has_outdated_files
def _setup_core(tmp_path: Path, toolchain: Toolchain | None) -> None:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path
CORE.toolchain = toolchain
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {"CONFIG_X": "y"}}
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: "5.5.5"}
def _seed_configured_build(tmp_path: Path) -> None:
"""A settled native build: configure outputs predate what comes next."""
build = tmp_path / "build"
(build / "config").mkdir(parents=True)
(build / "config" / "sdkconfig.h").write_text("")
(build / "CMakeCache.txt").write_text("")
(build / "build.ninja").write_text("")
# Explicitly older than what the test writes next: has_outdated_files()
# compares st_mtime with a strict >, so same-tick writes would pass
past = time.time() - 60
for f in build.rglob("*"):
os.utime(f, (past, past))
@pytest.mark.parametrize(
("toolchain", "clean_expected"),
[(Toolchain.ESP_IDF, False), (Toolchain.PLATFORMIO, True), (None, True)],
)
def test_write_sdkconfig_cleans_only_on_platformio(
tmp_path: Path, toolchain: Toolchain | None, clean_expected: bool
) -> None:
"""A changed sdkconfig forces a full clean only under PlatformIO; the
esp-idf toolchain reconfigures via has_outdated_files() instead; an
unresolved toolchain fails safe onto the clean."""
_setup_core(tmp_path, toolchain)
_seed_configured_build(tmp_path)
with (
patch.object(CORE, "name", "test"),
patch("esphome.components.esp32.clean_build") as clean,
):
_write_sdkconfig()
assert "CONFIG_X" in CORE.relative_build_path("sdkconfig.test").read_text()
assert clean.called is clean_expected
if clean_expected:
clean.assert_called_once_with(clear_pio_cache=False)
# The change must still trigger a reconfigure: the internal
# sdkconfig snapshot is now newer than build/CMakeCache.txt
assert has_outdated_files() is True
clean.reset_mock()
# A settled configure restamps the cache; an unchanged rewrite
# must then neither clean nor mark the build stale
future = time.time() + 60
os.utime(CORE.relative_build_path("build/CMakeCache.txt"), (future, future))
_write_sdkconfig()
clean.assert_not_called()
assert has_outdated_files() is False
@@ -0,0 +1,22 @@
"""Tests for the per-board linker-script rule."""
from esphome.components.esp8266 import _choose_ld_script
from esphome.components.esp8266.boards import BOARDS, board_ld_script
def test_d1_wroom_02_keeps_its_shipped_layout() -> None:
"""The override must survive a BOARDS regeneration or key typo: the
2m.ld default moves _FS_end and the preferences sector on deployed
devices."""
assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld"
def test_default_boards_use_the_flash_size_layout() -> None:
assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld"
assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld"
def test_choose_ld_script_paths() -> None:
"""Default boards get the size layout, overriding boards keep theirs."""
assert _choose_ld_script("nodemcuv2") == "eagle.flash.4m.ld"
assert _choose_ld_script("d1_wroom_02") == "eagle.flash.2m64.ld"
@@ -0,0 +1,145 @@
"""Tests for the linker-script surgery shared with the native toolchain."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import pytest
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
from esphome.components.esp8266.build_surgery import (
RATETABLE_RULE,
apply_testing_memory_patches,
relocate_ratetable,
segment_length,
)
_COMMON_LD_SNIPPET = """\
.dport0.data : ALIGN(4)
{
_dport0_data_start = ABSOLUTE(.);
} >dport0_0_seg :dport0_0_phdr
.data : ALIGN(4)
{
_data_start = ABSOLUTE(.);
*(.data)
} >dram0_0_seg :dram0_0_phdr
"""
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
# the generated common ld only)
_FLASH_LD_SNIPPET = """\
MEMORY
{
dport0_0_seg : org = 0x3FF00000, len = 0x10
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
irom0_0_seg : org = 0x40201010, len = 0xfeff0
}
"""
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
# suffix the patcher must leave in place
_COMMON_LD_MEMORY_SNIPPET = """\
MEMORY
{
iram1_0_seg : org = 0x40100000, len = 0x8000ul
}
"""
def test_relocate_ratetable_inserts_after_data_start() -> None:
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
assert RATETABLE_RULE in patched
# Inserted after the .data section's anchor, not the .dport0.data one
# (whose closing brace bounds the decoy block)
assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")]
assert patched.index(RATETABLE_RULE) < patched.index("*(.data)")
# Idempotent on an already-patched script
assert relocate_ratetable(patched) == patched
def test_relocate_ratetable_requires_anchor() -> None:
with pytest.raises(RuntimeError, match="_data_start"):
relocate_ratetable("SECTIONS { }")
def test_testing_memory_patches_enlarge_segments() -> None:
patched = apply_testing_memory_patches(
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
)
assert segment_length(patched, "dram0_0_seg") == 0x200000
assert segment_length(patched, "irom0_0_seg") == 0x2000000
# Untouched segments keep their sizes
assert segment_length(patched, "dport0_0_seg") == 0x10
def test_testing_memory_patches_keep_ul_suffix() -> None:
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
replace only the hex digits, as testing_mode.py.script does."""
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
assert "len = 0x200000ul" in patched
assert segment_length(patched, "iram1_0_seg") == 0x200000
def test_segment_length_requires_whole_name() -> None:
"""A name must match its own line, never inside a longer segment name."""
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
def test_testing_memory_patches_unknown_segment_raises() -> None:
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
def test_segment_length() -> None:
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
def test_testing_memory_patches_missing_segment_raises() -> None:
"""A named segment the patch could not find raises instead of silently
keeping the real memory limits."""
with pytest.raises(RuntimeError, match="dram0_0_seg"):
apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",))
def test_board_build_covers_every_board() -> None:
"""Every supported board has native build metadata (the table may carry
extras that BOARDS does not expose)."""
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
"""The properties the linker-script cache depends on: the fingerprint is
stable across calls and changes when the module's source changes."""
first = build_surgery.surgery_fingerprint()
assert first == build_surgery.surgery_fingerprint()
assert len(first) == 64
int(first, 16) # sha256 hex digest
# A modified copy of the module must fingerprint differently
copy = tmp_path / "build_surgery_variant.py"
copy.write_text(
Path(build_surgery.__file__).read_text(encoding="utf-8")
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
encoding="utf-8",
)
spec = importlib.util.spec_from_file_location("build_surgery_variant", copy)
variant = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = variant
try:
spec.loader.exec_module(variant)
assert variant.surgery_fingerprint() != first
finally:
del sys.modules[spec.name]
def test_testing_memory_patches_present_but_unselected_raises() -> None:
"""A known segment left off the caller's list must fail, not silently
keep its real memory limit."""
with pytest.raises(RuntimeError, match="not selected"):
apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))
@@ -0,0 +1,23 @@
"""Tests for the Arduino framework version floor."""
import pytest
from esphome.components.esp8266 import _arduino_check_versions
import esphome.config_validation as cv
from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION
def test_versions_before_3_are_rejected() -> None:
with pytest.raises(cv.Invalid, match="no longer supported") as excinfo:
_arduino_check_versions({CONF_VERSION: "2.7.4"})
assert excinfo.value.path == [CONF_VERSION]
def test_supported_versions_pass() -> None:
value = _arduino_check_versions({CONF_VERSION: "3.0.2"})
assert value[CONF_VERSION] == "3.0.2"
assert "espressif8266@3.2.0" in value[CONF_PLATFORM_VERSION]
value = _arduino_check_versions({CONF_VERSION: "recommended"})
assert value[CONF_VERSION] == "3.1.2"
assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION]
@@ -0,0 +1,75 @@
"""Tests for the file image platform's prefetch extraction."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from esphome.components.file import image as file_image
from esphome.external_files import RemoteFile
from esphome.loader import get_component, get_platform
def test_extract_mdi_shorthand(setup_core: Path) -> None:
ref = file_image._extract_file_ref("mdi:home")
assert ref is not None
assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg"
assert ref.path.name == "home.svg"
assert ref.path.parent.name == "mdi"
def test_extract_web_url(setup_core: Path) -> None:
url = "https://example.com/img.png"
ref = file_image._extract_file_ref(url)
assert ref == RemoteFile(url, file_image.compute_local_image_path(url))
def test_extract_typed_dicts(setup_core: Path) -> None:
url = "https://example.com/img.png"
assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile(
url, file_image.compute_local_image_path(url)
)
ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"})
assert ref is not None
assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg"
def test_extract_skips_local_and_garbage(setup_core: Path) -> None:
assert file_image._extract_file_ref("images/local.png") is None
assert file_image._extract_file_ref("mdi:not a valid icon!") is None
assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None
assert file_image._extract_file_ref(42) is None
assert file_image._extract_file_ref(None) is None
def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None:
entries = [
{"file": "mdi:home"},
{"file": "images/local.png"},
{"file": "https://example.com/img.png"},
{"no_file_key": True},
]
[files] = list(file_image.PREFETCH_FILES(entries))
assert len(files) == 2
assert files[0].url.endswith("home.svg")
assert files[1].url == "https://example.com/img.png"
def test_extractor_matches_validator_path(setup_core: Path) -> None:
"""The path the validator downloads to equals the extractor's path."""
with patch(
"esphome.components.file.image.external_files.download_content"
) as mock_download:
file_image.validate_file_shorthand("mdi:home")
validated_path = mock_download.call_args[0][1]
assert validated_path == file_image._extract_file_ref("mdi:home").path
def test_hook_is_wired_to_both_animation_domains() -> None:
"""Both animation entry points expose the shared image hook."""
assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES
assert (
get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES
)
@@ -0,0 +1,229 @@
"""Tests for the font component's prefetch extraction."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome import external_files
from esphome.components import font
import esphome.config_validation as cv
from esphome.external_files import RemoteFile
def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict:
return {"family": family, "weight": weight, "italic": italic}
def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None:
spec = font._extract_remote_font("gfonts://Roboto")
assert spec is not None
assert spec[font.CONF_FAMILY] == "Roboto"
assert spec[font.CONF_WEIGHT] == 400
assert spec[font.CONF_ITALIC] is False
def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None:
assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700
assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500
def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None:
"""Boolean spellings the schema accepts are accepted by the extractor."""
spec = font._extract_remote_font(
{"type": "gfonts", "family": "Roboto", "italic": "true"}
)
assert spec is not None
assert spec[font.CONF_ITALIC] is True
assert (
font._extract_remote_font(
{"type": "gfonts", "family": "Roboto", "italic": "maybe"}
)
is None
)
def test_extract_typed_gfonts_dict(setup_core: Path) -> None:
spec = font._extract_remote_font(
{"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True}
)
assert spec is not None
assert spec[font.CONF_WEIGHT] == 500
assert spec[font.CONF_ITALIC] is True
def test_extract_web_font(setup_core: Path) -> None:
url = "https://example.com/font.ttf"
for value in (url, {"type": "web", "url": url}):
spec = font._extract_remote_font(value)
assert spec is not None
assert spec[font.CONF_URL] == url
def test_extract_skips_local_and_garbage(setup_core: Path) -> None:
assert font._extract_remote_font("fonts/local.ttf") is None
assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None
assert (
font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"})
is None
)
assert font._extract_remote_font(42) is None
def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None:
entries = [
{"file": "gfonts://Roboto"},
{"file": "fonts/local.ttf"},
{
"file": "https://example.com/font.ttf",
"extras": [{"file": "gfonts://Monocraft"}],
},
]
batches = list(font.PREFETCH_FILES(entries))
urls = [file.url for file in batches[0]]
assert font._gfonts_css_url(_gspec("Roboto")) in urls
assert font._gfonts_css_url(_gspec("Monocraft")) in urls
assert "https://example.com/font.ttf" in urls
assert len(batches[0]) == 3
def test_prefetch_skips_recent_ttf(setup_core: Path) -> None:
path = font._gfonts_ttf_path(_gspec("Roboto"))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"cached ttf")
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
assert batches == [[], []]
def test_stage2_parses_cached_css(setup_core: Path) -> None:
css_path = font._gfonts_css_path(_gspec("Roboto"))
css_path.parent.mkdir(parents=True, exist_ok=True)
css_path.write_text(
"src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');"
)
# Stage two only trusts CSS confirmed fetched this run.
external_files._run_data().fresh_paths.add(css_path)
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
assert batches[1] == [
RemoteFile(
"https://fonts.gstatic.com/roboto.ttf",
font._gfonts_ttf_path(_gspec("Roboto")),
)
]
def test_stage2_skips_missing_css(setup_core: Path) -> None:
batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}]))
assert batches[1] == []
def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None:
"""A bare-mapping extras value (valid raw config) is scanned."""
entries = [
{
"file": "fonts/local.ttf",
"extras": {"file": "gfonts://Roboto", "glyphs": "ABC"},
}
]
batches = list(font.PREFETCH_FILES(entries))
assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))]
def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None:
"""A CSS body that fails to parse is removed from the cache."""
spec = {
"family": "Roboto",
"weight": 400,
"italic": False,
"refresh": font._REFRESH_VALIDATOR("0s"),
}
css_path = font._gfonts_css_path(spec)
with (
patch(
"esphome.components.font.external_files.download_content",
return_value=b"no truetype url here",
),
patch(
"esphome.components.font.external_files.is_fresh_this_run",
return_value=True,
),
pytest.raises(cv.Invalid, match="please report this"),
):
font.download_gfont(spec)
assert not css_path.exists()
with (
patch(
"esphome.components.font.external_files.download_content",
return_value=b"\xff\xfe\x00\x01binary",
),
patch(
"esphome.components.font.external_files.is_fresh_this_run",
return_value=True,
),
pytest.raises(cv.Invalid, match="not a text document"),
):
font.download_gfont(spec)
assert not css_path.exists()
def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None:
"""A CSS body that could not be revalidated is not parsed for a ttf
URL; the cached font is used instead."""
spec = {
"family": "Roboto",
"weight": 400,
"italic": False,
"refresh": font._REFRESH_VALIDATOR("0s"),
}
ttf_path = font._gfonts_ttf_path(spec)
ttf_path.parent.mkdir(parents=True, exist_ok=True)
ttf_path.write_bytes(b"cached ttf")
cache = MagicMock()
with (
patch.object(font, "FONT_CACHE", cache),
patch(
"esphome.components.font.external_files.download_content",
return_value=b"stale css",
),
):
assert font.download_gfont(spec) is spec
cache.__setitem__.assert_called_once_with(spec, ttf_path)
def test_unrevalidated_gfonts_css_without_cached_font_errors(
setup_core: Path,
) -> None:
"""No verified CSS and no cached font is a clear error."""
spec = {
"family": "Roboto",
"weight": 500,
"italic": False,
"refresh": font._REFRESH_VALIDATOR("0s"),
}
with (
patch(
"esphome.components.font.external_files.download_content",
return_value=b"stale css",
),
pytest.raises(cv.Invalid, match="no cached font"),
):
font.download_gfont(spec)
def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None:
"""A leftover CSS from an earlier run is not trusted for stage two."""
css_path = font._gfonts_css_path(_gspec("Roboto"))
css_path.parent.mkdir(parents=True, exist_ok=True)
css_path.write_text(
"src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');"
)
batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}]))
assert batches[1] == []
@@ -0,0 +1,35 @@
"""Tests for the gsl3670 touchscreen prefetch extraction."""
from __future__ import annotations
from pathlib import Path
from esphome.components.gsl3670 import touchscreen as gsl
from esphome.external_files import RemoteFile
def test_prefetch_explicit_url(setup_core: Path) -> None:
url = "https://example.com/fw.bin"
entries = [{"platform": "gsl3670", "firmware": {"url": url}}]
assert list(gsl.PREFETCH_FILES(entries)) == [
[RemoteFile(url, gsl._cache_path(url))]
]
def test_prefetch_model_default_firmware(setup_core: Path) -> None:
entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}]
[files] = list(gsl.PREFETCH_FILES(entries))
assert len(files) == 1
assert (
files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"]
)
assert files[0].path == gsl._cache_path(files[0].url)
def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None:
entries = [
{"platform": "gsl3670", "firmware": {"file": "fw.bin"}},
{"platform": "gsl3670", "model": "CUSTOM"},
{"platform": "gsl3670"},
]
assert list(gsl.PREFETCH_FILES(entries)) == [[]]
@@ -0,0 +1,144 @@
"""Tests for the shared addressable-strip channel order helpers."""
import logging
import pytest
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
from esphome.components.light import (
channel_colors_struct,
migrate_channel_colors,
validate_channel_colors,
)
import esphome.config_validation as cv
from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER
from esphome.types import ConfigType
NO_WHITE = "light::ChannelColors::NO_WHITE"
@pytest.mark.parametrize(
("value", "expected"),
[
("RGB", "RGB"),
("grb", "GRB"),
("BRG", "BRG"),
("rgbw", "RGBW"),
("WRGB", "WRGB"),
("GWRB", "GWRB"),
],
)
def test_validate_channel_colors(value: str, expected: str) -> None:
assert validate_channel_colors(value) == expected
@pytest.mark.parametrize(
"value",
[
"RG", # missing a channel
"RGBB", # duplicate channel
"RRGB", # duplicate channel, correct length
"RGBWW", # two white channels
"RGBX", # unknown channel
"RGBWX", # unknown channel, correct length
"",
],
)
def test_validate_channel_colors_rejects_invalid(value: str) -> None:
with pytest.raises(cv.Invalid, match="is not a valid channel order"):
validate_channel_colors(value)
@pytest.mark.parametrize(
("value", "expected"),
[
("RGB", (0, 1, 2, NO_WHITE)),
("GRB", (1, 0, 2, NO_WHITE)),
("BRG", (1, 2, 0, NO_WHITE)),
("RGBW", (0, 1, 2, 3)),
("GRBW", (1, 0, 2, 3)),
("WRGB", (1, 2, 3, 0)),
("GWRB", (2, 0, 3, 1)),
],
)
def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None:
struct = channel_colors_struct(value)
assert str(struct.base) == "light::ChannelColors"
assert tuple(str(arg) for arg in struct.args.values()) == tuple(
str(field) for field in expected
)
def _migrate(config: ConfigType) -> ConfigType:
return migrate_channel_colors(removed_in="2027.3.0", component="test_strip")(config)
def test_migrate_passes_through_channel_colors() -> None:
config = {CONF_CHANNEL_COLORS: "GRBW"}
assert _migrate(config) == {CONF_CHANNEL_COLORS: "GRBW"}
@pytest.mark.parametrize(
("deprecated", "expected", "named"),
[
({}, "GRB", "'rgb_order' is"),
(
{CONF_IS_RGBW: False, CONF_IS_WRGB: False},
"GRB",
"'rgb_order', 'is_rgbw' and 'is_wrgb' are",
),
({CONF_IS_RGBW: True}, "GRBW", "'rgb_order' and 'is_rgbw' are"),
({CONF_IS_WRGB: True}, "WGRB", "'rgb_order' and 'is_wrgb' are"),
],
)
def test_migrate_folds_deprecated_keys(
deprecated: ConfigType,
expected: str,
named: str,
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_RGB_ORDER: "GRB", "num_leds": 1, **deprecated}
with caplog.at_level(logging.WARNING):
result = _migrate(config)
assert result == {CONF_CHANNEL_COLORS: expected, "num_leds": 1}
assert f"[test_strip] {named} deprecated" in caplog.text
assert f"'{CONF_CHANNEL_COLORS}: {expected}'" in caplog.text
assert "2027.3.0" in caplog.text
def test_migrate_does_not_mutate_input() -> None:
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
_migrate(config)
assert config == {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True}
@pytest.mark.parametrize("deprecated", [CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB])
def test_migrate_rejects_mixing_old_and_new(deprecated: str) -> None:
config = {CONF_CHANNEL_COLORS: "GRBW", deprecated: "GRB"}
with pytest.raises(cv.Invalid, match=f"cannot be combined with '{deprecated}'"):
_migrate(config)
def test_migrate_reports_every_conflicting_key() -> None:
config = {
CONF_CHANNEL_COLORS: "GRBW",
CONF_RGB_ORDER: "GRB",
CONF_IS_RGBW: True,
CONF_IS_WRGB: False,
}
with pytest.raises(
cv.Invalid, match="cannot be combined with 'rgb_order', 'is_rgbw' and 'is_wrgb'"
):
_migrate(config)
def test_migrate_requires_channel_colors() -> None:
with pytest.raises(cv.Invalid, match=f"'{CONF_CHANNEL_COLORS}' is required"):
_migrate({"num_leds": 1})
def test_migrate_rejects_is_rgbw_with_is_wrgb() -> None:
config = {CONF_RGB_ORDER: "GRB", CONF_IS_RGBW: True, CONF_IS_WRGB: True}
with pytest.raises(cv.Invalid, match="cannot both be enabled"):
_migrate(config)
@@ -53,9 +53,12 @@ def test_nonzero_indices_are_nonzero(gamma: float) -> None:
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
@pytest.mark.parametrize("gamma", [1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
"""The gamma table must be monotonically non-decreasing.
gamma_table_reverse_search()'s binary search depends on this.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
@@ -115,3 +118,13 @@ def test_lut_output_monotonically_nondecreasing() -> None:
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result
def test_table_matches_raw_power_curve() -> None:
"""Check the gamma table against known good values for gamma=2.8."""
table = generate_gamma_table(2.8)
golden = {1: 1, 5: 1, 15: 24, 27: 122, 28: 135, 100: 4766, 200: 33193, 254: 64818}
for i, expected in golden.items():
assert table[i] == expected, (
f"index {i}: table[{i}]={table[i]} expected {expected}"
)
@@ -0,0 +1,206 @@
"""Tests for the LVGL table widget's C++ code generation."""
from __future__ import annotations
import pytest
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.defines import set_widgets_completed
from esphome.components.lvgl.lvcode import LvContext
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.trigger import generate_triggers
from esphome.components.lvgl.widgets import Widget, widget_to_code
from esphome.components.lvgl.widgets.table import table_spec
from esphome.const import (
CONF_AUTOMATION_ID,
CONF_ON_VALUE,
CONF_THEN,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, TemplateArguments
from esphome.yaml_util import make_data_base
async def _create_table(raw_config: dict) -> Widget:
"""Validate `raw_config` as a table widget and generate its creation code."""
config = container_schema(table_spec)(raw_config)
parent = MockObj("parent_obj")
async with LvContext():
return await widget_to_code(config, table_spec, parent)
def _statements() -> list[str]:
return [str(s) for s in CORE.main_statements]
@pytest.mark.asyncio
async def test_create_table_sets_row_and_column_count(setup_core) -> None:
await _create_table(
{"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]}
)
statements = _statements()
assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements)
assert any(
"lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements
)
@pytest.mark.asyncio
async def test_create_table_writes_cell_values(setup_core) -> None:
await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]})
statements = _statements()
assert any(
'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s
for s in statements
)
assert any(
'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s
for s in statements
)
@pytest.mark.asyncio
async def test_create_table_sets_cell_control_flags(setup_core) -> None:
await _create_table(
{
"id": "table_ctrl",
"rows": [
{
"cells": [
{"text": "wide", "merge_right": True},
{"text": "cropped", "text_crop": True},
]
}
],
}
)
statements = _statements()
assert any(
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)"
in s
for s in statements
)
assert any(
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)"
in s
for s in statements
)
# text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted.
assert not any(
"table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements
)
@pytest.mark.asyncio
async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None:
await _create_table({"id": "table_px", "columns": [{"width": 96}]})
statements = _statements()
assert any(
"lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements
)
@pytest.mark.asyncio
async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None:
"""Regression test: lv_table_set_column_width() only accepts a literal
pixel count, so a percentage width must not be passed to it directly -
it has to go through the LvTableType helper that recomputes it at
runtime from the table's actual content width.
"""
await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]})
statements = _statements()
assert any("table_pct->init_column_pct(1)" in s for s in statements)
assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements)
assert not any(
"lv_table_set_column_width(table_pct->obj, 0" in s for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_with_both_indices(setup_core) -> None:
await _create_table(
{"id": "table_sel_both", "selected_row": 1, "selected_column": 2}
)
statements = _statements()
assert any(
"lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None:
await _create_table({"id": "table_sel_row", "selected_row": 1})
statements = _statements()
assert any(
"lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s
for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_omitted_entirely_when_not_configured(
setup_core,
) -> None:
await _create_table({"id": "table_no_selection", "rows": [["a"]]})
statements = _statements()
assert not any("lv_table_set_selected_cell" in s for s in statements)
@pytest.mark.asyncio
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
set_widgets_completed(True)
# Only inspect statements emitted by the action below, not by creation.
before = len(_statements())
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
config = entry.schema(
{"id": "table_update", "row": 1, "column": 1, "text": "new value"}
)
action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id)
await entry.coroutine_fun(config, action_id, TemplateArguments(), [])
statements = _statements()[before:]
assert any(
'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s
for s in statements
)
# Neither control flag was specified, so neither call should be emitted.
assert not any("LV_TABLE_CELL_CTRL" in s for s in statements)
@pytest.mark.asyncio
async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None:
config = container_schema(table_spec)(
{
"id": "table_on_value",
"rows": [["a"]],
"on_value": [
{"lambda": make_data_base("id(table_on_value).get_selected_row();")}
],
}
)
# Auto-generated IDs (trigger/automation/action) are normally resolved to
# unique names by esphome's full config pass before code generation; do
# that by hand here since this test only exercises the widget/trigger
# codegen slice in isolation.
automation_conf = config[CONF_ON_VALUE][0]
automation_conf[CONF_TRIGGER_ID].resolve([])
automation_conf[CONF_AUTOMATION_ID].resolve([])
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
parent = MockObj("parent_obj")
async with LvContext():
await widget_to_code(config, table_spec, parent)
set_widgets_completed(True)
await generate_triggers()
statements = _statements()
assert any(
"table_on_value->obj" in s
and "add_event_cb" in s
and "LV_EVENT_VALUE_CHANGED" in s
for s in statements
)
@@ -0,0 +1,142 @@
"""Tests for the LVGL table widget's configuration validation."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.widgets.table import (
CONF_MERGE_RIGHT,
CONF_TEXT_CROP,
TABLE_SCHEMA,
)
def test_minimal_config_is_valid() -> None:
assert TABLE_SCHEMA({}) == {}
def test_row_shorthand_expands_to_plain_cells() -> None:
config = TABLE_SCHEMA({"rows": [["Name", "Value"]]})
[row] = config["rows"]
assert row["cells"] == [{"text": "Name"}, {"text": "Value"}]
def test_row_dict_form_with_cell_overrides() -> None:
config = TABLE_SCHEMA(
{
"rows": [
{
"cells": [
"Temp",
{"text": "22.5", "text_crop": True, "merge_right": True},
]
}
]
}
)
[row] = config["rows"]
assert row["cells"][0] == {"text": "Temp"}
assert row["cells"][1] == {
"text": "22.5",
"merge_right": True,
"text_crop": True,
}
def test_row_count_defaults_are_not_injected_by_the_schema() -> None:
# Inference of row/column counts from `rows` happens at code generation
# time, not during validation - the schema should leave them unset.
config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]})
assert "row_count" not in config
assert "column_count" not in config
def test_explicit_row_and_column_count_are_kept() -> None:
config = TABLE_SCHEMA({"row_count": 5, "column_count": 3})
assert config["row_count"] == 5
assert config["column_count"] == 3
def test_row_count_too_small_for_given_rows_raises() -> None:
with pytest.raises(cv.Invalid, match="row_count"):
TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2})
def test_column_count_too_small_for_given_cells_raises() -> None:
with pytest.raises(cv.Invalid, match="column_count"):
TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2})
def test_columns_list_longer_than_column_count_raises() -> None:
with pytest.raises(cv.Invalid, match="columns"):
TABLE_SCHEMA(
{
"column_count": 1,
"columns": [{"width": 10}, {"width": 20}],
}
)
def test_columns_list_matching_inferred_column_count_is_valid() -> None:
config = TABLE_SCHEMA(
{
"rows": [["a", "b"]],
"columns": [{"width": 10}, {"width": 20}],
}
)
assert [c["width"] for c in config["columns"]] == [10, 20]
@pytest.mark.parametrize(
("width", "expected"),
[
(100, 100),
("50%", 0.5),
("32px", 32),
],
)
def test_column_width_accepts_pixels_and_percent(width, expected) -> None:
config = TABLE_SCHEMA({"columns": [{"width": width}]})
assert config["columns"][0]["width"] == expected
def test_columns_percent_widths_summing_over_100_percent_raises() -> None:
with pytest.raises(cv.Invalid, match="columns"):
TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]})
def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None:
config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]})
assert [c["width"] for c in config["columns"]] == [0.6, 0.4]
def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None:
# Pixel widths aren't part of the percentage budget, so they shouldn't
# count towards the 100% limit.
config = TABLE_SCHEMA(
{"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]}
)
assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2]
def test_selected_row_and_selected_column_are_independently_optional() -> None:
config = TABLE_SCHEMA({"selected_row": 1})
assert config["selected_row"] == 1
assert "selected_column" not in config
def test_cell_update_action_requires_at_least_one_field() -> None:
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
with pytest.raises(cv.Invalid):
entry.schema({"id": "some_table", "row": 0, "column": 0})
def test_cell_update_action_accepts_a_single_field() -> None:
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
config = entry.schema(
{"id": "some_table", "row": 0, "column": 0, "merge_right": True}
)
assert config[CONF_MERGE_RIGHT] is True
assert CONF_TEXT_CROP not in config
@@ -16,6 +16,7 @@ from esphome.const import (
CONF_TYPE,
CONF_URL,
)
from esphome.external_files import RemoteFile
@pytest.fixture
@@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models(
assert mock_download_content_many.call_count == 2
manifest_items = list(mock_download_content_many.call_args_list[0].args[0])
assert manifest_items == [
(f"https://example.com/models/{name}.json", paths[name] / "manifest.json")
RemoteFile(
f"https://example.com/models/{name}.json", paths[name] / "manifest.json"
)
for name in names
]
model_items = list(mock_download_content_many.call_args_list[1].args[0])
assert model_items == [
(f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite")
RemoteFile(
f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite"
)
for name in names
]
@@ -0,0 +1,154 @@
"""Tests for the shelly_dimmer firmware download and prefetch extraction."""
from __future__ import annotations
import hashlib
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome import external_files
from esphome.components.shelly_dimmer import light as shd
from esphome.config_validation import Invalid
from esphome.external_files import RemoteFile
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def test_prefetch_known_version(setup_core: Path) -> None:
entries = [{"firmware": {"version": "51.6", "update": True}}]
stages = list(shd.PREFETCH_FILES(entries))
url, sha = shd.KNOWN_FIRMWARE["51.6"]
assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]]
def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None:
"""Quoted booleans behave as the schema will normalize them."""
url, sha = shd.KNOWN_FIRMWARE["51.6"]
off = [{"firmware": {"version": "51.6", "update": "false"}}]
assert list(shd.PREFETCH_FILES(off)) == [[]]
on = [{"firmware": {"version": "51.6", "update": "true"}}]
assert list(shd.PREFETCH_FILES(on)) == [
[RemoteFile(url, shd._firmware_cache_path(sha))]
]
def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None:
"""A raw sha256 that is not a hash never becomes a path component."""
entries = [
{
"firmware": {
"url": "https://example.com/fw.bin",
"sha256": "/tmp/payload",
"update": True,
}
}
]
assert list(shd.PREFETCH_FILES(entries)) == [[]]
def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None:
"""A sha-keyed cache file needs no revalidation; get_firmware hashes it."""
url, sha = shd.KNOWN_FIRMWARE["51.6"]
shd._firmware_cache_path(sha).write_bytes(b"pinned firmware")
entries = [{"firmware": {"version": "51.6", "update": True}}]
assert list(shd.PREFETCH_FILES(entries)) == [[]]
def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None:
url = "https://example.com/fw.bin"
entries = [{"firmware": {"url": url, "update": True}}]
stages = list(shd.PREFETCH_FILES(entries))
key = external_files.url_cache_key(url)
# No sha means the bytes cannot be verified, so the prefetch itself
# must carry the validator's strict no-stale policy.
assert stages == [
[RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)]
]
def test_prefetch_skips_no_update(setup_core: Path) -> None:
entries = [
{"firmware": {"version": "51.6"}},
{"firmware": "51.6"},
{"firmware": {"version": "0.0", "update": True}},
{},
]
assert list(shd.PREFETCH_FILES(entries)) == [[]]
def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None:
"""A cached blob failing its hash check is discarded and re-downloaded."""
good = b"good firmware"
expected = _sha(good)
path = shd._firmware_cache_path(expected)
path.write_bytes(b"corrupted blob")
with patch(
"esphome.components.shelly_dimmer.light.external_files.download_content",
return_value=good,
) as mock_download:
result = shd.get_firmware(
{
"update": True,
"url": "https://example.com/fw.bin",
"sha256": expected,
}
)
mock_download.assert_called_once()
assert result == [int(b) for b in good]
def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None:
"""A cached blob passing its hash check is used with zero network."""
good = b"good firmware"
expected = _sha(good)
shd._firmware_cache_path(expected).write_bytes(good)
with patch(
"esphome.components.shelly_dimmer.light.external_files.download_content"
) as mock_download:
result = shd.get_firmware(
{
"update": True,
"url": "https://example.com/fw.bin",
"sha256": expected,
}
)
mock_download.assert_not_called()
assert result == [int(b) for b in good]
def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None:
"""A fresh download failing its hash check raises and is not cached."""
expected = _sha(b"expected firmware")
path = shd._firmware_cache_path(expected)
with (
patch(
"esphome.components.shelly_dimmer.light.external_files.download_content",
return_value=b"wrong firmware",
),
pytest.raises(Invalid, match="Hash mismatch"),
):
shd.get_firmware(
{"update": True, "url": "https://example.com/fw.bin", "sha256": expected}
)
assert not path.exists()
def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None:
"""The unverifiable no-hash branch must not accept a stale copy."""
with patch(
"esphome.components.shelly_dimmer.light.external_files.download_content",
return_value=b"fw",
) as mock_download:
shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"})
assert mock_download.call_args.kwargs["allow_stale"] is False
@@ -137,3 +137,77 @@ def test_process_stacktrace_esp32_crash_handler(
state = process_stacktrace(config, line_bt1, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# Reason line carries no address, must not trigger a decode
line_reason = "[E][esp32.crash:079]: Reason: Fault - LoadProhibited (cause 28)"
state = process_stacktrace(config, line_reason, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at code (e.g. jumping through a corrupted pointer) decodes
line_excvaddr = "[E][esp32.crash:081]: EXCVADDR: 0x400D9ABC (faulting address)"
state = process_stacktrace(config, line_excvaddr, False)
mock_esp32_decode_pc.assert_called_once_with(config, "400D9ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at data (heap/null) is not a code address, must be ignored
line_excvaddr_data = (
"[E][esp32.crash:081]: EXCVADDR: 0x0000001C (faulting address)"
)
state = process_stacktrace(config, line_excvaddr_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at code decodes
line_mtval = "[E][esp32.crash:081]: MTVAL: 0x42001234 (faulting address)"
state = process_stacktrace(config, line_mtval, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42001234")
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at data must be ignored
line_mtval_data = "[E][esp32.crash:081]: MTVAL: 0x3FC80123 (faulting address)"
state = process_stacktrace(config, line_mtval_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
def test_process_stacktrace_esp32_foreign_crash(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
"""Crash records from a different firmware build must not be decoded."""
from esphome.components.esp32 import process_stacktrace
config = {"name": "test"}
line_note = (
"[E][esp32.crash:390]: Captured by a different firmware build; "
"addresses belong to that build's ELF"
)
state = process_stacktrace(config, line_note, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
# Lowercase labels are deliberately not matched by any decoder regex,
# since symbols would come from the wrong ELF
lines_addrs = [
"[E][esp32.crash:391]: pc: 0x400D1234",
"[E][esp32.crash:392]: excvaddr: 0x400D5678",
"[E][esp32.crash:392]: mtval: 0x42001234",
"[E][esp32.crash:393]: bt0: 0x400F19A6",
"[E][esp32.crash:394]: other core (0):",
"[E][esp32.crash:395]: bt15: 0x42005ABC",
]
for line in lines_addrs:
state = process_stacktrace(config, line, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
@@ -0,0 +1,48 @@
"""Tests for the espnow component's final validation."""
import pytest
from esphome.components.esp32.const import (
VARIANT_ESP32C3,
VARIANT_ESP32H2,
VARIANT_ESP32P4,
)
from esphome.components.espnow import _validate_variant
import esphome.config_validation as cv
import esphome.final_validate as fv
from esphome.types import ConfigType
def _run(
monkeypatch, variant: str, full_config: dict, config: ConfigType
) -> ConfigType:
monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant)
token = fv.full_config.set(full_config)
try:
return _validate_variant(config)
finally:
fv.full_config.reset(token)
def test_variant_with_native_wifi_passes(monkeypatch) -> None:
"""A variant with a native Wi-Fi PHY needs no shim; config passes through."""
config = {"id": "espnow"}
assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config
def test_radioless_non_p4_variant_rejected(monkeypatch) -> None:
"""Radio-less variants without any ESP-NOW path are rejected outright."""
with pytest.raises(cv.Invalid, match="not supported"):
_run(monkeypatch, VARIANT_ESP32H2, {}, {})
def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None:
"""The P4 needs the esp32_hosted shim to supply the esp_now_* symbols."""
with pytest.raises(cv.Invalid, match="esp32_hosted"):
_run(monkeypatch, VARIANT_ESP32P4, {}, {})
def test_p4_with_esp32_hosted_passes(monkeypatch) -> None:
"""The P4 with esp32_hosted present validates; config passes through."""
config = {"id": "espnow"}
assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config
+36 -2
View File
@@ -2,7 +2,8 @@
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components import bk72xx, ln882x, rtl87xx
from esphome.components.libretiny import BASE_SCHEMA, _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
@@ -11,7 +12,7 @@ from esphome.components.libretiny.const import (
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
from esphome.core import CORE, KEY_CORE
@pytest.fixture
@@ -50,3 +51,36 @@ def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> No
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
def test_platform_schemas_are_isolated_instances() -> None:
"""Each LibreTiny platform must own its CONFIG_SCHEMA instance.
BASE_SCHEMA is shared; every platform prepends its own _set_core_data
extra. On the shared object, importing two platform modules in one process
made either platform's validation run both extras, so the wrong platform's
component data won and known boards failed to resolve.
"""
platforms = (bk72xx, ln882x, rtl87xx)
schemas = [platform.CONFIG_SCHEMA for platform in platforms]
assert len({id(schema) for schema in (BASE_SCHEMA, *schemas)}) == 4
# The shared base must not have accumulated any platform's extra.
# prepend_extra wraps validators in _Schema, so unwrap before comparing.
base_extras = [extra.schema for extra in BASE_SCHEMA._extra_schemas]
for platform in platforms:
assert platform._set_core_data not in base_extras
def test_each_platform_resolves_its_own_boards() -> None:
"""Validating one platform's config must leave that platform's component
data in CORE.data. On the shared schema, the last-imported platform's
_set_core_data won for every platform, so known boards failed to resolve
with "This board is unknown"."""
CORE.data[KEY_CORE] = {} # written by the schema's _update_core_data extra
for platform, board in (
(ln882x, "generic-ln882h"),
(bk72xx, "generic-bk7252"),
(rtl87xx, "generic-rtl8720cf-2mb-896k"),
):
platform.CONFIG_SCHEMA({CONF_BOARD: board})
assert CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] is platform.COMPONENT_DATA
+97 -10
View File
@@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered
by the framework tests under ``tests/unit_tests/``.
"""
from pathlib import Path
import re
from esphome.components import rp2
def test_board_id_has_wifi_for_known_wifi_board() -> None:
"""``rpipicow`` is the canonical Pico W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipicow") is True
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
"""Plain ``rpipico`` has no CYW43 → False."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico") is False
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico2w") is True
@@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
block and any genuinely-unsupported config trips the existing
"no CYW43" guard at compile time.
"""
from esphome.components import rp2
assert rp2.board_id_has_wifi("not-a-real-board-id") is True
@@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None:
opts in via ``ALIASES``; without this declaration the rename
framework wouldn't route legacy configs.
"""
from esphome.components import rp2
assert "rp2040" in rp2.ALIASES
assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0"
@@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None:
assert rp2040_boards is rp2_boards
assert rp2040_generate is rp2_generate
def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None:
"""The segment pool is global while the send queue is per-PCB.
lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``,
which is the floor for a *single* connection: at equality one busy PCB can
drain the pool for every other PCB. Dropping back to that floor would
rebuild the starvation this sizing exists to prevent, and nothing in the
build would complain.
"""
assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN
def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None:
"""``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on
``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the
heap past that bound is a real option, but it should be a deliberate one
rather than a side effect of tuning.
"""
assert rp2.LWIP_MEM_SIZE <= 64000
def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None:
"""Pin the floor as well as the ceiling.
The ceiling above is satisfied by arduino-pico's own 16 KB, which is the
value this change exists to move off, so on its own it would let a revert
through. Derive the floor from the sizing comment on the constant: with
TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block
(pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB),
a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's
max_connections on rp2 is 4. Room for three concurrent senders is the
minimum that makes the change worth making; 16 KB does not reach it.
"""
segments_per_full_send_buf = 4
bytes_per_mss_block = 1536
concurrent_senders = 3
assert (
concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block
<= rp2.LWIP_MEM_SIZE
)
def test_lwip_defines_carry_the_sizing_into_the_header() -> None:
"""The constants above only matter if they reach the generated header.
``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it
rather than on the constants alone: dropping a key here would silently
fall back to arduino-pico's own value while every other assertion in this
file stayed green.
"""
defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2)
assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE)
assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG)
assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN)
# Socket-derived counts pass through untouched.
assert defines["MEMP_NUM_TCP_PCB"] == "8"
assert defines["MEMP_NUM_UDP_PCB"] == "6"
assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2"
def test_lwipopts_template_renders_every_sizing_value() -> None:
"""Render the template the way _generate_lwipopts_h() does and check the
header that actually ships.
Covers both directions. A ``#define`` block deleted from the template
leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB
heap this change exists to move off, and the loop below catches that. A
placeholder with no dict key would otherwise render empty and emit a bare
``#define FOO``; StrictUndefined turns that into an error instead.
Matching on text also survives a filter or conditional appearing in the
template later, which a placeholder regex would not.
"""
from jinja2 import Environment, StrictUndefined
defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2)
template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text(
encoding="utf-8"
)
rendered = (
Environment(keep_trailing_newline=True, undefined=StrictUndefined)
.from_string(template_text)
.render(**defines)
)
for name, value in defines.items():
assert re.search(
rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE
), f"{name} did not reach the generated header as {value!r}"
@@ -8,7 +8,11 @@ import textwrap
import pytest
from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins
from esphome.components.rp2.generate_boards import (
generate,
load_boards,
parse_variant_pins,
)
PICO_PINS_HEADER = textwrap.dedent("""\
#pragma once
@@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None:
assert boards["rpipico"]["name"] == "Raspberry Pi Pico"
assert boards["rpipico"]["mcu"] == "rp2040"
assert boards["rpipico"]["max_pin"] == 29
# The die key only applies to the RP2350, which ships as more than one die
assert "die" not in boards["rpipico"]
assert "rpipico" in board_pins
assert board_pins["rpipico"]["LED"] == 25
@@ -158,19 +164,195 @@ def test_load_basic_board(arduino_pico: Path) -> None:
def test_load_rp2350_board(arduino_pico: Path) -> None:
"""The Pico 2 uses the RP2350A die, which only exposes GPIO 0-29."""
_add_board(
arduino_pico,
"rpipico2",
mcu="rp2350",
vendor="Raspberry Pi",
name="Pico 2",
pins_header=PICO_PINS_HEADER,
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["rpipico2"]["mcu"] == "rp2350"
assert boards["rpipico2"]["max_pin"] == 47
assert boards["rpipico2"]["max_pin"] == 29
assert boards["rpipico2"]["die"] == "A"
def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None:
"""A variant without PICO_RP2350A cannot be classified; fail loudly."""
_add_board(
arduino_pico,
"no_die_define",
mcu="rp2350",
pins_header=PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="no PICO_RP2350A define"):
load_boards(arduino_pico)
def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None:
"""An unparseable PICO_RP2350A value must not silently widen to B-die."""
_add_board(
arduino_pico,
"hex_die_define",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0x1\n" + PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="unrecognized PICO_RP2350A value"):
load_boards(arduino_pico)
def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None:
"""A third die breaks the "not A means B" reading, so stop rather than guess."""
_add_board(
arduino_pico,
"future_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n"
+ PICO_PINS_HEADER,
)
with pytest.raises(ValueError, match="found a PICO_RP2350C define"):
load_boards(arduino_pico)
def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None:
"""PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter."""
_add_board(
arduino_pico,
"revision_define",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n"
+ PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["revision_define"]["die"] == "A"
def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None:
"""Literal forms like (1u) classify the same as bare 1."""
_add_board(
arduino_pico,
"paren_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A (1u)\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["paren_die"]["max_pin"] == 29
assert boards["paren_die"]["die"] == "A"
def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None:
"""A variant declaring the RP2350B die keeps the full GPIO 0-47 range.
The define uses extra whitespace, matching real variant headers.
"""
_add_board(
arduino_pico,
"weact_rp2350b",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0 // RP2350B\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["weact_rp2350b"]["max_pin"] == 47
assert boards["weact_rp2350b"]["die"] == "B"
def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None:
"""Generic boards leave the die a build-time choice; stay permissive.
The permissive range is a fallback, so the die must be recorded as unknown
rather than as the B die.
"""
_add_board(
arduino_pico,
"generic_rp2350",
mcu="rp2350",
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["generic_rp2350"]["max_pin"] == 47
assert boards["generic_rp2350"]["die"] is None
def test_generated_output_records_die(arduino_pico: Path) -> None:
"""The rendered boards.py carries the die on every RP2350 entry."""
_add_board(
arduino_pico,
"rpipico",
pins_header=PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"a_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"b_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER,
)
_add_board(
arduino_pico,
"menu_die",
mcu="rp2350",
pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER,
)
namespace: dict = {}
exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace)
boards = namespace["BOARDS"]
assert boards["a_die"]["die"] == "A"
assert boards["b_die"]["die"] == "B"
assert boards["menu_die"]["die"] is None
assert "die" not in boards["rpipico"]
def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None:
"""Pin defines beyond the A-die range are dropped from the pin map."""
header = textwrap.dedent("""\
#define PICO_RP2350A 1
#define PIN_LED (25u)
#define PIN_SPI0_MISO (40u)
""")
_add_board(arduino_pico, "a_die", mcu="rp2350", pins_header=header)
board_pins, _ = load_boards(arduino_pico)
assert board_pins["a_die"]["LED"] == 25
assert "MISO" not in board_pins["a_die"]
def test_rp2350a_board_keeps_cyw43_virtual_pins(arduino_pico: Path) -> None:
"""A-die narrowing must not filter CYW43 virtual pins (64-66)."""
_add_board(
arduino_pico,
"rpipico2w",
mcu="rp2350",
pins_header="#define PICO_RP2350A 1\n" + PICOW_PINS_HEADER,
)
board_pins, boards = load_boards(arduino_pico)
assert boards["rpipico2w"]["max_pin"] == 29
assert boards["rpipico2w"]["max_virtual_pin"] == 64
assert board_pins["rpipico2w"]["LED"] == 64
def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None:
@@ -0,0 +1,37 @@
"""Tests for the udp component configuration schema."""
from __future__ import annotations
import pytest
from esphome.components import udp
from esphome.components.packet_transport import (
CONF_BINARY_SENSORS,
CONF_ENCRYPTION,
CONF_PING_PONG_ENABLE,
CONF_PROVIDERS,
CONF_ROLLING_CODE_ENABLE,
CONF_SENSORS,
)
import esphome.config_validation as cv
@pytest.mark.parametrize(
"option",
[
CONF_PROVIDERS,
CONF_ENCRYPTION,
CONF_PING_PONG_ENABLE,
CONF_ROLLING_CODE_ENABLE,
CONF_SENSORS,
CONF_BINARY_SENSORS,
],
)
def test_relocated_option_rejected(option: str) -> None:
"""Options that moved to packet_transport raise a pointing error."""
with pytest.raises(cv.Invalid) as exc_info:
udp.CONFIG_SCHEMA({option: True})
assert (
f"The '{option}' option should now be configured in the 'packet_transport' component"
in str(exc_info.value)
)