mirror of
https://github.com/esphome/esphome.git
synced 2026-10-25 15:35:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdec0c1485 | ||
|
|
70e85d939d | ||
|
|
25b3c2820a |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from esphome import automation, core
|
||||
from esphome import automation, core, pins
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import (
|
||||
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_FROM,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
|
||||
}
|
||||
|
||||
|
||||
class DisplayModel(Protocol):
|
||||
"""The parts of a display model used by enable_pin_schema()."""
|
||||
|
||||
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
|
||||
|
||||
|
||||
def enable_pin_schema(
|
||||
model: DisplayModel, config: ConfigType
|
||||
) -> dict[cv.Optional, Any]:
|
||||
"""
|
||||
Get the schema entry for a display's enable pins.
|
||||
Pins given in the user config are used as they are. Otherwise the model's default enable
|
||||
pins are used, except any that is also used elsewhere in the config, so a user can take
|
||||
control of that pin, e.g. with a switch.
|
||||
:param config: The display config as given by the user
|
||||
"""
|
||||
schema = cv.ensure_list(pins.gpio_output_pin_schema)
|
||||
default = model.get_default(CONF_ENABLE_PIN, None)
|
||||
if default is None or CONF_ENABLE_PIN in config:
|
||||
return {cv.Optional(CONF_ENABLE_PIN): schema}
|
||||
return {
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=default
|
||||
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
|
||||
}
|
||||
|
||||
|
||||
def validate_rotation(value):
|
||||
value = cv.string(value)
|
||||
value = value.removesuffix("°")
|
||||
|
||||
@@ -146,7 +146,7 @@ def model_schema(config):
|
||||
cv.GenerateID(): cv.declare_id(class_name),
|
||||
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
|
||||
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
|
||||
map_sequence
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Self
|
||||
|
||||
from esphome.components.mipi import check_model_requirements
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -128,24 +128,5 @@ class EpaperModel:
|
||||
return model
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
"""Check that the components this model needs are configured, see check_model_requirements()."""
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv.Optional(CONF_UPDATE_MODE): update_mode,
|
||||
# One or more GPIOs driven high during setup to power on the panel
|
||||
# (e.g. board power-enable rails), before reset and init.
|
||||
cv.Optional(
|
||||
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
|
||||
): cv.ensure_list(pins.gpio_output_pin_schema),
|
||||
**display.enable_pin_schema(model, config),
|
||||
cv.Optional(CONF_RESET_DURATION): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=core.TimePeriod(milliseconds=500)),
|
||||
|
||||
@@ -28,6 +28,7 @@ from esphome.const import (
|
||||
CONF_X,
|
||||
CONF_Y,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import FixedVector
|
||||
|
||||
from ..automation import action_to_code
|
||||
@@ -41,6 +42,7 @@ from ..defines import (
|
||||
CONF_RADIUS,
|
||||
CONF_SRC,
|
||||
CONF_START_ANGLE,
|
||||
LValidator,
|
||||
addr,
|
||||
literal,
|
||||
)
|
||||
@@ -57,7 +59,7 @@ from ..lv_validation import (
|
||||
)
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
||||
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
||||
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
|
||||
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .img import CONF_IMAGE
|
||||
|
||||
@@ -221,7 +223,6 @@ RECT_PROPS = {
|
||||
"radius",
|
||||
"bg_opa",
|
||||
"bg_color",
|
||||
"bg_grad",
|
||||
"border_color",
|
||||
"border_width",
|
||||
"border_opa",
|
||||
@@ -239,6 +240,30 @@ RECT_PROPS = {
|
||||
"shadow_opa",
|
||||
)
|
||||
}
|
||||
RECT_PROPS.update(
|
||||
bg_grad=LValidator(
|
||||
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
|
||||
)
|
||||
)
|
||||
RECT_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
|
||||
cv.Optional(CONF_OPA): cv.invalid(
|
||||
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
|
||||
),
|
||||
}
|
||||
POLYGON_PROPS = {
|
||||
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
|
||||
}
|
||||
POLYGON_SCHEMA = {
|
||||
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
|
||||
**{
|
||||
cv.Optional(prop): cv.invalid(
|
||||
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
|
||||
)
|
||||
for prop in (*RECT_PROPS, CONF_OPA)
|
||||
if prop not in POLYGON_PROPS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _draw_line(layer, dsc, points):
|
||||
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
**DRAW_OPA_SCHEMA,
|
||||
**DRAW_SCHEMA,
|
||||
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**RECT_SCHEMA,
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
|
||||
LINE_PROPS = {
|
||||
"width": STYLE_PROPS["line_width"],
|
||||
"color": STYLE_PROPS["line_color"],
|
||||
"dash-width": STYLE_PROPS["line_dash_width"],
|
||||
"dash-gap": STYLE_PROPS["line_dash_gap"],
|
||||
"dash_width": STYLE_PROPS["line_dash_width"],
|
||||
"dash_gap": STYLE_PROPS["line_dash_gap"],
|
||||
"round_start": lv_bool,
|
||||
"round_end": lv_bool,
|
||||
}
|
||||
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
|
||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
**POLYGON_SCHEMA,
|
||||
},
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
|
||||
_draw_line(layer, line_dsc, points)
|
||||
|
||||
return await draw_to_code(
|
||||
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
import importlib.util
|
||||
from typing import Any, Self
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components.const import CONF_COLOR_DEPTH
|
||||
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
|
||||
import esphome.config_validation as cv
|
||||
@@ -23,6 +25,7 @@ from esphome.const import (
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
@@ -306,6 +309,57 @@ def model_schema_extractor(
|
||||
return decorate
|
||||
|
||||
|
||||
def _is_pin_expander(key: str) -> bool:
|
||||
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
|
||||
from esphome import loader
|
||||
|
||||
if key not in pins.PIN_SCHEMA_REGISTRY:
|
||||
if importlib.util.find_spec(f"esphome.components.{key}") is None:
|
||||
return False
|
||||
# Loading the component registers its pin schema. If it fails to load (the
|
||||
# loader logs why), still require it rather than quietly dropping it.
|
||||
if loader.get_component(key) is None:
|
||||
return True
|
||||
return key in pins.PIN_SCHEMA_REGISTRY
|
||||
|
||||
|
||||
def _pin_expanders(value: Any) -> set[str]:
|
||||
"""Find the pin expander components used by any pin configs in a model's defaults."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return set().union(*(_pin_expanders(v) for v in value))
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
found = set().union(*(_pin_expanders(v) for v in value.values()))
|
||||
if CONF_NUMBER in value:
|
||||
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
|
||||
return found
|
||||
|
||||
|
||||
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component a display model requires is not configured.
|
||||
These are the components in its ``requires`` default plus any pin expander used by its
|
||||
default pins, which is needed even if the user config overrides those pins.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
"""
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
|
||||
if missing := {x for x in requirements if x not in global_config}:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
|
||||
|
||||
class DriverChip:
|
||||
"""
|
||||
A class representing a MIPI DBI driver chip model.
|
||||
@@ -671,32 +725,14 @@ class DriverChip:
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
|
||||
Check that the components this model needs are configured, see check_model_requirements().
|
||||
Also logs a warning if the model is deprecated.
|
||||
"""
|
||||
if deprecation_reason := self.get_default("deprecation_reason"):
|
||||
LOGGER.warning(
|
||||
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
||||
)
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
check_model_requirements(self.name, self.defaults)
|
||||
|
||||
|
||||
def requires_buffer(config) -> bool:
|
||||
|
||||
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
|
||||
}
|
||||
|
||||
|
||||
def model_schema(config: ConfigType) -> cv.All:
|
||||
def model_schema(config: ConfigType) -> cv.Schema:
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
# Dimensions are optional if the model has a default width
|
||||
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
|
||||
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
|
||||
schema = display.FULL_DISPLAY_SCHEMA.extend(
|
||||
return display.FULL_DISPLAY_SCHEMA.extend(
|
||||
{
|
||||
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
|
||||
cv.GenerateID(): cv.declare_id(MipiDsi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
|
||||
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
|
||||
}
|
||||
)
|
||||
return cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32P4]),
|
||||
)
|
||||
|
||||
|
||||
@model_schema_extractor(MODELS, model_schema)
|
||||
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
|
||||
config
|
||||
)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ DsiDriverChip(
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="730Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="960Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
@@ -122,7 +122,7 @@ DsiDriverChip(
|
||||
pclk_frequency="70MHz",
|
||||
lane_bit_rate="965Mbps",
|
||||
color_order="RGB",
|
||||
requires={"psram", "pi4ioe5v6408"},
|
||||
requires={"psram"},
|
||||
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
|
||||
@@ -17,7 +17,7 @@ DsiDriverChip(
|
||||
color_order="RGB",
|
||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||
reset_pin={"xl9535": None, "number": 2},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
|
||||
model.get_default(CONF_DRAW_ROUNDING, 1)
|
||||
),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
|
||||
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
|
||||
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
|
||||
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
schema = model_schema(config)
|
||||
config = cv.All(
|
||||
schema,
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
|
||||
)(config)
|
||||
# Check before the schema, which would fail on default pins of a missing expander
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
config = model_schema(config)(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ ST7701S(
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 17},
|
||||
reset_pin={"xl9535": None, "number": 5},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
pclk_pin=41,
|
||||
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
|
||||
invert_colors=False,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 3},
|
||||
requires={"psram", "xl9535"},
|
||||
requires={"psram"},
|
||||
de_pin=45,
|
||||
hsync_pin=47,
|
||||
vsync_pin=41,
|
||||
|
||||
@@ -115,7 +115,7 @@ st7701s.extend(
|
||||
vsync_pulse_width=8,
|
||||
vsync_back_porch=20,
|
||||
cs_pin={"pca9554": None, "number": 4},
|
||||
requires={"psram", "pca9554"},
|
||||
requires={"psram"},
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
vsync_pin=17,
|
||||
|
||||
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
|
||||
pclk_frequency="16MHz",
|
||||
reset_pin={"ch422g": None, "number": 3},
|
||||
enable_pin={"ch422g": None, "number": 2},
|
||||
requires={"psram", "ch422g"},
|
||||
requires={"psram"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
|
||||
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MipiSpi),
|
||||
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
|
||||
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
|
||||
pins.gpio_output_pin_schema
|
||||
),
|
||||
**display.enable_pin_schema(model, config),
|
||||
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
|
||||
COLOR_ORDERS, upper=True
|
||||
),
|
||||
|
||||
@@ -22,7 +22,6 @@ DriverChip(
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
use_axis_flips=True,
|
||||
reset_delay=20,
|
||||
requires={"pca9554"},
|
||||
initsequence=(
|
||||
delay(100),
|
||||
(0xFF, 0x20, 0x10, 0x10),
|
||||
|
||||
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
class OptionalPinValidationStep(ConfigValidationStep):
|
||||
"""Remove optional pins that are also used elsewhere"""
|
||||
|
||||
# After the ID pass, and before component final validations which may inspect pin use
|
||||
priority = -15.0
|
||||
|
||||
def run(self, result: Config) -> None:
|
||||
if result.errors:
|
||||
return
|
||||
pins.PIN_SCHEMA_REGISTRY.remove_optional_conflicts(result)
|
||||
|
||||
|
||||
class PinUseValidationCheck(ConfigValidationStep):
|
||||
"""Check for pin reuse"""
|
||||
|
||||
@@ -1437,6 +1449,7 @@ def validate_config(
|
||||
result.add_validation_step(PrefetchRemoteFilesValidationStep())
|
||||
result.add_validation_step(IDPassValidationStep())
|
||||
result.add_validation_step(CoreFinalValidateStep())
|
||||
result.add_validation_step(OptionalPinValidationStep())
|
||||
result.add_validation_step(PinUseValidationCheck())
|
||||
|
||||
result.add_validation_step(RemoveReferenceValidationStep())
|
||||
|
||||
+108
-10
@@ -1,8 +1,10 @@
|
||||
from collections.abc import Callable, Hashable
|
||||
from contextvars import ContextVar
|
||||
from functools import reduce
|
||||
import logging
|
||||
from logging import Logger
|
||||
import operator
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -21,17 +23,64 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.schema_extractors import schema_extractor
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, SafeExpType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.config import Config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# A use of a pin: the path of the component using it, the pin provider's id and the pin config
|
||||
PinUse = tuple[list, Any, ConfigType]
|
||||
|
||||
# Set while validating pins that should give way to any other use of the same pin
|
||||
_validating_optional = ContextVar("validating_optional_pins", default=False)
|
||||
|
||||
|
||||
def _remove_value(conf: Any, target: Any) -> bool:
|
||||
"""Remove the object ``target`` from wherever it is nested in ``conf``."""
|
||||
items = conf.items() if isinstance(conf, dict) else enumerate(conf)
|
||||
for key, value in items:
|
||||
if value is target:
|
||||
del conf[key]
|
||||
return True
|
||||
if isinstance(value, (dict, list)) and _remove_value(value, target):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PinRegistry(dict):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pins_used = {}
|
||||
# Entries of pins_used that give way to other uses, keyed by id()
|
||||
self.optional_pins = {}
|
||||
|
||||
def reset(self):
|
||||
self.pins_used = {}
|
||||
self.optional_pins = {}
|
||||
|
||||
def optional(self, validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
"""
|
||||
Wrap a pin validator so the pins it validates give way to any other use of the
|
||||
same pin. Instead of the pin being reported as used in multiple places,
|
||||
remove_optional_conflicts() removes it from the config, unless every other use
|
||||
sets allow_other_uses, in which case the pin is shared. Use this for pins a
|
||||
component supplies as defaults that a user may want to take over.
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def wrapper(value: Any) -> Any:
|
||||
if value == SCHEMA_EXTRACT:
|
||||
return validator
|
||||
token = _validating_optional.set(True)
|
||||
try:
|
||||
return validator(value)
|
||||
finally:
|
||||
_validating_optional.reset(token)
|
||||
|
||||
return wrapper
|
||||
|
||||
def get_count(self, key, id, number):
|
||||
"""
|
||||
@@ -86,7 +135,10 @@ class PinRegistry(dict):
|
||||
self.pins_used[pin_key] = []
|
||||
# client_id identifies the instance of the providing component
|
||||
client_id = result.get(key)
|
||||
self.pins_used[pin_key].append((path_context.get(), client_id, result))
|
||||
entry = (path_context.get(), client_id, result)
|
||||
self.pins_used[pin_key].append(entry)
|
||||
if _validating_optional.get():
|
||||
self.optional_pins[id(entry)] = entry
|
||||
# return the validated pin config
|
||||
return result
|
||||
|
||||
@@ -110,23 +162,69 @@ class PinRegistry(dict):
|
||||
key = self.get_key(key) if isinstance(key, dict) else key
|
||||
return self[key][0]
|
||||
|
||||
def final_validate(self, fconf):
|
||||
"""
|
||||
Run the final validation for all pins, and check for reuse
|
||||
:param fconf: The full config
|
||||
"""
|
||||
def _grouped(self) -> dict[tuple, list[PinUse]]:
|
||||
"""Group pin uses by (schema key, resolved provider id, pin number)."""
|
||||
# Regroup by (schema key, resolved provider id, pin number) rather than
|
||||
# the validate()-time bucket, so the same physical pin reached via an
|
||||
# explicit id and via {address: ...} (or an omitted id) is recognized as
|
||||
# one reuse group regardless of which syntax was used to reference the hub.
|
||||
grouped: dict[tuple, list] = {}
|
||||
grouped: dict[tuple, list[PinUse]] = {}
|
||||
for (key, _, number), pin_list in self.pins_used.items():
|
||||
for entry in pin_list:
|
||||
client_id = entry[1]
|
||||
provider_id = client_id.id if isinstance(client_id, ID) else client_id
|
||||
grouped.setdefault((key, provider_id, number), []).append(entry)
|
||||
return grouped
|
||||
|
||||
for (key, _, _), pin_list in grouped.items():
|
||||
def remove_optional_conflicts(self, fconf: "Config") -> None:
|
||||
"""
|
||||
Remove optional pins that are also used elsewhere from the config, or mark them
|
||||
as shared if every other use sets allow_other_uses. This runs once ids are resolved and before the components' final validation,
|
||||
so those only see the pins that will be used.
|
||||
:param fconf: The full config
|
||||
"""
|
||||
if not self.optional_pins:
|
||||
return
|
||||
for (key, provider_id, _), pin_list in self._grouped().items():
|
||||
optional = [e for e in pin_list if id(e) in self.optional_pins]
|
||||
others = [e for e in pin_list if id(e) not in self.optional_pins]
|
||||
if not optional or not others:
|
||||
continue
|
||||
if all(e[2].get(CONF_ALLOW_OTHER_USES, False) for e in others):
|
||||
# The other uses ask to share the pin, so share it with the optional ones too
|
||||
for _, _, pin_config in optional:
|
||||
pin_config[CONF_ALLOW_OTHER_USES] = True
|
||||
continue
|
||||
for entry in optional:
|
||||
self._remove_optional(fconf, key, provider_id, entry)
|
||||
|
||||
def _remove_optional(
|
||||
self, fconf: "Config", key: str, provider_id: str | None, entry: PinUse
|
||||
) -> None:
|
||||
"""
|
||||
Remove an optional pin from the config and from the pins used. If it cannot be
|
||||
found in the config it is kept, so final_validate() reports the conflict.
|
||||
"""
|
||||
pin_path, _, pin_config = entry
|
||||
if not _remove_value(fconf.get_config_for_path(pin_path), pin_config):
|
||||
return
|
||||
for pin_list in self.pins_used.values():
|
||||
pin_list[:] = [e for e in pin_list if e is not entry]
|
||||
del self.optional_pins[id(entry)]
|
||||
pin = f"{key} {provider_id} pin" if provider_id is not None else "Pin"
|
||||
_LOGGER.info(
|
||||
"%s %s is used elsewhere, so is not applied as a default for %s",
|
||||
pin,
|
||||
pin_config[CONF_NUMBER],
|
||||
"->".join(str(p) for p in pin_path),
|
||||
)
|
||||
|
||||
def final_validate(self, fconf):
|
||||
"""
|
||||
Run the final validation for all pins, and check for reuse
|
||||
:param fconf: The full config
|
||||
"""
|
||||
for (key, _, _), pin_list in self._grouped().items():
|
||||
count = len(pin_list) # number of places same pin used.
|
||||
final_val_fun = self[key][2] # final validation function
|
||||
for pin_path, client_id, pin_config in pin_list:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-sticky
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
# Takes over the model's default enable pin
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: panel_power
|
||||
pin: GPIO47
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-sticky
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for applying the default enable pins of epaper_spi models."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def _enable_pin_numbers(main_cpp: str) -> list[int]:
|
||||
"""Get the GPIO numbers passed to set_enable_pins() in the generated code."""
|
||||
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
if match is None:
|
||||
return []
|
||||
numbers = []
|
||||
for pin_id in match.group(1).split(", "):
|
||||
pin = re.search(rf"{pin_id}->set_pin\(::GPIO_NUM_(\d+)\);", main_cpp)
|
||||
assert pin is not None
|
||||
numbers.append(int(pin.group(1)))
|
||||
return numbers
|
||||
|
||||
|
||||
def test_default_enable_pin(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The model's enable pin is applied when it is not used elsewhere."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_default.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [47]
|
||||
|
||||
|
||||
def test_default_enable_pin_used_elsewhere(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A default enable pin used by a switch is left out instead of failing validation."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_used_elsewhere.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == []
|
||||
assert "panel_power->set_pin(" in main_cpp
|
||||
|
||||
|
||||
def test_user_enable_pins(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Enable pins given in the config are applied as given."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [25, 26]
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: it8951-enable-pins
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
spi:
|
||||
clk_pin: 7
|
||||
mosi_pin: 9
|
||||
miso_pin: 8
|
||||
|
||||
# Takes over the model's default 1.8V logic supply enable pin
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: logic_supply
|
||||
pin: GPIO21
|
||||
|
||||
display:
|
||||
- platform: it8951
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-e1003
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for it8951 configuration validation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome.components.const import CONF_HOLD_STATE
|
||||
@@ -26,3 +28,18 @@ def test_reterminal_e1003_enable_pins_hold_state(
|
||||
enable_pins = config[CONF_ENABLE_PIN]
|
||||
assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11]
|
||||
assert all(pin[CONF_HOLD_STATE] for pin in enable_pins)
|
||||
|
||||
|
||||
def test_reterminal_e1003_enable_pin_used_elsewhere(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A default enable pin used by a switch is left out, and the other is still applied."""
|
||||
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
|
||||
|
||||
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
assert match is not None
|
||||
pin_ids = match.group(1).split(", ")
|
||||
assert len(pin_ids) == 1
|
||||
assert f"{pin_ids[0]}->set_pin(::GPIO_NUM_11);" in main_cpp
|
||||
assert "logic_supply->set_pin(" in main_cpp
|
||||
@@ -0,0 +1,50 @@
|
||||
esphome:
|
||||
name: test-canvas-draw-options
|
||||
on_boot:
|
||||
- lvgl.canvas.draw_rectangle:
|
||||
id: test_canvas
|
||||
x: 0
|
||||
y: 0
|
||||
width: 20
|
||||
height: 10
|
||||
bg_grad: test_grad
|
||||
- lvgl.canvas.draw_line:
|
||||
id: test_canvas
|
||||
dash_width: 4
|
||||
dash_gap: 2
|
||||
points:
|
||||
- 0,0
|
||||
- 10,10
|
||||
|
||||
esp32:
|
||||
board: lolin_c3_mini
|
||||
|
||||
spi:
|
||||
mosi_pin:
|
||||
number: GPIO2
|
||||
ignore_strapping_warning: true
|
||||
clk_pin: GPIO1
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
data_rate: 20MHz
|
||||
model: st7735
|
||||
cs_pin:
|
||||
number: GPIO8
|
||||
ignore_strapping_warning: true
|
||||
dc_pin: GPIO3
|
||||
|
||||
lvgl:
|
||||
gradients:
|
||||
- id: test_grad
|
||||
direction: hor
|
||||
stops:
|
||||
- color: 0xFF0000
|
||||
position: 0
|
||||
- color: 0x0000FF
|
||||
position: 255
|
||||
widgets:
|
||||
- canvas:
|
||||
id: test_canvas
|
||||
width: 32
|
||||
height: 32
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Canvas draw options must map onto the LVGL 9 draw descriptor fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
import esphome.components.lvgl.widgets.canvas # noqa: F401 - registers the actions
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
config_path = (
|
||||
Path(request.fspath).parent / "config" / "canvas_draw_options_test.yaml"
|
||||
)
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_rect_gradient_is_copied_by_value(main_cpp: str) -> None:
|
||||
assert ".bg_grad = *test_grad;" in main_cpp
|
||||
|
||||
|
||||
def test_line_dash_fields(main_cpp: str) -> None:
|
||||
assert ".dash_width = 4;" in main_cpp
|
||||
assert ".dash_gap = 2;" in main_cpp
|
||||
|
||||
|
||||
def test_rect_opa_names_the_per_part_keys() -> None:
|
||||
with pytest.raises(cv.Invalid, match="bg_opa"):
|
||||
ACTION_REGISTRY["lvgl.canvas.draw_rectangle"].schema(
|
||||
{"id": "cv", "x": 0, "y": 0, "width": 10, "height": 10, "opa": "50%"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key", ["opa", "bg_color", "bg_grad", "radius", "shadow_width"]
|
||||
)
|
||||
def test_polygon_rejects_non_border_keys(key: str) -> None:
|
||||
with pytest.raises(cv.Invalid, match="drawn as an outline"):
|
||||
ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
|
||||
{"id": "cv", "points": [{"x": 0, "y": 0}], key: 1}
|
||||
)
|
||||
|
||||
|
||||
def test_polygon_accepts_border_keys() -> None:
|
||||
config = ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
|
||||
{
|
||||
"id": "cv",
|
||||
"points": [{"x": 0, "y": 0}],
|
||||
"border_color": 0xFF0000,
|
||||
"border_width": 2,
|
||||
"border_opa": "50%",
|
||||
}
|
||||
)
|
||||
assert config["border_width"] == 2
|
||||
@@ -0,0 +1,38 @@
|
||||
esphome:
|
||||
name: enable-pins-address
|
||||
|
||||
esp32:
|
||||
board: esp32-p4-evboard
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
speed: 200MHz
|
||||
|
||||
esp_ldo:
|
||||
- channel: 3
|
||||
voltage: 2.5V
|
||||
|
||||
i2c:
|
||||
sda: GPIO7
|
||||
scl: GPIO8
|
||||
|
||||
pi4ioe5v6408:
|
||||
- id: pi4ioe1
|
||||
address: 0x43
|
||||
- id: pi4ioe2
|
||||
address: 0x44
|
||||
|
||||
# Takes over the default enable pin on the hub at 0x44, given by address
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: takeover
|
||||
pin:
|
||||
pi4ioe5v6408:
|
||||
address: 0x44
|
||||
number: 1
|
||||
|
||||
display:
|
||||
- platform: mipi_dsi
|
||||
id: default_pins
|
||||
model: TEST-ENABLE-BY-ADDRESS
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for applying the default enable pins of mipi_dsi models."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.mipi import DriverChip
|
||||
|
||||
|
||||
def test_default_enable_pins_by_address(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Default enable pins naming a hub by address resolve to that hub, and only
|
||||
the pin used elsewhere is left out, not the same pin number on the other hub."""
|
||||
from esphome.components.mipi_dsi.display import MODELS
|
||||
|
||||
models = set(DriverChip.models)
|
||||
model = MODELS["M5STACK-TAB5"].extend(
|
||||
"TEST-ENABLE-BY-ADDRESS",
|
||||
enable_pin=[
|
||||
{"pi4ioe5v6408": {"address": 0x43}, "number": 1},
|
||||
{"pi4ioe5v6408": {"address": 0x44}, "number": 1},
|
||||
],
|
||||
)
|
||||
for name in set(DriverChip.models) - models:
|
||||
monkeypatch.delitem(DriverChip.models, name)
|
||||
monkeypatch.setitem(MODELS, model.name, model)
|
||||
|
||||
main_cpp = generate_main(component_fixture_path("enable_pins_address.yaml"))
|
||||
|
||||
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
assert match is not None
|
||||
pin_ids = match.group(1).split(", ")
|
||||
assert len(pin_ids) == 1
|
||||
assert f"{pin_ids[0]}->set_parent(pi4ioe1);" in main_cpp
|
||||
assert f"{pin_ids[0]}->set_pin(1);" in main_cpp
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4
|
||||
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4, VARIANT_ESP32S3
|
||||
|
||||
# Importing the I/O expanders registers their pin schemas with
|
||||
# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed
|
||||
@@ -215,3 +215,20 @@ def test_code_generation(
|
||||
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp
|
||||
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
|
||||
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
|
||||
|
||||
|
||||
def test_unsupported_variant_reported_before_requirements(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On an unsupported chip the variant error is shown, not the missing components."""
|
||||
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
|
||||
from esphome.core import CORE
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
||||
)
|
||||
CORE.raw_config = {}
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"This feature is only available on ESP32P4"):
|
||||
CONFIG_SCHEMA({"model": "SEEED-RETERMINAL-D1001"})
|
||||
@@ -0,0 +1,32 @@
|
||||
esphome:
|
||||
name: enable-pins-test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
i2c:
|
||||
sda: 8
|
||||
scl: 9
|
||||
|
||||
ch422g:
|
||||
- id: ch422g_hub
|
||||
|
||||
# Takes over the model's default backlight enable pin
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: backlight
|
||||
pin:
|
||||
ch422g: ch422g_hub
|
||||
number: 2
|
||||
|
||||
display:
|
||||
- platform: mipi_rgb
|
||||
id: default_pins
|
||||
model: ESP32-S3-TOUCH-LCD-7-800X480
|
||||
update_interval: never
|
||||
@@ -0,0 +1,24 @@
|
||||
esphome:
|
||||
name: enable-pins-test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
i2c:
|
||||
sda: 8
|
||||
scl: 9
|
||||
|
||||
ch422g:
|
||||
- id: ch422g_hub
|
||||
|
||||
display:
|
||||
- platform: mipi_rgb
|
||||
id: default_pins
|
||||
model: ESP32-S3-TOUCH-LCD-7-800X480
|
||||
update_interval: never
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for applying the default enable pins of mipi_rgb models."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def _enable_pin_numbers(main_cpp: str) -> list[int]:
|
||||
"""Get the expander pin numbers passed to set_enable_pins() in the generated code."""
|
||||
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
|
||||
assert match is not None
|
||||
numbers = []
|
||||
for pin_id in match.group(1).split(", "):
|
||||
pin = re.search(rf"{pin_id}->set_pin\((\d+)\);", main_cpp)
|
||||
assert pin is not None
|
||||
numbers.append(int(pin.group(1)))
|
||||
return numbers
|
||||
|
||||
|
||||
def test_default_enable_pins(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""All of the model's enable pins are applied when none are used elsewhere."""
|
||||
main_cpp = generate_main(component_fixture_path("enable_pins_default.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [2, 6]
|
||||
|
||||
|
||||
def test_default_enable_pin_used_elsewhere(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A default enable pin used by a switch is left out instead of failing validation."""
|
||||
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
|
||||
assert _enable_pin_numbers(main_cpp) == [6]
|
||||
assert "backlight->set_pin(" in main_cpp
|
||||
@@ -8,7 +8,12 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
|
||||
# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
|
||||
@@ -87,3 +92,78 @@ def test_model_requires_psram_and_expander(
|
||||
"spi_id": "spi_bus",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_model_requires_expander_when_pins_overridden(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Any,
|
||||
) -> None:
|
||||
"""A model with default pins on an expander requires it even when the user config
|
||||
overrides all of those pins, and without the expander being listed in ``requires``."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
||||
)
|
||||
set_component_config("psram", True)
|
||||
CORE.raw_config = {"psram": True}
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
|
||||
):
|
||||
_validated(
|
||||
{
|
||||
"model": "ESP32-S3-TOUCH-LCD-7-800X480",
|
||||
"reset_pin": 10,
|
||||
"enable_pin": [11, 12],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_model_requires_expander_with_default_pins(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Any,
|
||||
) -> None:
|
||||
"""The missing expander is reported before the model's default pins on it are validated."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
||||
)
|
||||
set_component_config("psram", True)
|
||||
CORE.raw_config = {"psram": True}
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
|
||||
):
|
||||
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
|
||||
|
||||
|
||||
def test_unsupported_variant_reported_before_requirements(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On an unsupported chip the variant error is shown, not the missing components."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32-c3-devkitm-1", KEY_VARIANT: VARIANT_ESP32C3},
|
||||
)
|
||||
CORE.raw_config = {}
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"This feature is only available on ESP32S3, ESP32P4, ESP32S31",
|
||||
):
|
||||
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
|
||||
|
||||
|
||||
def test_expander_that_fails_to_load_is_still_required(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An expander component that exists but fails to load is still counted as required."""
|
||||
from esphome import loader, pins
|
||||
from esphome.components import mipi
|
||||
|
||||
monkeypatch.setattr(pins, "PIN_SCHEMA_REGISTRY", pins.PinRegistry())
|
||||
monkeypatch.setattr(loader, "get_component", lambda key: None)
|
||||
defaults = {"enable_pin": {"xl9535": "hub", "number": 1, "mode": "output"}}
|
||||
assert mipi._pin_expanders(defaults) == {"xl9535"}
|
||||
@@ -1115,6 +1115,21 @@ lvgl:
|
||||
skew_y: 5
|
||||
pivot_x: 25
|
||||
pivot_y: 25
|
||||
- lvgl.canvas.draw_rectangle:
|
||||
x: 10
|
||||
y: 10
|
||||
width: 120
|
||||
height: 80
|
||||
bg_grad: color_bar
|
||||
- lvgl.canvas.draw_line:
|
||||
color: green
|
||||
width: 2
|
||||
opa: 60%
|
||||
dash_width: 6
|
||||
dash_gap: 3
|
||||
points:
|
||||
- 10,10
|
||||
- 90,90
|
||||
- lvgl.canvas.draw_line:
|
||||
color: blue
|
||||
width: 4
|
||||
@@ -1127,7 +1142,6 @@ lvgl:
|
||||
- 200, 50
|
||||
- 50,50
|
||||
- lvgl.canvas.draw_polygon:
|
||||
bg_color: teal
|
||||
border_color: white
|
||||
border_width: 2
|
||||
border_opa: cover
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
esphome:
|
||||
name: test-copy-entity-state
|
||||
on_boot:
|
||||
# Before the copies' setup
|
||||
- priority: 800
|
||||
then:
|
||||
- sensor.template.publish:
|
||||
id: early_sensor
|
||||
state: 1.5
|
||||
- binary_sensor.template.publish:
|
||||
id: early_binary
|
||||
state: true
|
||||
- text_sensor.template.publish:
|
||||
id: early_text
|
||||
state: early
|
||||
# After the copies' setup
|
||||
- priority: -100
|
||||
then:
|
||||
- sensor.template.publish:
|
||||
id: late_sensor
|
||||
state: 2.5
|
||||
- text_sensor.template.publish:
|
||||
id: late_text
|
||||
state: late
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
logger:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: early_sensor
|
||||
update_interval: never
|
||||
- platform: template
|
||||
id: late_sensor
|
||||
update_interval: never
|
||||
- platform: copy
|
||||
source_id: early_sensor
|
||||
name: Copy Early Sensor
|
||||
- platform: copy
|
||||
source_id: late_sensor
|
||||
name: Copy Late Sensor
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
id: early_binary
|
||||
- platform: copy
|
||||
source_id: early_binary
|
||||
name: Copy Early Binary
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
id: early_text
|
||||
update_interval: never
|
||||
- platform: template
|
||||
id: late_text
|
||||
update_interval: never
|
||||
- platform: copy
|
||||
source_id: early_text
|
||||
name: Copy Early Text
|
||||
- platform: copy
|
||||
source_id: late_text
|
||||
name: Copy Late Text
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Update Sources
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: early_sensor
|
||||
state: 3.5
|
||||
- sensor.template.publish:
|
||||
id: late_sensor
|
||||
state: 7.25
|
||||
- binary_sensor.template.publish:
|
||||
id: early_binary
|
||||
state: false
|
||||
- text_sensor.template.publish:
|
||||
id: early_text
|
||||
state: changed
|
||||
- text_sensor.template.publish:
|
||||
id: late_text
|
||||
state: updated
|
||||
@@ -0,0 +1,45 @@
|
||||
esphome:
|
||||
name: test-copy-initially-off
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
logger:
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy DEPENDENCIES = ["uart"]
|
||||
uart:
|
||||
baud_rate: 9600
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 9600
|
||||
|
||||
globals:
|
||||
- id: changes
|
||||
type: int
|
||||
|
||||
binary_sensor:
|
||||
# Holds off from its constructor, so it never publishes a first state
|
||||
- platform: uart
|
||||
id: source_binary
|
||||
uart_id: mock_uart
|
||||
data: [0x01]
|
||||
- platform: copy
|
||||
source_id: source_binary
|
||||
name: Copy Binary
|
||||
on_state_change:
|
||||
- lambda: id(changes)++;
|
||||
- component.update: change_count
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: change_count
|
||||
name: Change Count
|
||||
update_interval: never
|
||||
lambda: return id(changes);
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Integration test for the copy sensor, binary sensor and text sensor platforms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import (
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
EntityState,
|
||||
SensorState,
|
||||
TextSensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
INITIAL: dict[str, tuple[type[EntityState], float | bool | str]] = {
|
||||
"copy_early_sensor": (SensorState, 1.5),
|
||||
"copy_late_sensor": (SensorState, 2.5),
|
||||
"copy_early_binary": (BinarySensorState, True),
|
||||
"copy_early_text": (TextSensorState, "early"),
|
||||
"copy_late_text": (TextSensorState, "late"),
|
||||
}
|
||||
UPDATED: dict[str, tuple[type[EntityState], float | bool | str]] = {
|
||||
"copy_early_sensor": (SensorState, 3.5),
|
||||
"copy_late_sensor": (SensorState, 7.25),
|
||||
"copy_early_binary": (BinarySensorState, False),
|
||||
"copy_early_text": (TextSensorState, "changed"),
|
||||
"copy_late_text": (TextSensorState, "updated"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_entity_state(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Copies hold the state their source published before and after their setup, then follow it."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
keys = {name: require_entity(entities, name).key for name in INITIAL}
|
||||
button = require_entity(entities, "update_sources", ButtonInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
for name, (cls, state) in INITIAL.items():
|
||||
key = keys[name]
|
||||
assert sub.initial_states[key] == cls(key=key, state=state), name
|
||||
|
||||
names = list(UPDATED)
|
||||
waits = [sub.waiter.next_state(keys[name]) for name in names]
|
||||
client.button_command(button.key)
|
||||
for name, got in zip(names, await asyncio.gather(*waits), strict=True):
|
||||
cls, state = UPDATED[name]
|
||||
assert got == cls(key=keys[name], state=state), name
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Integration test for a copy binary sensor whose source starts off."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState, SensorInfo, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_initially_off(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The copy takes the source's constructor state and its on_state_change sees it."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
copy = require_entity(entities, "copy_binary", BinarySensorInfo)
|
||||
count = require_entity(entities, "change_count", SensorInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
assert sub.initial_states[copy.key] == BinarySensorState(
|
||||
key=copy.key, state=False
|
||||
)
|
||||
assert sub.initial_states[count.key] == SensorState(key=count.key, state=1.0)
|
||||
@@ -141,6 +141,21 @@ def test_convert_walks_callable_schema_extractor() -> None:
|
||||
assert "foo" in config_var["schema"]["config_vars"]
|
||||
|
||||
|
||||
def test_convert_sees_pin_through_optional_pin_wrapper() -> None:
|
||||
"""A pin schema wrapped by PIN_SCHEMA_REGISTRY.optional() is still dumped as a pin."""
|
||||
from esphome import pins
|
||||
|
||||
_bls.add_pin_validators()
|
||||
schema = pins.gpio_output_pin_schema
|
||||
expected: dict = {}
|
||||
_bls.convert(schema, expected, "/test")
|
||||
config_var: dict = {}
|
||||
_bls.convert(pins.PIN_SCHEMA_REGISTRY.optional(schema), config_var, "/test")
|
||||
|
||||
assert expected["type"] == "pin"
|
||||
assert config_var == expected
|
||||
|
||||
|
||||
def test_convert_emits_key_constraints() -> None:
|
||||
base = cv.Schema({cv.Optional("transport", default="tcp"): cv.string})
|
||||
schema = cv.All(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for esphome/pins.py."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
import functools
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -285,6 +286,136 @@ def test_registry_final_validate_runs_hook_with_parent_config(
|
||||
assert not config.errors
|
||||
|
||||
|
||||
def _validate_at(
|
||||
registry: pins.PinRegistry, path: list, conf: dict, optional: bool = False
|
||||
) -> dict:
|
||||
"""Validate a hub pin as if it is in the component at ``path``."""
|
||||
token = path_context.set(path)
|
||||
try:
|
||||
validator = functools.partial(registry.validate, key="hub")
|
||||
if optional:
|
||||
validator = registry.optional(validator)
|
||||
return validator(conf)
|
||||
finally:
|
||||
path_context.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pin_path")
|
||||
def test_registry_final_validate_removes_optional_pin_used_elsewhere(
|
||||
registry: pins.PinRegistry, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An optional pin also used elsewhere is removed from its config without error."""
|
||||
client_id = ID("h1", is_declaration=True)
|
||||
switch_pin = _validate_at(
|
||||
registry, ["switch", 0], {"hub": client_id, CONF_NUMBER: 2}
|
||||
)
|
||||
kept = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 6}, optional=True
|
||||
)
|
||||
dropped = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 2}, optional=True
|
||||
)
|
||||
config = _final_config(client_id)
|
||||
config["switch"] = [{"pin": switch_pin}]
|
||||
config["display"] = [{"enable_pin": [dropped, kept]}]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
registry.remove_optional_conflicts(config)
|
||||
|
||||
# Removed before final validation, so later checks of pin use don't see it
|
||||
assert config["display"][0]["enable_pin"] == [kept]
|
||||
assert config["switch"][0]["pin"] is switch_pin
|
||||
assert registry.get_count("hub", client_id, 2) == 1
|
||||
assert "hub h1 pin 2 is used elsewhere" in caplog.text
|
||||
registry.final_validate(config)
|
||||
assert not config.errors
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pin_path")
|
||||
def test_registry_shares_optional_pin_when_others_allow_it(
|
||||
registry: pins.PinRegistry,
|
||||
) -> None:
|
||||
"""An optional pin is shared, not removed, when every other use sets allow_other_uses."""
|
||||
client_id = ID("h1", is_declaration=True)
|
||||
switch_pin = _validate_at(
|
||||
registry,
|
||||
["switch", 0],
|
||||
{"hub": client_id, CONF_NUMBER: 2, CONF_ALLOW_OTHER_USES: True},
|
||||
)
|
||||
optional = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 2}, optional=True
|
||||
)
|
||||
config = _final_config(client_id)
|
||||
config["switch"] = [{"pin": switch_pin}]
|
||||
config["display"] = [{"enable_pin": [optional]}]
|
||||
|
||||
registry.remove_optional_conflicts(config)
|
||||
registry.final_validate(config)
|
||||
|
||||
assert not config.errors
|
||||
assert config["display"][0]["enable_pin"] == [optional]
|
||||
assert optional[CONF_ALLOW_OTHER_USES] is True
|
||||
assert registry.get_count("hub", client_id, 2) == 2
|
||||
|
||||
|
||||
def test_optional_wrapper_is_transparent_to_schema_extraction() -> None:
|
||||
"""The schema dumper sees the wrapped validator through the optional() wrapper."""
|
||||
validator = cv.ensure_list(pins.gpio_output_pin_schema)
|
||||
|
||||
assert pins.PIN_SCHEMA_REGISTRY.optional(validator)(SCHEMA_EXTRACT) is validator
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pin_path")
|
||||
def test_registry_keeps_optional_pin_missing_from_config(
|
||||
registry: pins.PinRegistry,
|
||||
) -> None:
|
||||
"""An optional pin that cannot be found in the config is kept, so the conflict is reported."""
|
||||
client_id = ID("h1", is_declaration=True)
|
||||
switch_pin = _validate_at(
|
||||
registry, ["switch", 0], {"hub": client_id, CONF_NUMBER: 2}
|
||||
)
|
||||
optional = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 2}, optional=True
|
||||
)
|
||||
config = _final_config(client_id)
|
||||
config["switch"] = [{"pin": switch_pin}]
|
||||
config["display"] = [{"enable_pin": [dict(optional)]}]
|
||||
|
||||
registry.remove_optional_conflicts(config)
|
||||
registry.final_validate(config)
|
||||
|
||||
assert registry.get_count("hub", client_id, 2) == 2
|
||||
assert config.errors
|
||||
assert all("used in multiple places" in str(err) for err in config.errors)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pin_path")
|
||||
def test_registry_final_validate_optional_pins_alone(
|
||||
registry: pins.PinRegistry,
|
||||
) -> None:
|
||||
"""Optional pins only give way to other uses: one is kept, and two still conflict."""
|
||||
client_id = ID("h1", is_declaration=True)
|
||||
single = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 6}, optional=True
|
||||
)
|
||||
first = _validate_at(
|
||||
registry, ["display", 0], {"hub": client_id, CONF_NUMBER: 2}, optional=True
|
||||
)
|
||||
second = _validate_at(
|
||||
registry, ["display", 1], {"hub": client_id, CONF_NUMBER: 2}, optional=True
|
||||
)
|
||||
config = _final_config(client_id)
|
||||
config["display"] = [{"enable_pin": [first, single]}, {"enable_pin": [second]}]
|
||||
|
||||
registry.remove_optional_conflicts(config)
|
||||
registry.final_validate(config)
|
||||
|
||||
assert config["display"][0]["enable_pin"] == [first, single]
|
||||
assert config["display"][1]["enable_pin"] == [second]
|
||||
assert config.errors
|
||||
assert all("used in multiple places" in str(err) for err in config.errors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "expected"),
|
||||
[
|
||||
@@ -357,3 +488,37 @@ def test_check_strapping_pin(caplog: pytest.LogCaptureFixture) -> None:
|
||||
pins.check_strapping_pin(
|
||||
{CONF_NUMBER: 3, CONF_IGNORE_STRAPPING_WARNING: True}, {2}, logger
|
||||
)
|
||||
|
||||
|
||||
def test_optional_pins_removed_between_id_pass_and_final_validation() -> None:
|
||||
"""Optional pins are removed once ids are resolved, and before components inspect pin use."""
|
||||
from esphome.config import (
|
||||
FinalValidateValidationStep,
|
||||
IDPassValidationStep,
|
||||
OptionalPinValidationStep,
|
||||
)
|
||||
|
||||
assert (
|
||||
IDPassValidationStep.priority
|
||||
> OptionalPinValidationStep.priority
|
||||
> FinalValidateValidationStep.priority
|
||||
)
|
||||
|
||||
|
||||
def test_optional_pin_step_skips_config_with_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Optional pins are only removed when the config has no errors so far."""
|
||||
from esphome.config import OptionalPinValidationStep
|
||||
|
||||
remove = MagicMock()
|
||||
monkeypatch.setattr(pins.PIN_SCHEMA_REGISTRY, "remove_optional_conflicts", remove)
|
||||
config = Config()
|
||||
|
||||
OptionalPinValidationStep().run(config)
|
||||
remove.assert_called_once_with(config)
|
||||
|
||||
remove.reset_mock()
|
||||
config.add_str_error("Invalid", ["display", 0])
|
||||
OptionalPinValidationStep().run(config)
|
||||
remove.assert_not_called()
|
||||
Reference in new issue
Block a user