mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 23:37:34 +00:00
+1
-1
@@ -1 +1 @@
|
||||
442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451
|
||||
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user