Merge pull request #16948 from esphome/bump-2026.6.0b2

2026.6.0b2
This commit is contained in:
Jesse Hills
2026-06-15 12:05:19 +12:00
committed by GitHub
35 changed files with 1451 additions and 220 deletions
+1 -1
View File
@@ -1 +1 @@
442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.6.0b1
PROJECT_NUMBER = 2026.6.0b2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+24
View File
@@ -1615,8 +1615,14 @@ FLASH_SIZES = [
]
CONF_FLASH_SIZE = "flash_size"
CONF_FLASH_MODE = "flash_mode"
CONF_FLASH_FREQUENCY = "flash_frequency"
CONF_CPU_FREQUENCY = "cpu_frequency"
CONF_PARTITIONS = "partitions"
FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"]
FLASH_FREQUENCIES = [
f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16)
]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -1630,6 +1636,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of(
*FLASH_SIZES, upper=True
),
cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True),
cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
*FLASH_FREQUENCIES, upper=True
),
cv.Optional(CONF_PARTITIONS): cv.Any(
cv.file_,
cv.ensure_list(
@@ -1866,6 +1876,12 @@ async def to_code(config):
"board_upload.maximum_size",
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
)
if flash_mode := config.get(CONF_FLASH_MODE):
cg.add_platformio_option("board_build.flash_mode", flash_mode)
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
cg.add_platformio_option(
"board_build.f_flash", f"{flash_frequency[:-3]}000000L"
)
if CONF_SOURCE in conf:
cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]])
@@ -2016,6 +2032,14 @@ async def to_code(config):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True
)
if flash_mode := config.get(CONF_FLASH_MODE):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True
)
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
)
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
@@ -1,3 +1,5 @@
import os
Import("env") # noqa: F821
# Remove custom_sdkconfig from the board config as it causes
@@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board:
del board._manifest["espidf"]["custom_sdkconfig"]
if not board._manifest["espidf"]:
del board._manifest["espidf"]
# Referenced by rules in esphome/idf_component.yml; an unset env var is a
# fatal error there. Always 0: in PlatformIO builds arduino is not a managed
# IDF component.
os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0")
+17 -1
View File
@@ -492,6 +492,15 @@ def _parse_register(config, regex, line):
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
# Structured crash handler output (crash_handler.cpp) from a previous boot:
# PC: 0x40220060
# EXCVADDR: 0x0000008A
# BT0: 0x40212345
STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile(
r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})"
)
STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})")
STACKTRACE_BAD_ALLOC_RE = re.compile(
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
)
@@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state):
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
)
# ESP8266 PC/EXCVADDR
# ESP8266 PC/EXCVADDR (legacy Arduino postmortem)
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
# ESP8266 structured crash handler (crash_handler.cpp) from previous boot
_parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line)
match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line)
if match is not None:
_decode_pc(config, match.group(1))
# bad alloc
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
if match is not None:
+100 -75
View File
@@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda
from esphome.cpp_generator import MockObj
from esphome.final_validate import full_config
from esphome.helpers import write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.writer import clean_build
from esphome.yaml_util import load_yaml
@@ -75,10 +76,14 @@ from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
SET_STATE_SCHEMA,
STATE_SCHEMA,
STYLE_REMAP,
STYLE_SCHEMA,
WIDGET_TYPES,
any_widget_schema,
container_schema,
container_schema_value,
obj_dict,
)
from .styles import styles_to_code, theme_to_code
@@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA
page_spec,
)
# These style schemas live in .schemas but are imported here so they land in
# this module's namespace, where script/build_language_schema.py registers them
# as *named* schemas and emits `extends` references — instead of inlining the
# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the
# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in
# this file; this tuple keeps the imports live (and self-documents why).
_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA)
# Widget registration happens via WidgetType.__init__ in individual widget files
# The imports below trigger creation of the widget types
# Action registration (lvgl.{widget}.update) happens automatically
@@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict:
FINAL_VALIDATE_SCHEMA = final_validation
LVGL_SCHEMA = cv.All(
container_schema(
obj_spec,
cv.polling_component_schema("1s")
.extend(
{
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
),
}
)
for event in df.LV_SCREEN_EVENT_TRIGGERS
+ df.LV_DISPLAY_EVENT_TRIGGERS
},
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
cv.Optional(
df.CONF_DEFAULT_FONT, default="montserrat_14"
): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
FULL_STYLE_SCHEMA
)
),
cv.Optional(CONF_ON_IDLE): validate_automation(
# The options accepted at the top level of an `lvgl:` block, on top of the base
# object schema that `container_schema(obj_spec, ...)` supplies. Held in a
# module-level name (rather than inline) so the schema-extractor wrapper on
# CONFIG_SCHEMA below can hand the language-schema dumper the same composed
# schema the runtime validates against.
LVGL_TOP_LEVEL_SCHEMA = (
cv.polling_component_schema("1s")
.extend(
{
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
cv.Required(CONF_TIMEOUT): cv.templatable(
cv.positive_time_period_milliseconds
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
},
single=True,
)
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
cv.Optional(
df.CONF_TRANSPARENCY_KEY, default=0x000400
): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA),
),
)
for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS
},
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
FULL_STYLE_SCHEMA
)
),
cv.Optional(CONF_ON_IDLE): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
cv.Required(CONF_TIMEOUT): cv.templatable(
cv.positive_time_period_milliseconds
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
},
single=True,
)
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA)
)
LVGL_SCHEMA = cv.All(
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
add_hello_world,
)
@schema_extractor("schema")
def lvgl_config_schema(config):
"""
Can't use cv.ensure_list here because it converts an empty config to an empty list,
rather than a default config.
"""
if config is SCHEMA_EXTRACT:
# CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema
# closure, so the language-schema dumper can't see the top-level `lvgl:`
# fields (it would emit an empty schema). Hand it the same composed
# obj + top-level schema the runtime validates against, plus the
# `widgets:` key (added per-value by append_layout_schema at runtime, so
# otherwise invisible to the dumper). Validation of real configs (the
# branches below) is unchanged.
return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend(
{cv.Optional(df.CONF_WIDGETS): any_widget_schema()}
)
if not config or isinstance(config, dict):
return [LVGL_SCHEMA(config)]
return cv.Schema([LVGL_SCHEMA])(config)
+41 -7
View File
@@ -22,7 +22,11 @@ from esphome.const import (
)
from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.schema_extractors import (
SCHEMA_EXTRACT,
EnableSchemaExtraction,
schema_extractor,
)
from . import defines as df, lv_validation as lvalid
from .defines import (
@@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[
] = {}
def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema:
"""
Build the static schema that :func:`container_schema` validates against, i.e.
everything except the value-dependent ``append_layout_schema`` applied at
validation time.
Factored out and exposed so the language-schema dumper can extract a
representative schema for a widget — and for the top-level ``lvgl:`` block,
whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the
:func:`container_schema` validator closure.
"""
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
return schema.extend(widget_type.schema)
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
@@ -649,12 +672,7 @@ def container_schema(
def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
cached_schema = container_schema_value(widget_type, extras)
return cached_schema
def validator(value: Any) -> Any:
@@ -678,7 +696,23 @@ def any_widget_schema(extras=None):
:return: A validator for the Widgets key
"""
@schema_extractor("schema")
def validator(value):
if value is SCHEMA_EXTRACT:
# The widgets: list is built per-value at validation time, so the
# language-schema dumper sees nothing. Enumerate every registered
# widget type as an optional key (a widget item is really a
# single-key mapping; over-listing them lets editors complete any
# widget — `esphome config` enforces exactly one). extras carries the
# layout child options where applicable.
return cv.ensure_list(
cv.Schema(
{
cv.Optional(name): container_schema_value(widget_type, extras)
for name, widget_type in WIDGET_TYPES.items()
}
)
)
if isinstance(value, dict):
# Convert to list
is_dict = True
+91 -40
View File
@@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips
# Special constant for delays in command sequences
DELAY_FLAG = 0xFFF # Special flag to indicate a delay
CONF_PAD_HEIGHT = "pad_height"
CONF_PAD_WIDTH = "pad_width"
CONF_PIXEL_MODE = "pixel_mode"
CONF_USE_AXIS_FLIPS = "use_axis_flips"
@@ -202,6 +204,8 @@ def dimension_schema(rounding):
rounding
),
cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding),
cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding),
cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding),
}
),
)
@@ -311,6 +315,36 @@ class DriverChip:
name = name.upper()
self.name = name
self.initsequence = initsequence
if CONF_NATIVE_WIDTH in defaults:
if CONF_WIDTH not in defaults:
defaults[CONF_WIDTH] = (
defaults[CONF_NATIVE_WIDTH]
- defaults.get(CONF_OFFSET_WIDTH, 0)
- defaults.get(CONF_PAD_WIDTH, 0)
)
else:
native_width = (
defaults.get(CONF_WIDTH, 0)
+ defaults.get(CONF_OFFSET_WIDTH, 0)
+ defaults.get(CONF_PAD_WIDTH, 0)
)
if native_width != 0:
defaults[CONF_NATIVE_WIDTH] = native_width
if CONF_NATIVE_HEIGHT in defaults:
if CONF_HEIGHT not in defaults:
defaults[CONF_HEIGHT] = (
defaults[CONF_NATIVE_HEIGHT]
- defaults.get(CONF_OFFSET_HEIGHT, 0)
- defaults.get(CONF_PAD_HEIGHT, 0)
)
else:
native_height = (
defaults.get(CONF_HEIGHT, 0)
+ defaults.get(CONF_OFFSET_HEIGHT, 0)
+ defaults.get(CONF_PAD_HEIGHT, 0)
)
if native_height != 0:
defaults[CONF_NATIVE_HEIGHT] = native_height
self.defaults = defaults
DriverChip.models[name] = self
@@ -336,18 +370,6 @@ class DriverChip:
initsequence = list(kwargs.pop("initsequence", self.initsequence))
initsequence.extend(kwargs.pop("add_init_sequence", ()))
defaults = self.defaults.copy()
if (
CONF_WIDTH in defaults
and CONF_OFFSET_WIDTH in kwargs
and CONF_NATIVE_WIDTH not in defaults
):
defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH]
if (
CONF_HEIGHT in defaults
and CONF_OFFSET_HEIGHT in kwargs
and CONF_NATIVE_HEIGHT not in defaults
):
defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT]
defaults.update(kwargs)
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
@@ -385,13 +407,16 @@ class DriverChip:
return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms
return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms
def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]:
def get_dimensions(
self, config, swap: bool = True
) -> tuple[int, int, int, int, int, int]:
"""
Return the dimensions of the current model.
:param config: The current configuration
:param swap: If width/height should be swapped when axes are swapped.
:return:
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
"""
if CONF_DIMENSIONS in config:
# Explicit dimensions, just use as is
dimensions = config[CONF_DIMENSIONS]
@@ -400,33 +425,71 @@ class DriverChip:
height = dimensions[CONF_HEIGHT]
offset_width = dimensions[CONF_OFFSET_WIDTH]
offset_height = dimensions[CONF_OFFSET_HEIGHT]
return width, height, offset_width, offset_height
(width, height) = dimensions
return width, height, 0, 0
if CONF_PAD_WIDTH in dimensions:
pad_width = dimensions[CONF_PAD_WIDTH]
native_width = width + offset_width + pad_width
else:
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
if native_width == 0:
pad_width = 0
native_width = width + offset_width
else:
pad_width = native_width - width - offset_width
if CONF_PAD_HEIGHT in dimensions:
pad_height = dimensions[CONF_PAD_HEIGHT]
native_height = height + offset_height + pad_height
else:
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
if native_height == 0:
pad_height = 0
native_height = height + offset_height
else:
pad_height = native_height - height - offset_height
if (
pad_width + offset_width >= native_width
or pad_height + offset_height >= native_height
):
raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS])
if pad_width < 0 or pad_height < 0:
raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS])
return width, height, offset_width, offset_height, pad_width, pad_height
# Must be a tuple
width, height = dimensions
return width, height, 0, 0, 0, 0
# Default dimensions, use model defaults
transform = self.get_transform(config)
width = self.get_default(CONF_WIDTH)
height = self.get_default(CONF_HEIGHT)
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
offset_width = self.get_default(CONF_OFFSET_WIDTH, 0)
offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0)
pad_width = self.get_default(
CONF_PAD_WIDTH, native_width - width - offset_width
)
pad_height = self.get_default(
CONF_PAD_HEIGHT, native_height - height - offset_height
)
if pad_width < 0 or pad_height < 0:
raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS])
# if mirroring axes and there are offsets, also mirror the offsets to cater for situations where
# the offset is asymmetric
if transform.get(CONF_MIRROR_X):
native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2)
offset_width = native_width - width - offset_width
offset_width, pad_width = pad_width, offset_width
if transform.get(CONF_MIRROR_Y):
native_height = self.get_default(
CONF_NATIVE_HEIGHT, height + offset_height * 2
)
offset_height = native_height - height - offset_height
# Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer
offset_height, pad_height = pad_height, offset_height
# Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer
if swap and transform.get(CONF_SWAP_XY) is True:
width, height = height, width
offset_height, offset_width = offset_width, offset_height
return width, height, offset_width, offset_height
pad_width, pad_height = pad_height, pad_width
return width, height, offset_width, offset_height, pad_width, pad_height
def get_base_transform(self, config):
transform = config.get(
@@ -450,20 +513,8 @@ class DriverChip:
def get_transform(self, config) -> dict[str, bool]:
transform = self.get_base_transform(config)
can_transform = self.rotation_as_transform(config)
# Can we use the MADCTL register to set the rotation?
if can_transform and CONF_TRANSFORM not in config:
rotation = config[CONF_ROTATION]
if rotation == 180:
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
elif rotation == 90:
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
else:
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
transform[CONF_TRANSFORM] = True
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
return transform
def swap_xy_schema(self):
@@ -498,8 +549,8 @@ class DriverChip:
return madctl
def add_madctl(self, sequence: list, config: dict):
# Add the MADCTL command to the sequence based on the configuration.
# This takes into account rotation if it can be implemented in the transform
# Add the MADCTL command to the sequence based on the base configuration.
# Rotation is not applied here, it will be done at runtime.
transform = self.get_transform(config)
madctl = self.get_madctl(transform, config)
sequence.append((MADCTL, madctl & 0xFF))
+6 -2
View File
@@ -172,7 +172,9 @@ def _config_schema(config):
)(config)
config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
display.add_metadata(
config[CONF_ID],
width,
@@ -206,7 +208,9 @@ async def to_code(config):
model = MODELS[config[CONF_MODEL].upper()]
color_depth = COLOR_DEPTHS[get_color_depth(config)]
pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit"))
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode)
sequence = model.get_sequence(config)
+6 -2
View File
@@ -235,7 +235,9 @@ def _config_schema(config):
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
display.add_metadata(
config[CONF_ID],
width,
@@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
var = cg.new_Pvariable(config[CONF_ID], width, height)
cg.add(var.set_model(model.name))
if enable_pin := config.get(CONF_ENABLE_PIN):
+27 -9
View File
@@ -27,7 +27,7 @@ from esphome.components.mipi import (
requires_buffer,
)
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
import esphome.config_validation as cv
from esphome.config_validation import ALLOW_EXTRA
from esphome.const import (
@@ -121,7 +121,9 @@ def denominator(config):
"""
model = MODELS[config[CONF_MODEL]]
frac = config.get(CONF_BUFFER_SIZE)
_width, height, _offset_width, _offset_height = model.get_dimensions(config)
_width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
if frac is None or frac > 0.75 or height < 32:
return 1
try:
@@ -169,11 +171,22 @@ def model_schema(config):
]
if bus_mode == TYPE_SINGLE:
other_options.append(CONF_SPI_16)
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
spi_mode = model.get_default(CONF_SPI_MODE)
if not spi_mode:
if bus_mode == TYPE_OCTAL or (
bus_mode == TYPE_SINGLE
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
):
spi_mode = "MODE3"
else:
spi_mode = "MODE0"
schema = (
display.FULL_DISPLAY_SCHEMA.extend(
spi.spi_device_schema(
cs_pin_required=False,
default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0",
default_mode=spi_mode,
default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000),
mode=bus_mode,
)
@@ -279,8 +292,8 @@ def customise_schema(config):
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, _offset_width, _offset_height = model.get_dimensions(
config, not has_hardware_transform
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
display.add_metadata(
config[CONF_ID],
@@ -313,14 +326,17 @@ def _final_validate(config):
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
# Always call this to check dimensions during validation
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config:
# If PSRAM is not enabled, choose a small buffer size by default
if not requires_buffer(config):
return # No need to pick a size
color_depth = get_color_depth(config)
frac = denominator(config)
width, height, _offset_width, _offset_height = model.get_dimensions(config)
buffer_size = color_depth // 8 * width * height // frac
# Target a buffer size of 20kB, except for large displays, which shouldn't end up here
fraction = min(20000.0, buffer_size // 4) / buffer_size
@@ -347,8 +363,8 @@ def get_instance(config):
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, offset_width, offset_height = model.get_dimensions(
config, not has_hardware_transform
width, height, offset_width, offset_height, pad_width, pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit"))
@@ -374,6 +390,8 @@ def get_instance(config):
height,
offset_width,
offset_height,
pad_width,
pad_height,
madctl,
has_hardware_transform,
]
+55 -32
View File
@@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
* @tparam HEIGHT Height of the display in pixels
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
* buffer
*/
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, bool HAS_HARDWARE_ROTATION>
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL,
bool HAS_HARDWARE_ROTATION>
class MipiSpi : public display::Display,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_1MHZ> {
@@ -126,17 +131,6 @@ class MipiSpi : public display::Display,
return HEIGHT;
}
// If hardware rotation is in use, the actual display width/height changes with rotation
int get_width_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_width();
return WIDTH;
}
int get_height_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_height();
return HEIGHT;
}
void set_init_sequence(const std::vector<uint8_t> &sequence) { this->init_sequence_ = sequence; }
// reset the display, and write the init sequence
@@ -233,14 +227,25 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
HAS_HARDWARE_ROTATION);
internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(),
this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8,
IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_,
this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION);
}
protected:
/* METHODS */
// If hardware rotation is in use, the actual display width/height changes with rotation
int get_width_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_width();
return WIDTH;
}
int get_height_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_height();
return HEIGHT;
}
// convenience functions to write commands with or without data
void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); }
void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); }
@@ -330,20 +335,34 @@ class MipiSpi : public display::Display,
this->write_command_(MADCTL_CMD, madctl);
}
uint16_t get_offset_width_() {
uint16_t get_offset_width_() const {
if constexpr (HAS_HARDWARE_ROTATION) {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return OFFSET_HEIGHT;
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
return OFFSET_HEIGHT;
case display::DISPLAY_ROTATION_180_DEGREES:
return PAD_WIDTH;
case display::DISPLAY_ROTATION_270_DEGREES:
return PAD_HEIGHT;
default:
break;
}
}
return OFFSET_WIDTH;
}
uint16_t get_offset_height_() {
uint16_t get_offset_height_() const {
if constexpr (HAS_HARDWARE_ROTATION) {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return OFFSET_WIDTH;
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
return PAD_WIDTH;
case display::DISPLAY_ROTATION_180_DEGREES:
return PAD_HEIGHT;
case display::DISPLAY_ROTATION_270_DEGREES:
return OFFSET_WIDTH;
default:
break;
}
}
return OFFSET_HEIGHT;
}
@@ -396,7 +415,7 @@ class MipiSpi : public display::Display,
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8);
}
} else {
for (size_t y = 0; y != static_cast<size_t>(h); y++) {
for (size_t y = 0; y != h; y++) {
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
this->write_array(ptr, w);
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
@@ -492,19 +511,23 @@ class MipiSpi : public display::Display,
* @tparam BUFFERPIXEL Color depth of the buffer
* @tparam DISPLAYPIXEL Color depth of the display
* @tparam BUS_TYPE The type of the interface bus (single, quad, octal)
* @tparam ROTATION The rotation of the display
* @tparam WIDTH Width of the display in pixels
* @tparam HEIGHT Height of the display in pixels
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
* @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer).
* @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even)
*/
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL,
bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT,
OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT,
uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
class MipiSpiBuffer
: public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH,
OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
public:
// these values define the buffer size needed to write in accordance with the chip pixel alignment
// requirements. If the required rounding does not divide the width and height, we round up to the next multiple and
@@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
void dump_config() override {
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
esph_log_config(TAG,
" Rotation: %d°\n"
" Buffer pixels: %d bits\n"
@@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
void setup() override {
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
MADCTL, HAS_HARDWARE_ROTATION>::setup();
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup();
RAMAllocator<BUFFERTYPE> allocator{};
this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION);
if (this->buffer_ == nullptr) {
+28
View File
@@ -179,6 +179,9 @@ ILI9342 = DriverChip(
# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation
ILI9341.extend(
"M5CORE2",
# Reset native dimensions due to axis swap.
native_width=320,
native_height=240,
width=320,
height=240,
mirror_x=False,
@@ -786,3 +789,28 @@ ST7796.extend(
dc_pin=0,
invert_colors=True,
)
ST7789V.extend(
"GEEKMAGIC-SMALLTV",
data_rate="40MHz",
height=240,
width=240,
offset_width=0,
offset_height=0,
invert_colors=True,
buffer_size=0.125,
reset_pin=2,
dc_pin=0,
)
ST7789V.extend(
"GEEKMAGIC-SMALLTV-PRO",
data_rate="40MHz",
height=240,
width=240,
offset_width=0,
offset_height=0,
invert_colors=True,
buffer_size=0.125,
reset_pin=4,
dc_pin=2,
)
@@ -269,3 +269,16 @@ ST7789V.extend(
cs_pin=14,
dc_pin={"number": 15, "ignore_strapping_warning": True},
)
ST7789V.extend(
"WAVESHARE-ESP32-S3-GEEK",
cs_pin=10,
dc_pin=8,
reset_pin=9,
width=135,
height=240,
offset_width=52,
offset_height=40,
invert_colors=True,
data_rate="40MHz",
)
+6 -4
View File
@@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate {
write_only_(write_only) {
if (!this->release_device_)
add_device_();
if (this->write_only_) {
ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
Utility::get_pin_no(this->cs_pin_));
}
}
bool is_ready() override { return this->handle_ != nullptr; }
@@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate {
config.post_cb = nullptr;
if (this->bit_order_ == BIT_ORDER_LSB_FIRST)
config.flags |= SPI_DEVICE_BIT_LSBFIRST;
if (this->write_only_) {
if (this->write_only_)
config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY;
ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
Utility::get_pin_no(this->cs_pin_));
}
esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Add device failed - err %X", err);
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.6.0b1"
__version__ = "2026.6.0b2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+7
View File
@@ -958,6 +958,13 @@ class EsphomeCore:
return build_flag
def add_build_unflag(self, build_unflag: str) -> None:
if self.using_toolchain_esp_idf:
# The native ESP-IDF build generator does not consume build_unflags
_LOGGER.warning(
"Build unflag %s is ignored when building with the native "
"ESP-IDF toolchain",
build_unflag,
)
self.build_unflags.add(build_unflag)
_LOGGER.debug("Adding build unflag: %s", build_unflag)
+52 -14
View File
@@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
include_file(path, basename, is_c_header)
def _add_library_str(lib: str) -> None:
if "@" in lib:
name, vers = lib.split("@", 1)
cg.add_library(name, vers)
elif "://" in lib:
# Repository...
if "=" in lib:
name, repo = lib.split("=", 1)
cg.add_library(name, None, repo)
else:
cg.add_library(None, None, lib)
else:
cg.add_library(lib, None)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_platformio_options(pio_options):
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
if CORE.using_toolchain_esp_idf:
# The native ESP-IDF build doesn't read platformio.ini; honor the
# options with a native equivalent and warn about the rest, which
# would otherwise be silently ignored.
for key, val in pio_options.items():
vals = [val] if isinstance(val, str) else val
if key == CONF_BUILD_FLAGS:
# Deprecated: esphome->build_flags is the native equivalent.
# Remove before 2026.12.0
_LOGGER.warning(
"esphome->platformio_options->build_flags is deprecated; use "
"esphome->build_flags instead. Support for it will be removed "
"in 2026.12.0."
)
for flag in vals:
cg.add_build_flag(flag)
elif key == "lib_deps":
# Routed through the regular library mechanism so the libraries
# are converted to IDF components like any other PIO library
for lib in vals:
_add_library_str(lib)
elif key == "lib_ignore":
# Read by the PIO-library-to-IDF-component conversion
# (generate_idf_components); filters both top-level libraries
# and dependencies discovered during conversion
cg.add_platformio_option(key, vals)
elif key != "upload_speed":
# upload_speed needs no handling: it is read from the raw
# config at upload time (upload_using_esptool)
_LOGGER.warning(
"esphome->platformio_options->%s is ignored when building with "
"the native ESP-IDF toolchain",
key,
)
return
# Add includes at the very end, so that they override everything
for key, val in pio_options.items():
if key in ["build_flags", "lib_ignore"] and not isinstance(val, list):
@@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None:
# Libraries
for lib in config[CONF_LIBRARIES]:
if "@" in lib:
name, vers = lib.split("@", 1)
cg.add_library(name, vers)
elif "://" in lib:
# Repository...
if "=" in lib:
name, repo = lib.split("=", 1)
cg.add_library(name, None, repo)
else:
cg.add_library(None, None, lib)
else:
cg.add_library(lib, None)
_add_library_str(lib)
cg.add_build_flag("-Wno-unused-variable")
cg.add_build_flag("-Wno-unused-but-set-variable")
+1 -1
View File
@@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None:
# Gates arduino-only components in esphome/idf_component.yml (IDF reads it at
# reconfigure time). Set here -- before the manifest is written/reconfigured.
os.environ["ESPHOME_ARDUINO"] = (
os.environ["ESPHOME_ARDUINO_COMPONENT"] = (
"1" if settings.target_framework == "arduino" else "0"
)
+43 -12
View File
@@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
class Source:
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
raise NotImplementedError
@@ -64,10 +64,12 @@ class URLSource(Source):
def __init__(self, url: str):
self.url = url
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
base_dir = Path(CORE.data_dir) / DOMAIN
h = hashlib.new("sha256")
h.update(self.url.encode())
if salt:
h.update(salt.encode())
path = base_dir / h.hexdigest()[:8] / dir_suffix
# Marker file written last to signal a complete extraction. Using a
# marker (instead of just `path.is_dir()`) means an interrupted
@@ -99,12 +101,12 @@ class GitSource(Source):
self.url = url
self.ref = ref
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
path, _ = git.clone_or_update(
url=self.url,
ref=self.ref,
refresh=git.NEVER_REFRESH if not force else None,
domain=DOMAIN,
domain=f"{DOMAIN}/{salt}" if salt else DOMAIN,
submodules=[],
subpath=Path(dir_suffix),
)
@@ -146,14 +148,16 @@ class IDFComponent:
def get_require_name(self):
return self.get_sanitized_name().replace("/", "__")
def download(self, force: bool = False):
def download(self, force: bool = False, salt: str = ""):
"""
The dependency name should match the directory name at the end of the override path.
The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name.
If you want to specify the full name of the component with the namespace, replace / in the component name with __.
@see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html
"""
self.path = self.source.download(self.get_sanitized_name(), force=force)
self.path = self.source.download(
self.get_sanitized_name(), force=force, salt=salt
)
def _apply_extra_script(component: IDFComponent) -> None:
@@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
The returned list holds the top-level components (those directly requested);
transitive dependencies are converted too and wired into each component's
generated manifest.
``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by
short name (part after the ``/``), matched against both the top-level
libraries and every dependency discovered during the graph walk.
"""
nodes: dict[str, _LibNode] = {}
lib_ignore = {
name.split("/")[-1].lower()
for name in CORE.platformio_options.get("lib_ignore", [])
}
# The generated CMakeLists.txt/idf_component.yml inside the shared cache
# bake in the dependency wiring, which lib_ignore changes; salt the cache
# path so configs with different lib_ignore values don't fight over (and
# constantly rewrite) the same converted component files.
salt = (
hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8]
if lib_ignore
else ""
)
def is_ignored(name: str | None) -> bool:
if not lib_ignore or name is None:
return False
return name.split("/")[-1].lower() in lib_ignore
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
key, is_git, locator = _node_key(name, version, repository)
node = nodes.get(key) or _LibNode(key=key, is_git=is_git)
@@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
top_level = [
add_spec(library.name, library.version, library.repository)
for library in libraries
if not is_ignored(library.name)
]
# Collect + resolve to a fixpoint: a node is (re)resolved whenever its
@@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
component = IDFComponent(
_owner_pkgname_to_name(owner, name), version, URLSource(url)
)
component.download()
component.download(salt=salt)
library_json_path = component.path / "library.json"
library_properties_path = component.path / "library.properties"
@@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
except InvalidIDFComponent as e:
_LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e))
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_ignored(dep_name):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = None
@@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
dep_url, dep_version = dep_version, None
except (TypeError, ValueError):
pass
dep_key = add_spec(
_owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")),
dep_version,
dep_url,
)
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)
+1 -1
View File
@@ -109,4 +109,4 @@ dependencies:
git: https://github.com/FastLED/FastLED.git
version: d44c800a9e876a8394caefc2ce4915dd96dac77b
rules:
- if: "$ESPHOME_ARDUINO == 1"
- if: "$ESPHOME_ARDUINO_COMPONENT == 1"
+13 -5
View File
@@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
; This are common settings for the ESP32 (all variants) using Arduino.
[common:esp32-arduino]
extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32.git
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps =
@@ -168,12 +171,16 @@ build_flags =
-DAUDIO_NO_SD_FS ; i2s_audio
build_unflags =
${common.build_unflags}
extra_scripts = post:esphome/components/esp32/post_build.py.script
extra_scripts =
pre:esphome/components/esp32/pre_build.py.script
post:esphome/components/esp32/post_build.py.script
; This are common settings for the ESP32 (all variants) using IDF.
[common:esp32-idf]
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32.git
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = espidf
lib_deps =
@@ -187,7 +194,9 @@ build_flags =
-DUSE_ESP32_FRAMEWORK_ESP_IDF
build_unflags =
${common.build_unflags}
extra_scripts = post:esphome/components/esp32/post_build.py.script
extra_scripts =
pre:esphome/components/esp32/pre_build.py.script
post:esphome/components/esp32/post_build.py.script
; These are common settings for the RP2040 using Arduino.
[common:rp2040-arduino]
@@ -271,7 +280,6 @@ build_unflags =
[env:esp32-arduino]
extends = common:esp32-arduino
board = esp32dev
board_build.partitions = huge_app.csv
build_flags =
${common:esp32-arduino.build_flags}
${flags:runtime.build_flags}
+28
View File
@@ -428,6 +428,33 @@ def fix_menu():
menu[S_EXTENDS].append("display_menu_base.MENU_TYPES")
def fix_lvgl_widgets():
# lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The
# dumper has no cycle detection, so — like fix_menu — hoist the inlined
# widget-type enumeration into a named schema and reference it for both the
# top-level list and each widget's own children, instead of expanding it.
if "lvgl" not in output:
return
schemas = output["lvgl"][S_SCHEMAS]
config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS]
widgets = config_vars.get("widgets")
if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]:
return
# 1. Hoist the (one-level) widget enumeration into a named schema.
schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]}
# 2. Reference it from the top-level widgets: list instead of inlining.
widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}
# 3. Let every widget contain child widgets, via the same named ref.
for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values():
if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget:
widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = {
S_TYPE: S_SCHEMA,
"is_list": True,
"key": "Optional",
S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]},
}
def get_logger_tags():
pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE)
# tags not in components dir
@@ -740,6 +767,7 @@ def build_schema():
add_logger_tags()
shrink()
fix_menu()
fix_lvgl_widgets()
# aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc.
data = {}
@@ -0,0 +1,7 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
flash_mode: qio
flash_frequency: 80MHz
framework:
type: esp-idf
+26
View File
@@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
def test_flash_mode_sets_sdkconfig_and_pio_option(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""flash_mode/flash_frequency select the esptool flash parameters on both backends."""
generate_main(component_config_path("flash_mode_idf.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
def test_flash_mode_unset_leaves_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without flash_mode the board/sdkconfig defaults stay untouched."""
generate_main(component_config_path("flash_mode_default.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig)
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
assert "board_build.flash_mode" not in CORE.platformio_options
assert "board_build.f_flash" not in CORE.platformio_options
+2 -2
View File
@@ -314,7 +314,7 @@ def test_native_generation(
main_cpp = generate_main(component_fixture_path("native.yaml"))
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, true, 1, 1>()"
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
)
assert "set_init_sequence({240, 1, 8, 242" in main_cpp
@@ -330,7 +330,7 @@ def test_lvgl_generation(
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
assert (
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, true>();"
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
@@ -0,0 +1,434 @@
"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
MODELS,
get_instance,
)
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config: ConfigType) -> ConfigType:
"""Run schema + final validation and return the validated config."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
class TestSPIModeCalculation:
"""Test default SPI mode calculation logic."""
@pytest.mark.parametrize(
("bus_mode", "cs_pin", "expected_mode"),
[
pytest.param(
TYPE_OCTAL,
None,
"MODE3",
id="octal_bus_no_cs",
),
pytest.param(
TYPE_OCTAL,
14,
"MODE3",
id="octal_bus_with_cs",
),
pytest.param(
TYPE_SINGLE,
None,
"MODE3",
id="single_bus_no_cs",
),
pytest.param(
TYPE_SINGLE,
14,
"MODE0",
id="single_bus_with_cs",
),
pytest.param(
TYPE_QUAD,
None,
"MODE0",
id="quad_bus_no_cs",
),
pytest.param(
TYPE_QUAD,
14,
"MODE0",
id="quad_bus_with_cs",
),
],
)
def test_default_spi_mode_calculation(
self,
bus_mode: str,
cs_pin: int | None,
expected_mode: str,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that SPI mode is correctly calculated based on bus mode and CS pin."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
config: ConfigType = {
"model": "custom",
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": bus_mode,
}
# Add dc_pin for modes that require it (single and octal)
# quad mode does not allow dc_pin
if bus_mode != TYPE_QUAD:
config[CONF_DC_PIN] = 11
# Add CS pin if specified
if cs_pin is not None:
config[CONF_CS_PIN] = cs_pin
validated = validated_config(config)
# The validated config should have the correct SPI mode set by model_schema
assert validated.get(CONF_SPI_MODE) == expected_mode
def test_explicit_spi_mode_overrides_default(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that an explicitly configured SPI mode is not overridden."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# For octal bus, default is MODE3, but we specify MODE0
config = validated_config(
{
"model": "custom",
"dc_pin": 11, # Required for octal mode
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": TYPE_OCTAL,
"spi_mode": "MODE0", # Explicitly set
}
)
assert config[CONF_SPI_MODE] == "MODE0"
class TestModelWithPaddingDimensions:
"""Test that padding dimensions are correctly returned by models."""
def test_model_get_dimensions_returns_six_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that get_dimensions() returns 6 values including padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# Test with a real model
model = MODELS["ST7735"]
config = {"model": "ST7735", "dc_pin": 18}
# Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height)
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6
assert all(isinstance(v, int) for v in dimensions)
def test_custom_model_padding_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding values for a custom model with explicit offset."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 20,
"offset_height": 10,
},
"init_sequence": [[0xA0, 0x01]],
}
)
# For custom models, the model is created dynamically from the config
# We can verify the config has the right dimensions
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 320
assert config["dimensions"]["offset_width"] == 20
assert config["dimensions"]["offset_height"] == 10
# Padding is not stored in config for custom models (defaults to 0)
assert config["dimensions"].get("offset_width_pad", 0) == 0
assert config["dimensions"].get("offset_height_pad", 0) == 0
class TestNewModelVariants:
"""Test new model variants added in this change."""
def test_m5core2_with_native_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test M5CORE2 variant with reset native_width and native_height."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# M5CORE2 should validate successfully
config = validated_config({"model": "M5CORE2"})
assert config is not None
# Verify the model has correct dimensions
model = MODELS["M5CORE2"]
dimensions = model.get_dimensions(config)
width, height, _, _, _, _ = dimensions
assert width == 320
assert height == 240
def test_geekmagic_smalltv_variant(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test GEEKMAGIC-SMALLTV variant of ST7789V."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# GEEKMAGIC-SMALLTV should validate successfully
config = validated_config({"model": "GEEKMAGIC-SMALLTV"})
assert config is not None
# Verify it's a variant of ST7789V with expected dimensions
model = MODELS["GEEKMAGIC-SMALLTV"]
dimensions = model.get_dimensions(config)
width, height, offset_x, offset_y, _, _ = dimensions
assert width == 240
assert height == 240
assert offset_x == 0
assert offset_y == 0
def test_all_predefined_models_with_new_get_dimensions_signature(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Verify all predefined models work with new 6-value get_dimensions()."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
for name, model in MODELS.items():
# Skip custom model
if name == "custom":
continue
config = {"model": name}
# Try to get dimensions - should return 6 values for all models
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6, (
f"Model {name} should return 6 dimensions, got {len(dimensions)}"
)
class TestTemplateParameterPassing:
"""Test that padding parameters are correctly passed to C++ templates."""
def test_instance_creation_with_padding(
self,
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""Test that get_instance() correctly passes padding parameters to template."""
main_cpp = generate_main(component_fixture_path("native.yaml"))
# native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer
# (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE,
# WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION,
# FRACTION, ROUNDING)
# The instantiation should include padding values (0, 0 for default)
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
), (
"Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation"
)
def test_single_mode_with_offset_padding(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that single-mode display with custom offset works with padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Should not raise any errors
instance = get_instance(config)
assert instance is not None
class TestUserConfiguredPadding:
"""Test that pad_width and pad_height can be configured in user dimensions."""
def test_explicit_pad_width_and_height_in_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that pad_width and pad_height can be explicitly set in dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
"pad_width": 80,
"pad_height": 40,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Config should validate successfully with padding dimensions
assert config is not None
assert config["dimensions"]["pad_width"] == 80
assert config["dimensions"]["pad_height"] == 40
def test_padding_for_native_dimension_calculation(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that explicit padding allows native dimensions to be calculated."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A controller that has 320x320 total pixels with:
# - 240x320 active display area
# - offset_width=40, offset_height=20
# - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240, # Active display width
"height": 320, # Active display height
"offset_width": 40,
"offset_height": 0,
"pad_width": 40, # Pixels after width+offset
"pad_height": 0, # Pixels after height+offset
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Get instance should work and correctly calculate native dimensions
instance = get_instance(config)
assert instance is not None
def test_padding_without_offset(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding can be used without offset for controllers with top-left-aligned displays."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A display with no offset but padding on right and bottom
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 240,
"offset_width": 0,
"offset_height": 0,
"pad_width": 0,
"pad_height": 16,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
assert config is not None
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 240
assert config["dimensions"]["pad_height"] == 16
+107
View File
@@ -4,7 +4,12 @@ from __future__ import annotations
import ast
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import pytest
from esphome import config_validation as cv
@@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
entry = converted["schema"]["config_vars"]["hostname"]
assert "sensitive" not in entry
assert "sensitive_source" not in entry
# ---------------------------------------------------------------------------
# Regression tests for the lvgl schema dump.
#
# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are
# built lazily at validation time, so the static dumper used to emit an empty
# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA
# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise
# the full `build_schema()` and assert the generated lvgl.json carries the data
# the schema_extractor hooks added.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict:
"""Run the full language-schema build once and return parsed lvgl.json.
The build must run in a fresh interpreter: ``build_language_schema.py``
enables schema extraction *before* importing any esphome component, and the
extraction hooks are no-ops if the components were already imported (as they
are inside the pytest session). Running it as a subprocess mirrors how CI
generates the schema and keeps this test isolated from import order.
"""
out_dir = tmp_path_factory.mktemp("language_schema")
subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)],
check=True,
capture_output=True,
text=True,
)
return json.loads((out_dir / "lvgl.json").read_text())
def _lvgl_config_vars(lvgl_schema: dict) -> dict:
config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"]
# Previously empty (`{}`); the schema_extractor on lvgl_config_schema now
# hands the dumper the composed top-level schema.
assert config_schema["type"] == "schema"
return config_schema["schema"]["config_vars"]
def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed.
assert len(config_vars) > 100
# A representative spread of top-level options the runtime validates.
for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"):
assert key in config_vars, f"missing top-level lvgl option: {key}"
def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# The widgets: list is assembled per-value at runtime; the extractor
# enumerates every registered widget type into a named WIDGET_TYPES schema
# which the widgets: list references (recursive, so widgets can nest).
assert "widgets" in config_vars
widgets = config_vars["widgets"]
assert widgets["is_list"] is True
assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][
"config_vars"
]
# Every registered widget type should appear as an optional key.
for name in ("obj", "label", "button", "slider", "switch", "arc"):
assert name in widget_types, f"widget type not enumerated: {name}"
# Each enumerated widget carries its own property schema, not an empty stub.
assert widget_types["label"]["type"] == "schema"
assert len(widget_types["label"]["schema"]["config_vars"]) > 0
# Each widget can contain child widgets, via the same named ref — so the
# tree is recursive and the dump stays finite.
nested = widget_types["obj"]["schema"]["config_vars"]["widgets"]
assert nested["is_list"] is True
assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None:
schemas = lvgl_schema["lvgl"]["schemas"]
# Importing these into the lvgl __init__ namespace lets the dumper register
# them as named schemas and emit `extends` refs instead of inlining them.
for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"):
assert name in schemas, f"style schema not registered as named: {name}"
# STYLE_SCHEMA must be referenced via `extends`, not inlined at every use
# site. Count the references to prove the dedup actually happened.
refs = 0
def _count(node: object) -> None:
nonlocal refs
if isinstance(node, dict):
extends = node.get("extends")
if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends:
refs += 1
for value in node.values():
_count(value)
elif isinstance(node, list):
for value in node:
_count(value)
_count(lvgl_schema)
assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}"
@@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace(
assert state is False
def test_process_stacktrace_esp8266_crash_handler(
setup_core: Path, mock_esp8266_decode_pc: Mock
) -> None:
"""Test process_stacktrace handles ESP8266 crash handler backtrace lines."""
from esphome.components.esp8266 import process_stacktrace
config = {"name": "test"}
# Simulate crash handler log lines as they appear from the API/serial
line_pc = "[E][esp8266:191]: PC: 0x40220060"
state = process_stacktrace(config, line_pc, False)
mock_esp8266_decode_pc.assert_called_once_with(config, "40220060")
assert state is False
mock_esp8266_decode_pc.reset_mock()
# Near-null data address (wild pointer) is not a code address, must be ignored
line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A"
state = process_stacktrace(config, line_excvaddr, False)
mock_esp8266_decode_pc.assert_not_called()
assert state is False
mock_esp8266_decode_pc.reset_mock()
line_bt0 = "[E][esp8266:196]: BT0: 0x40212345"
state = process_stacktrace(config, line_bt0, False)
mock_esp8266_decode_pc.assert_called_once_with(config, "40212345")
assert state is False
def test_process_stacktrace_esp32_backtrace(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
+123
View File
@@ -20,6 +20,9 @@ from esphome.const import (
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, config
from esphome.core.config import (
@@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None:
cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False)
# cpp_string_escape uses octal escapes for quotes
assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes
@pytest.mark.parametrize(
("lib", "name", "version", "repository"),
[
("ArduinoJson", "ArduinoJson", None, None),
("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None),
(
"noise-c=https://github.com/esphome/noise-c.git",
"noise-c",
None,
"https://github.com/esphome/noise-c.git",
),
],
)
def test_add_library_str(
lib: str, name: str, version: str | None, repository: str | None
) -> None:
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
config._add_library_str(lib)
libraries = list(CORE.platformio_libraries.values())
assert len(libraries) == 1
assert libraries[0].name == name
assert libraries[0].version == version
assert libraries[0].repository == repository
@pytest.mark.asyncio
async def test_add_platformio_options_native_idf(
caplog: pytest.LogCaptureFixture,
) -> None:
"""On the native IDF toolchain, build_flags/lib_deps/lib_ignore are
honored, upload_speed is silent and everything else warns."""
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
"lib_ignore": "libsodium",
"upload_speed": "115200",
"board_build.f_flash": "80000000L",
}
)
assert "-DSINGLE_FLAG" in CORE.build_flags
assert "ArduinoJson" in CORE.platformio_libraries
# lib_ignore is stored (listified) for generate_idf_components to read;
# nothing else lands in platformio_options on the native toolchain.
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text
assert "upload_speed" not in caplog.text
# build_flags has a first-class esphome equivalent, so it is deprecated.
# lib_deps/lib_ignore are kept as valid platformio_options (no warning).
assert (
"esphome->platformio_options->build_flags is deprecated; use "
"esphome->build_flags instead" in caplog.text
)
assert "lib_deps is deprecated" not in caplog.text
assert "lib_ignore is deprecated" not in caplog.text
@pytest.mark.asyncio
async def test_add_platformio_options_platformio(
caplog: pytest.LogCaptureFixture,
) -> None:
"""On the PlatformIO toolchain all options pass through to the ini,
with build_flags/lib_ignore listified."""
CORE.toolchain = Toolchain.PLATFORMIO
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG",
"lib_ignore": "libsodium",
"upload_speed": "115200",
}
)
assert CORE.platformio_options == {
"build_flags": ["-DSINGLE_FLAG"],
"lib_ignore": ["libsodium"],
"upload_speed": "115200",
}
# platformio_options is the correct mechanism on the PlatformIO toolchain,
# so the native-equivalent deprecation must not fire here.
assert "deprecated" not in caplog.text
def test_add_library_str_bare_url_requires_name() -> None:
"""A bare repository URL has no library name; CORE.add_library rejects it."""
with pytest.raises(ValueError, match="must have a name"):
config._add_library_str("https://github.com/esphome/noise-c.git")
@pytest.mark.asyncio
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None:
"""esphome->libraries entries are parsed and registered via cg.add_library."""
result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR)
assert result is not None
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
mock_cg.add_library.assert_any_call("SomeLib", None)
mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2")
mock_cg.add_library.assert_any_call(
"noise-c", None, "https://github.com/esphome/noise-c.git"
)
@@ -0,0 +1,8 @@
esphome:
name: test-libraries
libraries:
- SomeLib
- bblanchon/ArduinoJson@7.4.2
- noise-c=https://github.com/esphome/noise-c.git
host:
+18
View File
@@ -915,3 +915,21 @@ class TestEsphomeCore:
mock_enable.assert_called_once_with("Wire")
assert "Wire" in target.platformio_libraries
def test_add_build_unflag__warns_on_native_idf_toolchain(
self, target, caplog: pytest.LogCaptureFixture
) -> None:
"""Build unflags are not consumed by the native IDF build generator,
so adding one on that toolchain warns; PlatformIO stays silent."""
target.toolchain = const.Toolchain.PLATFORMIO
target.add_build_unflag("-fno-rtti")
assert "ignored" not in caplog.text
target.toolchain = const.Toolchain.ESP_IDF
target.add_build_unflag("-fno-exceptions")
assert (
"Build unflag -fno-exceptions is ignored when building with the "
"native ESP-IDF toolchain" in caplog.text
)
# The unflag is still recorded either way.
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
+3 -3
View File
@@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env(
target_framework: str,
expected: str,
) -> None:
"""_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps."""
"""_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps."""
# monkeypatch snapshots os.environ, so the env var _setup_core writes is
# restored after the test instead of leaking into later tests.
monkeypatch.delenv("ESPHOME_ARDUINO", raising=False)
monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False)
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
assert os.environ["ESPHOME_ARDUINO"] == expected
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
+115 -7
View File
@@ -1,3 +1,4 @@
import hashlib
import json
import os
from pathlib import Path
@@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
"esphome/C": {"name": "C"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency(
assert "idf_component_register" in generated
def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# lib_ignore must drop B at the top level and C when it is discovered as a
# dependency of A during the graph walk -- neither may be resolved,
# downloaded, or wired into a manifest. Matching is by lowercase short name.
manifests = {
"esphome/A": {
"name": "A",
"dependencies": [
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
],
},
"esphome/B": {"name": "B"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
monkeypatch.setattr(
esphome.espidf.component, "_resolve_registry_version", fake_resolve
)
# lib_ignore is read from CORE.platformio_options (stored there by
# _add_platformio_options); matched by lowercase short name.
monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]})
top = generate_idf_components(
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)]
)
assert [c.name for c in top] == ["esphome/A"]
# Ignored libraries were never resolved (and therefore never downloaded).
assert resolve_calls == ["A"]
# The ignored dependency is not wired into A's manifest.
assert top[0].dependencies == []
# lib_ignore changes the generated wiring, so the cache path is salted to
# keep this conversion separate from ones with a different lib_ignore.
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_handles_dependency_cycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle(
},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns(
"esphome/shared": {"name": "shared"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises(
) -> None:
# A library with neither library.json nor library.properties is invalid;
# fail loudly rather than silently generating build files for it.
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
# no library.json / library.properties written
@@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
"owner/shared": {"name": "shared"},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
@@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises(
) -> None:
# A top-level library that isn't ESP-IDF/esp32 compatible must fail fast,
# not be silently dropped.
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(
@@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped(
"esphome/B": {"name": "B", "platforms": ["espressif8266"]},
}
def fake_download(self, force=False):
def fake_download(self, force=False, salt=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
@@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped(
assert [c.name for c in top] == ["esphome/A"]
# The incompatible dependency was dropped, not wired in.
assert top[0].dependencies == []
def test_url_source_salt_changes_cache_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The salt is mixed into the URL hash so salted conversions get their own
cache tree. Pre-created extraction markers keep this network-free."""
monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml")
url = "http://example.com/lib.tar.gz"
base = tmp_path / ".esphome" / "pio_components"
expected = {}
for salt in ("", "abcd1234"):
digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8]
expected[salt] = base / digest / "lib"
expected[salt].mkdir(parents=True)
(expected[salt] / ".esphome_extracted").touch()
source = URLSource(url)
assert source.download("lib") == expected[""]
assert source.download("lib", salt="abcd1234") == expected["abcd1234"]
def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
"""The salt becomes a subdirectory of the git clone domain."""
domains: list[str] = []
def fake_clone_or_update(**kwargs):
domains.append(kwargs["domain"])
return Path("/cloned"), None
monkeypatch.setattr(
esphome.espidf.component.git, "clone_or_update", fake_clone_or_update
)
source = GitSource("https://github.com/esphome/noise-c.git", "v1.0")
source.download("noise-c")
source.download("noise-c", salt="abcd1234")
assert domains == ["pio_components", "pio_components/abcd1234"]
def test_idf_component_download_passes_salt() -> None:
"""IDFComponent.download forwards the sanitized name and salt to the
source and records the returned path."""
source = MagicMock()
source.download.return_value = Path("/converted/owner/name")
c = IDFComponent("owner/name", "1.0", source=source)
c.download(force=True, salt="abcd1234")
source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234")
assert c.path == Path("/converted/owner/name")