mirror of
https://github.com/esphome/esphome.git
synced 2026-09-25 22:10:21 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user