Compare commits

..
44 changed files with 1078 additions and 184 deletions

No files matched your search

+16 -5
View File
@@ -13,10 +13,19 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -31,4 +40,6 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
@@ -0,0 +1,16 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,10 +5,19 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -19,4 +28,6 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
@@ -0,0 +1,16 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
+30 -1
View File
@@ -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("°")
+1 -1
View File
@@ -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)
+1 -3
View File
@@ -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)),
+33 -8
View File
@@ -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
)
+56 -20
View File
@@ -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:
+8 -11
View File
@@ -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,),
+1 -1
View File
@@ -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),
+3 -5
View File
@@ -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)
)
+2 -2
View File
@@ -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},
+1 -3
View File
@@ -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),
-5
View File
@@ -124,7 +124,6 @@ from esphome.core import (
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
@@ -1110,10 +1109,6 @@ def sub_sensors(
return SubEntities(new_sensor, config, parent)
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
{
cv.Required(CONF_ID): cv.use_id(Sensor),
@@ -22,7 +22,6 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -239,12 +238,6 @@ def sub_text_sensors(
return SubEntities(new_text_sensor, config, parent)
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
follow_text_sensor = FollowEntity(
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(text_sensor_ns.using)
+13
View File
@@ -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())
-18
View File
@@ -45,7 +45,6 @@ from esphome.core.config import (
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
RawExpression,
@@ -520,23 +519,6 @@ class ApplyEntity:
_APPLY_DOMAIN = "entity_apply"
@dataclass(frozen=True, slots=True)
class FollowEntity:
"""A state callback, its parameters and the value an entity republishes from it."""
callback: str
params: tuple[tuple[SafeExpType, str], ...]
value: str
def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None:
"""Make ``entity`` publish every state of ``source``; ``args`` precede the callback."""
publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};"
callback = LambdaExpression(
[publish], list(self.params), capture="", return_type=cg.void
)
add(getattr(source, self.callback)(*args, callback))
@dataclass(frozen=True, slots=True)
class SubEntities:
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
+108 -10
View File
@@ -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
+17
View File
@@ -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
+81 -1
View File
@@ -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"}
+15 -1
View File
@@ -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
@@ -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 -48
View File
@@ -11,7 +11,7 @@ import pytest
from esphome import final_validate as fv
import esphome.codegen as cg
from esphome.components import button, number, select, sensor, switch, text_sensor
from esphome.components import button, number, select, switch
from esphome.config import Config
import esphome.config_validation as cv
from esphome.config_validation import Invalid
@@ -1883,53 +1883,6 @@ async def test_sub_entities_create_from_whole_config(
assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]
@pytest.mark.parametrize(
("follow", "expected"),
[
(
sensor.follow_sensor,
[
(
"src->add_on_state_callback([](float x) -> void {\n"
" ::dst->publish_state(x);\n})"
)
],
),
(
text_sensor.follow_text_sensor,
[
(
"src->add_on_state_callback([](const std::string & x) -> void {\n"
" ::dst->publish_state(x);\n})"
)
],
),
],
)
def test_follow_entity(
setup_test_environment: list[str],
follow: Callable[[MockObj, MockObj], None],
expected: list[str],
) -> None:
"""The entity follows its source through a lambda that captures nothing."""
follow(MockObj("dst", "->"), MockObj("src", "->"))
assert setup_test_environment == expected
def test_follow_entity_hub_callback(setup_test_environment: list[str]) -> None:
"""A hub callback takes leading arguments and the published value is an expression."""
follow = entity_helpers.FollowEntity(
"register_listener", ((cg.uint8, "x"),), "x == 3"
)
follow(MockObj("dst", "->"), MockObj("hub", "->"), 7)
assert setup_test_environment == [
(
"hub->register_listener(7, [](uint8_t x) -> void {\n"
" ::dst->publish_state(x == 3);\n})"
)
]
def test_entity_duplicate_validator_without_name() -> None:
"""A config with no name is returned untouched and not tracked."""
validator = entity_duplicate_validator("sensor")
+165
View File
@@ -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()