From 8269e2c961456a66b97a245eef4a10cdc7b11425 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Apr 2025 12:27:23 -1000 Subject: [PATCH 01/13] Ensure plaintext responds with bad indicator byte before dropping the connection (#8521) --- esphome/components/api/api_frame_helper.cpp | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 62f375508c..9e1b1521dd 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -893,8 +893,28 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { ParsedFrame frame; aerr = try_read_frame_(&frame); - if (aerr != APIError::OK) + if (aerr != APIError::OK) { + if (aerr == APIError::BAD_INDICATOR) { + // Make sure to tell the remote that we don't + // understand the indicator byte so it knows + // we do not support it. + struct iovec iov[1]; + // The \x00 first byte is the marker for plaintext. + // + // The remote will know how to handle the indicator byte, + // but it likely won't understand the rest of the message. + // + // We must send at least 3 bytes to be read, so we add + // a message after the indicator byte to ensures its long + // enough and can aid in debugging. + const char msg[] = "\x00" + "Bad indicator byte"; + iov[0].iov_base = (void *) msg; + iov[0].iov_len = 19; + write_raw_(iov, 1); + } return aerr; + } buffer->container = std::move(frame.msg); buffer->data_offset = 0; From 2291a1dc39f989f26d443d5047057ecc2f6094e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Apr 2025 12:58:26 -1000 Subject: [PATCH 02/13] Bump aioesphomeapi to 29.9.0 (#8522) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ef1542ffe9..0ee928569b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.8.1 click==8.1.7 esphome-dashboard==20250212.0 -aioesphomeapi==29.7.0 +aioesphomeapi==29.9.0 zeroconf==0.146.3 puremagic==1.28 ruamel.yaml==0.18.10 # dashboard_import From 1c72fd4674f769af5f7d23ebc78035dab7416732 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:00:39 +1000 Subject: [PATCH 03/13] [lvgl] add on_boot trigger (#8498) --- esphome/components/lvgl/__init__.py | 4 +++- esphome/components/lvgl/schemas.py | 28 ++++++++++++++++++------- esphome/components/lvgl/trigger.py | 11 +++++++++- tests/components/lvgl/lvgl-package.yaml | 6 ++++++ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 22571c2550..a3e4ee83fa 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_GROUP, CONF_ID, CONF_LAMBDA, + CONF_ON_BOOT, CONF_ON_IDLE, CONF_PAGES, CONF_TIMEOUT, @@ -50,7 +51,7 @@ from .schemas import ( ) from .styles import add_top_layer, styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code -from .trigger import generate_triggers +from .trigger import add_on_boot_triggers, generate_triggers from .types import ( FontEngine, IdleTrigger, @@ -365,6 +366,7 @@ async def to_code(configs): conf[CONF_TRIGGER_ID], lv_component, False ) await build_automation(resume_trigger, [], conf) + await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) # This must be done after all widgets are created for comp in helpers.lvgl_components_required: diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index ae50d5b2e1..6321ae276f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_FORMAT, CONF_GROUP, CONF_ID, + CONF_ON_BOOT, CONF_ON_VALUE, CONF_STATE, CONF_TEXT, @@ -14,6 +15,7 @@ from esphome.const import ( CONF_TYPE, ) from esphome.core import TimePeriod +from esphome.core.config import StartupTrigger from esphome.schema_extractors import SCHEMA_EXTRACT from . import defines as df, lv_validation as lvalid @@ -216,14 +218,24 @@ def automation_schema(typ: LvType): events = events + (CONF_ON_VALUE,) args = typ.get_arg_type() if isinstance(typ, LvType) else [] args.append(lv_event_t_ptr) - return { - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template(*args)), - } - ) - for event in events - } + return cv.Schema( + { + cv.Optional(event): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(*args) + ), + } + ) + for event in events + } + ).extend( + { + cv.Optional(CONF_ON_BOOT): validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} + ) + } + ) def base_update_schema(widget_type, parts): diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index b76f90fecd..283c9a5e56 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.const import CONF_ID, CONF_ON_VALUE, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_BOOT, CONF_ON_VALUE, CONF_TRIGGER_ID from .defines import ( CONF_ALIGN, @@ -28,6 +28,13 @@ from .types import LV_EVENT from .widgets import LvScrActType, get_scr_act, widget_map +async def add_on_boot_triggers(triggers): + for conf in triggers: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], 390) + await cg.register_component(trigger, conf) + await automation.build_automation(trigger, [], conf) + + async def generate_triggers(): """ Generate LVGL triggers for all defined widgets @@ -75,6 +82,8 @@ async def generate_triggers(): UPDATE_EVENT, ) + await add_on_boot_triggers(w.config.get(CONF_ON_BOOT, ())) + # Generate align to directives while we're here if align_to := w.config.get(CONF_ALIGN_TO): target = widget_map[align_to[CONF_ID]].obj diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index c527f51b1e..3dde14194e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -24,6 +24,8 @@ lvgl: logger.log: LVGL is Paused on_resume: logger.log: LVGL has resumed + on_boot: + logger.log: LVGL has started bg_color: light_blue disp_bg_color: color_id disp_bg_image: cat_image @@ -210,6 +212,10 @@ lvgl: src: !lambda "return {dog_image, cat_image};" duration: 2s - label: + on_boot: + lvgl.label.update: + id: hello_label + text: Goodbye Cruel World id: hello_label text: Hello world text_color: 0xFF8000 From 6240bfff97181629a1b2f2089560f752074310c1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:03:29 +1000 Subject: [PATCH 04/13] [lvgl] Make line points templatable (#8502) --- esphome/components/lvgl/__init__.py | 1 + esphome/components/lvgl/automation.py | 2 +- esphome/components/lvgl/defines.py | 4 ++ esphome/components/lvgl/lvcode.py | 4 -- esphome/components/lvgl/lvgl_esphome.h | 14 +++++++ esphome/components/lvgl/schemas.py | 29 ++++++++++++- esphome/components/lvgl/widgets/line.py | 54 ++++++++++--------------- tests/components/lvgl/lvgl-package.yaml | 10 +++++ 8 files changed, 80 insertions(+), 38 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index a3e4ee83fa..f3cb809e7e 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -375,6 +375,7 @@ async def to_code(configs): add_define("LV_COLOR_SCREEN_TRANSP", "1") for use in helpers.lv_uses: add_define(f"LV_USE_{use.upper()}") + cg.add_define(f"USE_LVGL_{use.upper()}") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) cg.add_build_flag("-DLV_CONF_H=1") diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 168fc03cb7..b0979b2848 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -17,6 +17,7 @@ from .defines import ( CONF_SHOW_SNOW, PARTS, literal, + static_cast, ) from .lv_validation import lv_bool, lv_color, lv_image, opacity from .lvcode import ( @@ -32,7 +33,6 @@ from .lvcode import ( lv_expr, lv_obj, lvgl_comp, - static_cast, ) from .schemas import DISP_BG_SCHEMA, LIST_ACTION_SCHEMA, LVGL_SCHEMA, base_update_schema from .types import ( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index a713124bb3..03599de284 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -35,6 +35,10 @@ def literal(arg): return arg +def static_cast(type, value): + return literal(f"static_cast<{type}>({value})") + + def call_lambda(lamb: LambdaExpression): expr = lamb.content.strip() if expr.startswith("return") and expr.endswith(";"): diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 6b98cc4251..0ab5f9e18e 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -285,10 +285,6 @@ class LvExpr(MockLv): pass -def static_cast(type, value): - return literal(f"static_cast<{type}>({value})") - - # Top level mock for generic lv_ calls to be recorded lv = MockLv("lv_") # Just generate an expression diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 69fa808d53..be6379249f 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -90,6 +90,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images // Parent class for things that wrap an LVGL object class LvCompound { public: + virtual ~LvCompound() = default; virtual void set_obj(lv_obj_t *lv_obj) { this->obj = lv_obj; } lv_obj_t *obj{}; }; @@ -330,6 +331,19 @@ class LVEncoderListener : public Parented { }; #endif // USE_LVGL_KEY_LISTENER +#ifdef USE_LVGL_LINE +class LvLineType : public LvCompound { + public: + std::vector get_points() { return this->points_; } + void set_points(std::vector points) { + this->points_ = std::move(points); + lv_line_set_points(this->obj, this->points_.data(), this->points_.size()); + } + + protected: + std::vector points_{}; +}; +#endif #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) class LvSelectable : public LvCompound { public: diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 6321ae276f..89c9502d27 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -19,7 +19,7 @@ from esphome.core.config import StartupTrigger from esphome.schema_extractors import SCHEMA_EXTRACT from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .defines import CONF_TIME_FORMAT, CONF_X, CONF_Y, LV_GRAD_DIR from .helpers import add_lv_use, requires_component, validate_printf from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import LvglComponent, lv_event_t_ptr @@ -87,6 +87,33 @@ ENCODER_SCHEMA = cv.Schema( } ) + +def point_shorthand(value): + """ + A shorthand for a point in the form of x,y + :param value: The value to check + :return: The value as a tuple of x,y + """ + if isinstance(value, str): + try: + x, y = map(int, value.split(",")) + return {CONF_X: x, CONF_Y: y} + except ValueError: + pass + raise cv.Invalid("Invalid point format, should be , ") + + +POINT_SCHEMA = cv.Any( + cv.Schema( + { + cv.Required(CONF_X): cv.templatable(cv.int_), + cv.Required(CONF_Y): cv.templatable(cv.int_), + } + ), + point_shorthand, +) + + # All LVGL styles and their validators STYLE_PROPS = { "align": df.CHILD_ALIGNMENTS.one_of, diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 0156fb1780..220e3a3b57 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -1,11 +1,11 @@ -import functools - import esphome.codegen as cg import esphome.config_validation as cv +from esphome.core import Lambda -from ..defines import CONF_MAIN -from ..lvcode import lv -from ..types import LvType +from ..defines import CONF_MAIN, CONF_X, CONF_Y, call_lambda +from ..lvcode import lv_add +from ..schemas import POINT_SCHEMA +from ..types import LvCompound, LvType from . import Widget, WidgetType CONF_LINE = "line" @@ -15,47 +15,37 @@ CONF_POINT_LIST_ID = "point_list_id" lv_point_t = cg.global_ns.struct("lv_point_t") -def point_list(il): - il = cv.string(il) - nl = il.replace(" ", "").split(",") - return [int(n) for n in nl] - - -def cv_point_list(value): - if not isinstance(value, list): - raise cv.Invalid("List of points required") - values = [point_list(v) for v in value] - if not functools.reduce(lambda f, v: f and len(v) == 2, values, True): - raise cv.Invalid("Points must be a list of x,y integer pairs") - return values - - LINE_SCHEMA = { - cv.Required(CONF_POINTS): cv_point_list, - cv.GenerateID(CONF_POINT_LIST_ID): cv.declare_id(lv_point_t), + cv.Required(CONF_POINTS): cv.ensure_list(POINT_SCHEMA), } -LINE_MODIFY_SCHEMA = { - cv.Optional(CONF_POINTS): cv_point_list, - cv.GenerateID(CONF_POINT_LIST_ID): cv.declare_id(lv_point_t), -} + +async def process_coord(coord): + if isinstance(coord, Lambda): + coord = call_lambda( + await cg.process_lambda(coord, (), return_type="lv_coord_t") + ) + if not coord.endswith("()"): + coord = f"static_cast({coord})" + return cg.RawExpression(coord) + return cg.safe_exp(coord) class LineType(WidgetType): def __init__(self): super().__init__( CONF_LINE, - LvType("lv_line_t"), + LvType("LvLineType", parents=(LvCompound,)), (CONF_MAIN,), LINE_SCHEMA, - modify_schema=LINE_MODIFY_SCHEMA, ) async def to_code(self, w: Widget, config): - """For a line object, create and add the points""" - if data := config.get(CONF_POINTS): - points = cg.static_const_array(config[CONF_POINT_LIST_ID], data) - lv.line_set_points(w.obj, points, len(data)) + points = [ + [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + for p in config[CONF_POINTS] + ] + lv_add(w.var.set_points(points)) line_spec = LineType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3dde14194e..3048ad1951 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -614,6 +614,8 @@ lvgl: align: center points: - 5, 5 + - x: !lambda return random_uint32() % 100; + y: !lambda return random_uint32() % 100; - 70, 70 - 120, 10 - 180, 60 @@ -622,6 +624,14 @@ lvgl: - lvgl.line.update: id: lv_line_id line_color: 0xFFFF + points: + - 5, 5 + - x: !lambda return random_uint32() % 100; + y: !lambda return random_uint32() % 100; + - 70, 70 + - 120, 10 + - 180, 60 + - 240, 10 - lvgl.page.next: - switch: align: right_mid From a866370a2e0a415b7c77c929e5480d20bbbacdf8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:07:59 +1000 Subject: [PATCH 05/13] [spi] Implement octal mode (#8386) --- esphome/components/qspi_dbi/display.py | 2 +- esphome/components/spi/__init__.py | 81 ++++++++++++--------- esphome/components/spi/spi.h | 1 + esphome/components/spi/spi_esp_idf.cpp | 18 +++-- tests/components/spi/test.esp32-s3-idf.yaml | 42 +++++++---- 5 files changed, 88 insertions(+), 56 deletions(-) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index ab6dd66cf2..8c29991f37 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -113,7 +113,7 @@ BASE_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( cs_pin_required=False, default_mode="MODE0", default_data_rate=10e6, - quad=True, + mode=spi.TYPE_QUAD, ) ) ) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 3e6d680b89..5b28b3546b 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -37,6 +37,7 @@ CODEOWNERS = ["@esphome/core", "@clydebarrow"] spi_ns = cg.esphome_ns.namespace("spi") SPIComponent = spi_ns.class_("SPIComponent", cg.Component) QuadSPIComponent = spi_ns.class_("QuadSPIComponent", cg.Component) +OctalSPIComponent = spi_ns.class_("OctalSPIComponent", cg.Component) SPIDevice = spi_ns.class_("SPIDevice") SPIDataRate = spi_ns.enum("SPIDataRate") SPIMode = spi_ns.enum("SPIMode") @@ -78,6 +79,13 @@ CONF_INTERFACE = "interface" CONF_INTERFACE_INDEX = "interface_index" TYPE_SINGLE = "single" TYPE_QUAD = "quad" +TYPE_OCTAL = "octal" + +TYPE_CLASS = { + TYPE_SINGLE: SPIComponent, + TYPE_QUAD: QuadSPIComponent, + TYPE_OCTAL: OctalSPIComponent, +} # RP2040 SPI pin assignments are complicated; # refer to GPIO function select table in https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -230,7 +238,7 @@ def validate_spi_config(config): ): raise cv.Invalid("Invalid pin selections for hardware SPI interface") if CONF_DATA_PINS in spi and CONF_INTERFACE_INDEX not in spi: - raise cv.Invalid("Quad mode requires a hardware interface") + raise cv.Invalid("Quad and octal modes requires a hardware interface") return config @@ -251,7 +259,7 @@ def get_spi_interface(index): return "new SPIClass(HSPI)" -SPI_SCHEMA = cv.All( +SPI_SINGLE_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(SPIComponent), @@ -266,7 +274,7 @@ SPI_SCHEMA = cv.All( lower=True, ), cv.Optional(CONF_DATA_PINS): cv.invalid( - "'data_pins' should be used with 'type: quad' only" + "'data_pins' should be used with 'type: quad or octal' only" ), } ), @@ -274,38 +282,41 @@ SPI_SCHEMA = cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]), ) -SPI_QUAD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(QuadSPIComponent), - cv.Required(CONF_CLK_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_DATA_PINS): cv.All( - cv.ensure_list(pins.internal_gpio_output_pin_number), - cv.Length(min=4, max=4), - ), - cv.Optional(CONF_INTERFACE, default="hardware"): cv.one_of( - *sum(get_hw_interface_list(), ["hardware"]), - lower=True, - ), - cv.Optional(CONF_MISO_PIN): cv.invalid( - "'miso_pin' should not be used with quad SPI" - ), - cv.Optional(CONF_MOSI_PIN): cv.invalid( - "'mosi_pin' should not be used with quad SPI" - ), - } - ), - cv.only_on([PLATFORM_ESP32]), - cv.only_with_esp_idf, -) + +def spi_mode_schema(mode): + if mode == TYPE_SINGLE: + return SPI_SINGLE_SCHEMA + pin_count = 4 if mode == TYPE_QUAD else 8 + return cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TYPE_CLASS[mode]), + cv.Required(CONF_CLK_PIN): pins.gpio_output_pin_schema, + cv.Required(CONF_DATA_PINS): cv.All( + cv.ensure_list(pins.internal_gpio_output_pin_number), + cv.Length(min=pin_count, max=pin_count), + ), + cv.Optional(CONF_INTERFACE, default="hardware"): cv.one_of( + *sum(get_hw_interface_list(), ["hardware"]), + lower=True, + ), + cv.Optional(CONF_MISO_PIN): cv.invalid( + f"'miso_pin' should not be used with {mode} SPI" + ), + cv.Optional(CONF_MOSI_PIN): cv.invalid( + f"'mosi_pin' should not be used with {mode} SPI" + ), + } + ), + cv.only_on([PLATFORM_ESP32]), + cv.only_with_esp_idf, + ) + CONFIG_SCHEMA = cv.All( cv.ensure_list( cv.typed_schema( - { - TYPE_SINGLE: SPI_SCHEMA, - TYPE_QUAD: SPI_QUAD_SCHEMA, - }, + {k: spi_mode_schema(k) for k in TYPE_CLASS}, default_type=TYPE_SINGLE, ) ), @@ -344,19 +355,17 @@ def spi_device_schema( cs_pin_required=True, default_data_rate=cv.UNDEFINED, default_mode=cv.UNDEFINED, - quad=False, + mode=TYPE_SINGLE, ): """Create a schema for an SPI device. :param cs_pin_required: If true, make the CS_PIN required in the config. :param default_data_rate: Optional data_rate to use as default :param default_mode Optional. The default SPI mode to use. - :param quad If set, will require an SPI component configured as quad data bits. + :param mode Choose single, quad or octal mode. :return: The SPI device schema, `extend` this in your config schema. """ schema = { - cv.GenerateID(CONF_SPI_ID): cv.use_id( - QuadSPIComponent if quad else SPIComponent - ), + cv.GenerateID(CONF_SPI_ID): cv.use_id(TYPE_CLASS[mode]), cv.Optional(CONF_DATA_RATE, default=default_data_rate): SPI_DATA_RATE_SCHEMA, cv.Optional(CONF_SPI_MODE, default=default_mode): cv.enum( SPI_MODE_OPTIONS, upper=True diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 64463747a2..378d95e7b9 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -369,6 +369,7 @@ class SPIComponent : public Component { }; using QuadSPIComponent = SPIComponent; +using OctalSPIComponent = SPIComponent; /** * Base class for SPIDevice, un-templated. */ diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 55680f72d3..a78da2cd9a 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -211,11 +211,19 @@ class SPIBusHw : public SPIBus { buscfg.data1_io_num = data_pins[1]; buscfg.data2_io_num = data_pins[2]; buscfg.data3_io_num = data_pins[3]; - buscfg.data4_io_num = -1; - buscfg.data5_io_num = -1; - buscfg.data6_io_num = -1; - buscfg.data7_io_num = -1; - buscfg.flags |= SPICOMMON_BUSFLAG_QUAD; + if (data_pins.size() == 8) { + buscfg.data4_io_num = data_pins[4]; + buscfg.data5_io_num = data_pins[5]; + buscfg.data6_io_num = data_pins[6]; + buscfg.data7_io_num = data_pins[7]; + buscfg.flags |= SPICOMMON_BUSFLAG_OCTAL; + } else { + buscfg.data4_io_num = -1; + buscfg.data5_io_num = -1; + buscfg.data6_io_num = -1; + buscfg.data7_io_num = -1; + buscfg.flags |= SPICOMMON_BUSFLAG_QUAD; + } } buscfg.max_transfer_sz = MAX_TRANSFER_SIZE; auto err = spi_bus_initialize(channel, &buscfg, SPI_DMA_CH_AUTO); diff --git a/tests/components/spi/test.esp32-s3-idf.yaml b/tests/components/spi/test.esp32-s3-idf.yaml index d394c5d7a4..061e3dd44a 100644 --- a/tests/components/spi/test.esp32-s3-idf.yaml +++ b/tests/components/spi/test.esp32-s3-idf.yaml @@ -1,22 +1,36 @@ spi: - - id: spi_id_1 - type: single - clk_pin: - number: GPIO0 - ignore_strapping_warning: true - allow_other_uses: false - mosi_pin: GPIO6 - interface: hardware - id: quad_spi type: quad interface: spi3 - clk_pin: 47 + clk_pin: + number: 47 data_pins: - - number: 40 - allow_other_uses: false - - 41 - - 42 - - 43 + - allow_other_uses: true + number: 40 + - allow_other_uses: true + number: 41 + - allow_other_uses: true + number: 42 + - allow_other_uses: true + number: 43 + - id: octal_spi + type: octal + interface: hardware + clk_pin: + number: 0 + data_pins: + - 36 + - 37 + - 38 + - 39 + - allow_other_uses: true + number: 40 + - allow_other_uses: true + number: 41 + - allow_other_uses: true + number: 42 + - allow_other_uses: true + number: 43 - id: spi_id_3 interface: any clk_pin: 8 From 399c9ba4be0ba0d0005bd9fbd363730ac5512592 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 23:38:06 +0000 Subject: [PATCH 06/13] Bump pytest from 8.2.0 to 8.3.5 (#8528) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3e5b15a718..e43df6703f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.19.1 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==8.2.0 +pytest==8.3.5 pytest-cov==6.0.0 pytest-mock==3.14.0 pytest-asyncio==0.26.0 From 8c5adfb33f8dd45d9332ab6222c0c90e5f2e929d Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Wed, 9 Apr 2025 01:03:38 +0100 Subject: [PATCH 07/13] real_time_clock: Apply timezone immediately in set_timezone() (#8531) --- esphome/components/time/real_time_clock.cpp | 4 ---- esphome/components/time/real_time_clock.h | 7 ++++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 2b9a95c6bd..11e39e8f67 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -21,10 +21,6 @@ namespace time { static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; -void RealTimeClock::call_setup() { - this->apply_timezone_(); - PollingComponent::call_setup(); -} void RealTimeClock::synchronize_epoch_(uint32_t epoch) { // Update UTC epoch time. struct timeval timev { diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index a17168ae6f..401798a568 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -21,7 +21,10 @@ class RealTimeClock : public PollingComponent { explicit RealTimeClock(); /// Set the time zone. - void set_timezone(const std::string &tz) { this->timezone_ = tz; } + void set_timezone(const std::string &tz) { + this->timezone_ = tz; + this->apply_timezone_(); + } /// Get the time zone currently in use. std::string get_timezone() { return this->timezone_; } @@ -35,8 +38,6 @@ class RealTimeClock : public PollingComponent { /// Get the current time as the UTC epoch since January 1st 1970. time_t timestamp_now() { return ::time(nullptr); } - void call_setup() override; - void add_on_time_sync_callback(std::function callback) { this->time_sync_callback_.add(std::move(callback)); }; From 1f7a84cc8e85b4fbcbaa11bfa4358ece59521219 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:15:39 +1000 Subject: [PATCH 08/13] [lvgl] Implement canvas widget (#8504) --- esphome/components/lvgl/__init__.py | 16 +- esphome/components/lvgl/defines.py | 2 +- esphome/components/lvgl/lv_validation.py | 26 +- esphome/components/lvgl/lvcode.py | 13 +- esphome/components/lvgl/lvgl_esphome.h | 5 + esphome/components/lvgl/schemas.py | 66 ++-- esphome/components/lvgl/styles.py | 53 ++- esphome/components/lvgl/widgets/canvas.py | 403 ++++++++++++++++++++++ esphome/components/lvgl/widgets/line.py | 6 +- tests/components/lvgl/lvgl-package.yaml | 108 ++++++ 10 files changed, 631 insertions(+), 67 deletions(-) create mode 100644 esphome/components/lvgl/widgets/canvas.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index f3cb809e7e..30fa58c380 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -39,14 +39,13 @@ from .lvcode import LvContext, LvglComponent, lvgl_static from .schemas import ( DISP_BG_SCHEMA, FLEX_OBJ_SCHEMA, + FULL_STYLE_SCHEMA, GRID_CELL_SCHEMA, LAYOUT_SCHEMAS, - STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, create_modify_schema, - grid_alignments, obj_schema, ) from .styles import add_top_layer, styles_to_code, theme_to_code @@ -74,6 +73,7 @@ from .widgets.animimg import animimg_spec from .widgets.arc import arc_spec from .widgets.button import button_spec from .widgets.buttonmatrix import buttonmatrix_spec +from .widgets.canvas import canvas_spec from .widgets.checkbox import checkbox_spec from .widgets.dropdown import dropdown_spec from .widgets.img import img_spec @@ -126,6 +126,7 @@ for w_type in ( keyboard_spec, tileview_spec, qr_code_spec, + canvas_spec, ): WIDGET_TYPES[w_type.name] = w_type @@ -421,15 +422,8 @@ LVGL_SCHEMA = cv.All( "big_endian", "little_endian" ), cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}) - .extend(STYLE_SCHEMA) - .extend( - { - cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments, - cv.Optional(df.CONF_GRID_CELL_Y_ALIGN): grid_alignments, - cv.Optional(df.CONF_PAD_ROW): lvalid.pixels, - cv.Optional(df.CONF_PAD_COLUMN): lvalid.pixels, - } + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA ) ), cv.Optional(CONF_ON_IDLE): validate_automation( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 03599de284..7dedb55418 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -29,7 +29,7 @@ def add_define(macro, value="1"): lv_defines[macro] = value -def literal(arg): +def literal(arg) -> MockObj: if isinstance(arg, str): return MockObj(arg) return arg diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index f91ed893f2..a3b7cc8ed3 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -254,11 +254,27 @@ def pixels_or_percent_validator(value): pixels_or_percent = LValidator(pixels_or_percent_validator, uint32, retmapper=literal) -def zoom(value): +def pixels_validator(value): + if isinstance(value, str) and value.lower().endswith("px"): + value = value[:-2] + return cv.positive_int(value) + + +pixels = LValidator(pixels_validator, uint32, retmapper=literal) + + +def zoom_validator(value): value = cv.float_range(0.1, 10.0)(value) + return value + + +def zoom_retmapper(value): return int(value * 256) +zoom = LValidator(zoom_validator, uint32, retmapper=zoom_retmapper) + + def angle(value): """ Validation for an angle in degrees, converted to an integer representing 0.1deg units @@ -286,14 +302,6 @@ def size_validator(value): size = LValidator(size_validator, uint32, retmapper=literal) -def pixels_validator(value): - if isinstance(value, str) and value.lower().endswith("px"): - return cv.int_(value[:-2]) - return cv.int_(value) - - -pixels = LValidator(pixels_validator, uint32, retmapper=literal) - radius_consts = LvConstant("LV_RADIUS_", "CIRCLE") diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 0ab5f9e18e..c8d744dfc8 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -206,11 +206,16 @@ class LocalVariable(MockObj): def __enter__(self): CodeContext.start_block() - CodeContext.append( - VariableDeclarationExpression(self.base.type, self.modifier, self.base.id) - ) if self.rhs is not None: - CodeContext.append(AssignmentExpression(None, "", self.base, self.rhs)) + CodeContext.append( + AssignmentExpression(self.base.type, self.modifier, self.base, self.rhs) + ) + else: + CodeContext.append( + VariableDeclarationExpression( + self.base.type, self.modifier, self.base.id + ) + ) return MockObj(self.base) def __exit__(self, *args): diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index be6379249f..8ffdbf1eda 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -63,6 +63,11 @@ inline void lv_disp_set_bg_image(lv_disp_t *disp, esphome::image::Image *image) inline void lv_obj_set_style_bg_img_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { lv_obj_set_style_bg_img_src(obj, image->get_lv_img_dsc(), selector); } +inline void lv_canvas_draw_img(lv_obj_t *canvas, lv_coord_t x, lv_coord_t y, image::Image *image, + lv_draw_img_dsc_t *dsc) { + lv_canvas_draw_img(canvas, x, y, image->get_lv_img_dsc(), dsc); +} + #ifdef USE_LVGL_METER inline lv_meter_indicator_t *lv_meter_add_needle_img(lv_obj_t *obj, lv_meter_scale_t *scale, esphome::image::Image *src, lv_coord_t pivot_x, lv_coord_t pivot_y) { diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 89c9502d27..c05dfae8c7 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -87,31 +87,29 @@ ENCODER_SCHEMA = cv.Schema( } ) +POINT_SCHEMA = cv.Schema( + { + cv.Required(CONF_X): cv.templatable(cv.int_), + cv.Required(CONF_Y): cv.templatable(cv.int_), + } +) -def point_shorthand(value): + +def point_schema(value): """ A shorthand for a point in the form of x,y :param value: The value to check :return: The value as a tuple of x,y """ - if isinstance(value, str): - try: - x, y = map(int, value.split(",")) - return {CONF_X: x, CONF_Y: y} - except ValueError: - pass - raise cv.Invalid("Invalid point format, should be , ") - - -POINT_SCHEMA = cv.Any( - cv.Schema( - { - cv.Required(CONF_X): cv.templatable(cv.int_), - cv.Required(CONF_Y): cv.templatable(cv.int_), - } - ), - point_shorthand, -) + if isinstance(value, dict): + return POINT_SCHEMA(value) + try: + x, y = map(int, value.split(",")) + return {CONF_X: x, CONF_Y: y} + except ValueError: + pass + # not raising this in the catch block because pylint doesn't like it + raise cv.Invalid("Invalid point format, should be , ") # All LVGL styles and their validators @@ -132,6 +130,7 @@ STYLE_PROPS = { "bg_image_recolor": lvalid.lv_color, "bg_image_recolor_opa": lvalid.opacity, "bg_image_src": lvalid.lv_image, + "bg_image_tiled": lvalid.lv_bool, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, "border_color": lvalid.lv_color, @@ -146,9 +145,9 @@ STYLE_PROPS = { "height": lvalid.size, "image_recolor": lvalid.lv_color, "image_recolor_opa": lvalid.opacity, - "line_width": cv.positive_int, - "line_dash_width": cv.positive_int, - "line_dash_gap": cv.positive_int, + "line_width": lvalid.lv_positive_int, + "line_dash_width": lvalid.lv_positive_int, + "line_dash_gap": lvalid.lv_positive_int, "line_rounded": lvalid.lv_bool, "line_color": lvalid.lv_color, "opa": lvalid.opacity, @@ -176,8 +175,8 @@ STYLE_PROPS = { "LV_TEXT_DECOR_", "NONE", "UNDERLINE", "STRIKETHROUGH" ).several_of, "text_font": lv_font, - "text_letter_space": cv.positive_int, - "text_line_space": cv.positive_int, + "text_letter_space": lvalid.lv_positive_int, + "text_line_space": lvalid.lv_positive_int, "text_opa": lvalid.opacity, "transform_angle": lvalid.lv_angle, "transform_height": lvalid.pixels_or_percent, @@ -201,10 +200,15 @@ STYLE_REMAP = { "bg_image_recolor": "bg_img_recolor", "bg_image_recolor_opa": "bg_img_recolor_opa", "bg_image_src": "bg_img_src", + "bg_image_tiled": "bg_img_tiled", "image_recolor": "img_recolor", "image_recolor_opa": "img_recolor_opa", } +cell_alignments = df.LV_CELL_ALIGNMENTS.one_of +grid_alignments = df.LV_GRID_ALIGNMENTS.one_of +flex_alignments = df.LV_FLEX_ALIGNMENTS.one_of + # Complete object style schema STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend( { @@ -215,6 +219,16 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex } ) +# Also allow widget specific properties for use in style definitions +FULL_STYLE_SCHEMA = STYLE_SCHEMA.extend( + { + cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments, + cv.Optional(df.CONF_GRID_CELL_Y_ALIGN): grid_alignments, + cv.Optional(df.CONF_PAD_ROW): lvalid.pixels, + cv.Optional(df.CONF_PAD_COLUMN): lvalid.pixels, + } +) + # Object states. Top level properties apply to MAIN STATE_SCHEMA = cv.Schema( {cv.Optional(state): STYLE_SCHEMA for state in df.STATES} @@ -346,10 +360,6 @@ grid_spec = cv.Any( lvalid.size, df.LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space ) -cell_alignments = df.LV_CELL_ALIGNMENTS.one_of -grid_alignments = df.LV_GRID_ALIGNMENTS.one_of -flex_alignments = df.LV_FLEX_ALIGNMENTS.one_of - LAYOUT_SCHEMA = { cv.Optional(df.CONF_LAYOUT): cv.typed_schema( { diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 6332e0976f..b59ff513e2 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -1,4 +1,6 @@ +from esphome import automation import esphome.codegen as cg +import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import ID from esphome.cpp_generator import MockObj @@ -12,25 +14,54 @@ from .defines import ( ) from .helpers import add_lv_use from .lvcode import LambdaContext, LocalVariable, lv, lv_assign, lv_variable -from .schemas import ALL_STYLES, STYLE_REMAP -from .types import lv_lambda_t, lv_obj_t, lv_obj_t_ptr -from .widgets import Widget, add_widgets, set_obj_properties, theme_widget_map +from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, STYLE_REMAP +from .types import ObjUpdateAction, lv_lambda_t, lv_obj_t, lv_obj_t_ptr, lv_style_t +from .widgets import ( + Widget, + add_widgets, + set_obj_properties, + theme_widget_map, + wait_for_widgets, +) from .widgets.obj import obj_spec +async def style_set(svar, style): + for prop, validator in ALL_STYLES.items(): + if (value := style.get(prop)) is not None: + if isinstance(validator, LValidator): + value = await validator.process(value) + if isinstance(value, list): + value = "|".join(value) + remapped_prop = STYLE_REMAP.get(prop, prop) + lv.call(f"style_set_{remapped_prop}", svar, literal(value)) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): svar = cg.new_Pvariable(style[CONF_ID]) lv.style_init(svar) - for prop, validator in ALL_STYLES.items(): - if (value := style.get(prop)) is not None: - if isinstance(validator, LValidator): - value = await validator.process(value) - if isinstance(value, list): - value = "|".join(value) - remapped_prop = STYLE_REMAP.get(prop, prop) - lv.call(f"style_set_{remapped_prop}", svar, literal(value)) + await style_set(svar, style) + + +@automation.register_action( + "lvgl.style.update", + ObjUpdateAction, + FULL_STYLE_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.use_id(lv_style_t), + } + ), +) +async def style_update_to_code(config, action_id, template_arg, args): + await wait_for_widgets() + style = await cg.get_variable(config[CONF_ID]) + async with LambdaContext(parameters=args, where=action_id) as context: + await style_set(style, config) + + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + return var async def theme_to_code(config): diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py new file mode 100644 index 0000000000..bc26558624 --- /dev/null +++ b/esphome/components/lvgl/widgets/canvas.py @@ -0,0 +1,403 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.components.display_menu_base import CONF_LABEL +from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ID, CONF_TEXT, CONF_WIDTH +from esphome.cpp_generator import Literal, MockObj + +from ..automation import action_to_code +from ..defines import ( + CONF_END_ANGLE, + CONF_MAIN, + CONF_OPA, + CONF_PIVOT_X, + CONF_PIVOT_Y, + CONF_POINTS, + CONF_SRC, + CONF_START_ANGLE, + CONF_X, + CONF_Y, + literal, +) +from ..lv_validation import ( + lv_angle, + lv_bool, + lv_color, + lv_image, + lv_text, + opacity, + pixels, + size, +) +from ..lvcode import LocalVariable, lv, lv_assign +from ..schemas import STYLE_PROPS, STYLE_REMAP, TEXT_SCHEMA, point_schema +from ..types import LvType, ObjUpdateAction, WidgetType +from . import Widget, get_widgets +from .line import lv_point_t, process_coord + +CONF_CANVAS = "canvas" +CONF_BUFFER_ID = "buffer_id" +CONF_MAX_WIDTH = "max_width" +CONF_TRANSPARENT = "transparent" +CONF_RADIUS = "radius" + +lv_canvas_t = LvType("lv_canvas_t") + + +class CanvasType(WidgetType): + def __init__(self): + super().__init__( + CONF_CANVAS, + lv_canvas_t, + (CONF_MAIN,), + cv.Schema( + { + cv.Required(CONF_WIDTH): size, + cv.Required(CONF_HEIGHT): size, + cv.Optional(CONF_TRANSPARENT, default=False): cv.boolean, + } + ), + ) + + def get_uses(self): + return "img", CONF_LABEL + + async def to_code(self, w: Widget, config): + width = config[CONF_WIDTH] + height = config[CONF_HEIGHT] + use_alpha = "_ALPHA" if config[CONF_TRANSPARENT] else "" + lv.canvas_set_buffer( + w.obj, + lv.custom_mem_alloc( + literal(f"LV_CANVAS_BUF_SIZE_TRUE_COLOR{use_alpha}({width}, {height})") + ), + width, + height, + literal(f"LV_IMG_CF_TRUE_COLOR{use_alpha}"), + ) + + +canvas_spec = CanvasType() + + +@automation.register_action( + "lvgl.canvas.fill", + ObjUpdateAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Required(CONF_COLOR): lv_color, + cv.Optional(CONF_OPA, default="COVER"): opacity, + }, + ), +) +async def canvas_fill(config, action_id, template_arg, args): + widget = await get_widgets(config) + color = await lv_color.process(config[CONF_COLOR]) + opa = await opacity.process(config[CONF_OPA]) + + async def do_fill(w: Widget): + lv.canvas_fill_bg(w.obj, color, opa) + + return await action_to_code(widget, do_fill, action_id, template_arg, args) + + +@automation.register_action( + "lvgl.canvas.set_pixels", + ObjUpdateAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Required(CONF_COLOR): lv_color, + cv.Optional(CONF_OPA): opacity, + cv.Required(CONF_POINTS): cv.ensure_list(point_schema), + }, + ), +) +async def canvas_set_pixel(config, action_id, template_arg, args): + widget = await get_widgets(config) + color = await lv_color.process(config[CONF_COLOR]) + opa = await opacity.process(config.get(CONF_OPA)) + points = [ + ( + await pixels.process(p[CONF_X]), + await pixels.process(p[CONF_Y]), + ) + for p in config[CONF_POINTS] + ] + + async def do_set_pixels(w: Widget): + if isinstance(color, MockObj): + for point in points: + x, y = point + lv.canvas_set_px_color(w.obj, x, y, color) + else: + with LocalVariable("color", "lv_color_t", color, modifier="") as color_var: + for point in points: + x, y = point + lv.canvas_set_px_color(w.obj, x, y, color_var) + if opa: + if isinstance(opa, Literal): + for point in points: + x, y = point + lv.canvas_set_px_opa(w.obj, x, y, opa) + else: + with LocalVariable("opa", "lv_opa_t", opa, modifier="") as opa_var: + for point in points: + x, y = point + lv.canvas_set_px_opa(w.obj, x, y, opa_var) + + return await action_to_code(widget, do_set_pixels, action_id, template_arg, args) + + +DRAW_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Required(CONF_X): pixels, + cv.Required(CONF_Y): pixels, + } +) +DRAW_OPA_SCHEMA = DRAW_SCHEMA.extend( + { + cv.Optional(CONF_OPA): opacity, + } +) + + +async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg, args): + widget = await get_widgets(config) + x = await pixels.process(config.get(CONF_X)) + y = await pixels.process(config.get(CONF_Y)) + + async def action_func(w: Widget): + with LocalVariable("dsc", f"lv_draw_{dsc_type}_dsc_t", modifier="") as dsc: + dsc_addr = literal(f"&{dsc}") + lv.call(f"draw_{dsc_type}_dsc_init", dsc_addr) + if CONF_OPA in config: + opa = await opacity.process(config[CONF_OPA]) + lv_assign(dsc.opa, opa) + for prop, validator in props.items(): + if prop in config: + value = await validator.process(config[prop]) + mapped_prop = STYLE_REMAP.get(prop, prop) + lv_assign(getattr(dsc, mapped_prop), value) + await do_draw(w, x, y, dsc_addr) + + return await action_to_code(widget, action_func, action_id, template_arg, args) + + +RECT_PROPS = { + p: STYLE_PROPS[p] + for p in ( + "radius", + "bg_opa", + "bg_color", + "bg_grad", + "border_color", + "border_width", + "border_opa", + "outline_color", + "outline_width", + "outline_pad", + "outline_opa", + "shadow_color", + "shadow_width", + "shadow_ofs_x", + "shadow_ofs_y", + "shadow_spread", + "shadow_opa", + ) +} + + +@automation.register_action( + "lvgl.canvas.draw_rectangle", + ObjUpdateAction, + DRAW_SCHEMA.extend( + { + cv.Required(CONF_WIDTH): cv.templatable(cv.int_), + cv.Required(CONF_HEIGHT): cv.templatable(cv.int_), + }, + ).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}), +) +async def canvas_draw_rect(config, action_id, template_arg, args): + width = await pixels.process(config[CONF_WIDTH]) + height = await pixels.process(config[CONF_HEIGHT]) + + async def do_draw_rect(w: Widget, x, y, dsc_addr): + lv.canvas_draw_rect(w.obj, x, y, width, height, dsc_addr) + + return await draw_to_code( + config, "rect", RECT_PROPS, do_draw_rect, action_id, template_arg, args + ) + + +TEXT_PROPS = { + p: STYLE_PROPS[f"text_{p}"] + for p in ( + "font", + "color", + # "sel_color", + # "sel_bg_color", + "line_space", + "letter_space", + "align", + "decor", + ) +} + + +@automation.register_action( + "lvgl.canvas.draw_text", + ObjUpdateAction, + TEXT_SCHEMA.extend(DRAW_OPA_SCHEMA) + .extend( + { + cv.Required(CONF_MAX_WIDTH): cv.templatable(cv.int_), + }, + ) + .extend({cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}), +) +async def canvas_draw_text(config, action_id, template_arg, args): + text = await lv_text.process(config[CONF_TEXT]) + max_width = await pixels.process(config[CONF_MAX_WIDTH]) + + async def do_draw_text(w: Widget, x, y, dsc_addr): + lv.canvas_draw_text(w.obj, x, y, max_width, dsc_addr, text) + + return await draw_to_code( + config, "label", TEXT_PROPS, do_draw_text, action_id, template_arg, args + ) + + +IMG_PROPS = { + "angle": STYLE_PROPS["transform_angle"], + "zoom": STYLE_PROPS["transform_zoom"], + "recolor": STYLE_PROPS["image_recolor"], + "recolor_opa": STYLE_PROPS["image_recolor_opa"], + "opa": STYLE_PROPS["opa"], +} + + +@automation.register_action( + "lvgl.canvas.draw_image", + ObjUpdateAction, + DRAW_OPA_SCHEMA.extend( + { + cv.Required(CONF_SRC): lv_image, + cv.Optional(CONF_PIVOT_X, default=0): pixels, + cv.Optional(CONF_PIVOT_Y, default=0): pixels, + }, + ).extend({cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}), +) +async def canvas_draw_image(config, action_id, template_arg, args): + src = await lv_image.process(config[CONF_SRC]) + pivot_x = await pixels.process(config[CONF_PIVOT_X]) + pivot_y = await pixels.process(config[CONF_PIVOT_Y]) + + async def do_draw_image(w: Widget, x, y, dsc_addr): + dsc = MockObj(f"(*{dsc_addr})") + if pivot_x or pivot_y: + # pylint :disable=no-member + lv_assign(dsc.pivot, literal(f"{{{pivot_x}, {pivot_y}}}")) + lv.canvas_draw_img(w.obj, x, y, src, dsc_addr) + + return await draw_to_code( + config, "img", IMG_PROPS, do_draw_image, action_id, template_arg, args + ) + + +LINE_PROPS = { + "width": STYLE_PROPS["line_width"], + "color": STYLE_PROPS["line_color"], + "dash-width": STYLE_PROPS["line_dash_width"], + "dash-gap": STYLE_PROPS["line_dash_gap"], + "round_start": lv_bool, + "round_end": lv_bool, +} + + +@automation.register_action( + "lvgl.canvas.draw_line", + ObjUpdateAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Optional(CONF_OPA): opacity, + cv.Required(CONF_POINTS): cv.ensure_list(point_schema), + }, + ).extend({cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}), +) +async def canvas_draw_line(config, action_id, template_arg, args): + points = [ + [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + for p in config[CONF_POINTS] + ] + + async def do_draw_line(w: Widget, x, y, dsc_addr): + with LocalVariable( + "points", cg.std_vector.template(lv_point_t), points, modifier="" + ) as points_var: + lv.canvas_draw_line(w.obj, points_var.data(), points_var.size(), dsc_addr) + + return await draw_to_code( + config, "line", LINE_PROPS, do_draw_line, action_id, template_arg, args + ) + + +@automation.register_action( + "lvgl.canvas.draw_polygon", + ObjUpdateAction, + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Required(CONF_POINTS): cv.ensure_list(point_schema), + }, + ).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}), +) +async def canvas_draw_polygon(config, action_id, template_arg, args): + points = [ + [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + for p in config[CONF_POINTS] + ] + + async def do_draw_polygon(w: Widget, x, y, dsc_addr): + with LocalVariable( + "points", cg.std_vector.template(lv_point_t), points, modifier="" + ) as points_var: + lv.canvas_draw_polygon( + w.obj, points_var.data(), points_var.size(), dsc_addr + ) + + return await draw_to_code( + config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args + ) + + +ARC_PROPS = { + "width": STYLE_PROPS["arc_width"], + "color": STYLE_PROPS["arc_color"], + "rounded": STYLE_PROPS["arc_rounded"], +} + + +@automation.register_action( + "lvgl.canvas.draw_arc", + ObjUpdateAction, + DRAW_OPA_SCHEMA.extend( + { + cv.Required(CONF_RADIUS): pixels, + cv.Required(CONF_START_ANGLE): lv_angle, + cv.Required(CONF_END_ANGLE): lv_angle, + } + ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), +) +async def canvas_draw_arc(config, action_id, template_arg, args): + radius = await size.process(config[CONF_RADIUS]) + start_angle = await lv_angle.process(config[CONF_START_ANGLE]) + end_angle = await lv_angle.process(config[CONF_END_ANGLE]) + + async def do_draw_arc(w: Widget, x, y, dsc_addr): + lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) + + return await draw_to_code( + config, "arc", ARC_PROPS, do_draw_arc, action_id, template_arg, args + ) diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 220e3a3b57..94fdfe2346 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -4,7 +4,7 @@ from esphome.core import Lambda from ..defines import CONF_MAIN, CONF_X, CONF_Y, call_lambda from ..lvcode import lv_add -from ..schemas import POINT_SCHEMA +from ..schemas import point_schema from ..types import LvCompound, LvType from . import Widget, WidgetType @@ -16,14 +16,14 @@ lv_point_t = cg.global_ns.struct("lv_point_t") LINE_SCHEMA = { - cv.Required(CONF_POINTS): cv.ensure_list(POINT_SCHEMA), + cv.Required(CONF_POINTS): cv.ensure_list(point_schema), } async def process_coord(coord): if isinstance(coord, Lambda): coord = call_lambda( - await cg.process_lambda(coord, (), return_type="lv_coord_t") + await cg.process_lambda(coord, [], return_type="lv_coord_t") ) if not coord.endswith("()"): coord = f"static_cast({coord})" diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3048ad1951..78c261c01d 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -130,6 +130,10 @@ lvgl: on_click: then: - lvgl.widget.hide: message_box + - lvgl.style.update: + id: style_test + bg_color: blue + bg_opa: !lambda return 0.5; - id: simple_msgbox title: Simple @@ -510,6 +514,110 @@ lvgl: - id: page2 widgets: + - canvas: + id: canvas_id + align: center + width: 400 + height: 400 + transparent: true + on_boot: + - lvgl.canvas.fill: + color: blue + opa: 50% + - lvgl.canvas.draw_rectangle: + x: 20 + y: 20 + width: 150 + height: 150 + bg_color: green + bg_opa: cover + radius: 5 + border_color: black + border_width: 4 + border_opa: 80% + shadow_color: black + shadow_width: 10 + shadow_ofs_x: 5 + shadow_ofs_y: 5 + shadow_spread: 4 + shadow_opa: cover + outline_color: red + outline_width: 4 + outline_pad: 4 + outline_opa: cover + - lvgl.canvas.set_pixels: + color: red + points: + - x: 100 + y: 100 + - 100,101 + - 100,102 + - 100,103 + - 100,104 + - lvgl.canvas.set_pixels: + opa: 50% + color: !lambda return lv_color_make(255,255,255); + points: + - x: !lambda return random_uint32() % 200; + y: !lambda return random_uint32() % 200; + - 121,120 + - 122,120 + - 123,120 + - 124,120 + - 125,120 + + - lvgl.canvas.draw_text: + x: 100 + y: 100 + font: montserrat_18 + color: white + opa: cover + decor: underline + letter_space: 1 + line_space: 2 + text: Canvas Text + align: center + max_width: 150 + - lvgl.canvas.draw_image: + src: cat_image + x: 100 + y: 100 + angle: 90 + zoom: 2.0 + pivot_x: 25 + pivot_y: 25 + - lvgl.canvas.draw_line: + color: blue + width: 4 + round_end: true + round_start: false + points: + - 50,50 + - 50, 200 + - 200, 200 + - 200, 50 + - 50,50 + - lvgl.canvas.draw_polygon: + bg_color: teal + border_color: white + border_width: 2 + border_opa: cover + points: + - 150,150 + - 150, 300 + - 300, 300 + - 350, 250 + - lvgl.canvas.draw_arc: + x: 200 + y: 200 + radius: 40 + opa: 50% + color: purple + width: 6 + rounded: true + start_angle: 10 + end_angle: !lambda return 900; + - qrcode: id: lv_qr align: left_mid From 4a1cbfc533f6cbdedefb2ff41d351cbcc7a5267e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:19:05 +1200 Subject: [PATCH 09/13] Bump version to 2025.4.0b1 --- esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index f6f9b7df80..1d7f501c9e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.4.0-dev" +__version__ = "2025.4.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 6b930595e25baa9e170d04249baf00056f1a7a8a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:19:05 +1200 Subject: [PATCH 10/13] Bump version to 2025.5.0-dev --- esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/const.py b/esphome/const.py index f6f9b7df80..31da9c56ea 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.4.0-dev" +__version__ = "2025.5.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 27f6d00e7a0240f36afaaa3fefc7539c89b35ddc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:28:07 -1000 Subject: [PATCH 11/13] Bump ruff from 0.11.2 to 0.11.4 (#8538) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e43df6703f..8c460ddb49 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.6 flake8==7.2.0 # also change in .pre-commit-config.yaml when updating -ruff==0.11.2 # also change in .pre-commit-config.yaml when updating +ruff==0.11.4 # also change in .pre-commit-config.yaml when updating pyupgrade==3.19.1 # also change in .pre-commit-config.yaml when updating pre-commit From 645bd490ba046bc64b8bf7a38739284d3b29551b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:28:22 -1000 Subject: [PATCH 12/13] Bump pytest-cov from 6.0.0 to 6.1.1 (#8537) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8c460ddb49..9b75c73710 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ pre-commit # Unit tests pytest==8.3.5 -pytest-cov==6.0.0 +pytest-cov==6.1.1 pytest-mock==3.14.0 pytest-asyncio==0.26.0 asyncmock==0.4.2 From d9873e24a7a71adbf7d78b3d2d2b02fd2a5ae8ff Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:28:44 +1000 Subject: [PATCH 13/13] [lvgl] Fix use of image without canvas (Bugfix) (#8540) --- esphome/components/lvgl/lvgl_esphome.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8ffdbf1eda..3ae67e8a0b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -63,10 +63,12 @@ inline void lv_disp_set_bg_image(lv_disp_t *disp, esphome::image::Image *image) inline void lv_obj_set_style_bg_img_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { lv_obj_set_style_bg_img_src(obj, image->get_lv_img_dsc(), selector); } +#ifdef USE_LVGL_CANVAS inline void lv_canvas_draw_img(lv_obj_t *canvas, lv_coord_t x, lv_coord_t y, image::Image *image, lv_draw_img_dsc_t *dsc) { lv_canvas_draw_img(canvas, x, y, image->get_lv_img_dsc(), dsc); } +#endif #ifdef USE_LVGL_METER inline lv_meter_indicator_t *lv_meter_add_needle_img(lv_obj_t *obj, lv_meter_scale_t *scale, esphome::image::Image *src,