diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 503730098e..974eed9e81 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -41,7 +41,7 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_gradient_t, lv_opa_t +from .types import lv_coord_t, lv_gradient_t, lv_opa_t LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -277,7 +277,7 @@ def pixels_or_percent_validator(value): pixels_or_percent = LValidator( pixels_or_percent_validator, - uint32, + lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), ) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 2c57452a55..62117fbd32 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -123,8 +123,8 @@ ENCODER_SCHEMA = cv.Schema( POINT_SCHEMA = cv.Schema( { - cv.Required(CONF_X): cv.templatable(cv.int_), - cv.Required(CONF_Y): cv.templatable(cv.int_), + cv.Required(CONF_X): lvalid.pixels_or_percent, + cv.Required(CONF_Y): lvalid.pixels_or_percent, } ) @@ -137,9 +137,13 @@ def point_schema(value): """ if isinstance(value, dict): return POINT_SCHEMA(value) + if isinstance(value, list): + if len(value) != 2: + raise cv.Invalid("Invalid point format, should be , ") + return POINT_SCHEMA({CONF_X: value[0], CONF_Y: value[1]}) try: - x, y = map(int, value.split(",")) - return {CONF_X: x, CONF_Y: y} + x, y = str(value).split(",") + return POINT_SCHEMA({CONF_X: x, CONF_Y: y}) except ValueError: pass # not raising this in the catch block because pylint doesn't like it diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index f12766bae1..1308b82dcd 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -52,6 +52,7 @@ from ..lv_validation import ( lv_text, opacity, pixels, + pixels_or_percent, size, ) from ..lvcode import LocalVariable, lv, lv_assign, lv_expr @@ -59,7 +60,7 @@ from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property from ..types import LvType, ObjUpdateAction from . import Widget, WidgetType, get_widgets from .img import CONF_IMAGE -from .line import lv_point_precise_t, process_coord +from .line import lv_point_precise_t CONF_CANVAS = "canvas" CONF_BUFFER_ID = "buffer_id" @@ -434,6 +435,13 @@ LINE_PROPS = { } +def _validate_points(config): + for index, point in enumerate(config[CONF_POINTS]): + if not all(isinstance(p, int) for p in point.values()): + raise cv.Invalid("Points must be integers", path=[CONF_POINTS, index]) + return config + + @automation.register_action( "lvgl.canvas.draw_line", ObjUpdateAction, @@ -444,12 +452,15 @@ LINE_PROPS = { cv.Required(CONF_POINTS): cv.ensure_list(point_schema), **{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}, } - ), + ).add_extra(_validate_points), synchronous=True, ) async def canvas_draw_line(config, action_id, template_arg, args): points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels.process(p[CONF_X]), + await pixels.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] @@ -470,12 +481,15 @@ async def canvas_draw_line(config, action_id, template_arg, args): cv.Required(CONF_POINTS): cv.ensure_list(point_schema), **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, }, - ), + ).add_extra(_validate_points), synchronous=True, ) async def canvas_draw_polygon(config, action_id, template_arg, args): points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels_or_percent.process(p[CONF_X]), + await pixels_or_percent.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] # Close the polygon diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 3112cc28d0..19f421cbbd 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -1,12 +1,12 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_X, CONF_Y -from esphome.core import Lambda -from ..defines import CONF_MAIN, call_lambda +from ..defines import CONF_MAIN +from ..lv_validation import pixels_or_percent from ..lvcode import lv_add from ..schemas import point_schema -from ..types import LvCompound, LvType, lv_coord_t +from ..types import LvCompound, LvType from . import Widget, WidgetType CONF_LINE = "line" @@ -17,12 +17,6 @@ lv_point_t = cg.global_ns.struct("lv_point_t") lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") -async def process_coord(coord): - if isinstance(coord, Lambda): - return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) - return cg.safe_exp(coord) - - class LineType(WidgetType): def __init__(self): super().__init__( @@ -36,7 +30,10 @@ class LineType(WidgetType): async def to_code(self, w: Widget, config): if CONF_POINTS in config: points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + [ + await pixels_or_percent.process(p[CONF_X]), + await pixels_or_percent.process(p[CONF_Y]), + ] for p in config[CONF_POINTS] ] lv_add(w.var.set_points(points)) diff --git a/tests/component_tests/lvgl/config/line_points.yaml b/tests/component_tests/lvgl/config/line_points.yaml new file mode 100644 index 0000000000..5d7be3bc20 --- /dev/null +++ b/tests/component_tests/lvgl/config/line_points.yaml @@ -0,0 +1,84 @@ +esphome: + name: test-line + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + # Dict format + - line: + id: line_dict + points: + - x: 10 + y: 20 + - x: 100 + y: 200 + - x: 0 + y: 0 + + # List format + - line: + id: line_list + points: + - [10, 20] + - [100, 200] + - [0, 0] + + # String format + - line: + id: line_string + points: + - "10, 20" + - "100, 200" + - "0, 0" + + # Percentage - dict format + - line: + id: line_pct_dict + points: + - x: "50%" + y: "75%" + + # Percentage - list format + - line: + id: line_pct_list + points: + - ["50%", "75%"] + + # Percentage - string format + - line: + id: line_pct_string + points: + - "50%, 75%" + + # Mixed integer and percentage + - line: + id: line_mixed_dict + points: + - x: 10 + y: "50%" + - x: "25%" + y: 200 + + - line: + id: line_mixed_list + points: + - [10, "50%"] + - ["25%", 200] diff --git a/tests/component_tests/lvgl/test_line.py b/tests/component_tests/lvgl/test_line.py new file mode 100644 index 0000000000..fce0ef8fa8 --- /dev/null +++ b/tests/component_tests/lvgl/test_line.py @@ -0,0 +1,147 @@ +"""Tests for the LVGL line widget point schema and code generation.""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.lvgl.schemas import point_schema +from esphome.config_validation import Invalid +from esphome.const import CONF_X, CONF_Y + +# --------------------------------------------------------------------------- +# Validation: point_schema normalises dict / list / string to same result +# --------------------------------------------------------------------------- + + +class TestPointSchemaValidation: + """Test that all point input formats normalise to the same dict.""" + + @pytest.mark.parametrize( + "dict_input,list_input,string_input", + [ + ({CONF_X: 10, CONF_Y: 20}, [10, 20], "10, 20"), + ({CONF_X: 0, CONF_Y: 0}, [0, 0], "0, 0"), + ({CONF_X: 100, CONF_Y: 200}, [100, 200], "100, 200"), + ({CONF_X: -5, CONF_Y: -10}, [-5, -10], "-5, -10"), + ], + ) + def test_integer_formats_produce_same_result( + self, dict_input, list_input, string_input + ): + result_dict = point_schema(dict_input) + result_list = point_schema(list_input) + result_string = point_schema(string_input) + + assert result_dict == result_list + assert result_dict == result_string + + def test_percentage_formats_produce_same_result(self): + result_dict = point_schema({CONF_X: "50%", CONF_Y: "75%"}) + result_list = point_schema(["50%", "75%"]) + result_string = point_schema("50%, 75%") + + assert result_dict == result_list + assert result_dict == result_string + + def test_pixel_suffix_matches_plain_integer(self): + result_px = point_schema({CONF_X: "10px", CONF_Y: "20px"}) + result_int = point_schema({CONF_X: 10, CONF_Y: 20}) + + assert result_px == result_int + + @pytest.mark.parametrize( + "value", + [ + {CONF_X: 50, CONF_Y: 75}, + [50, 75], + "50, 75", + ], + ) + def test_output_contains_x_and_y(self, value): + result = point_schema(value) + + assert CONF_X in result + assert CONF_Y in result + + def test_list_wrong_length_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema([1]) + + with pytest.raises(Invalid, match="Invalid point"): + point_schema([1, 2, 3]) + + def test_string_without_comma_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema("garbage") + + def test_string_extra_commas_raises(self): + with pytest.raises(Invalid, match="Invalid point"): + point_schema("1,2,3") + + +# --------------------------------------------------------------------------- +# Code generation: different point formats produce identical C++ output +# --------------------------------------------------------------------------- + +_SET_POINTS_RE = re.compile(r"(\w+)->set_points\((.+?)\);") + + +def _extract_set_points(main_cpp: str) -> dict[str, str]: + """Return {var_name: args_text} for every set_points() call found.""" + return {m.group(1): m.group(2) for m in _SET_POINTS_RE.finditer(main_cpp)} + + +class TestLineCodeGeneration: + """Verify that alternative point formats generate identical C++ code.""" + + @pytest.fixture() + def main_cpp(self, generate_main, component_config_path) -> str: + return generate_main(component_config_path("line_points.yaml")) + + @pytest.fixture() + def set_points_calls(self, main_cpp) -> dict[str, str]: + return _extract_set_points(main_cpp) + + def test_integer_points_all_formats_match(self, set_points_calls): + """Dict, list, and string formats with integer points produce same set_points call.""" + assert set_points_calls["line_dict"] == set_points_calls["line_list"] + assert set_points_calls["line_dict"] == set_points_calls["line_string"] + + def test_percentage_points_all_formats_match(self, set_points_calls): + """Dict, list, and string formats with percentage points produce same set_points call.""" + assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_list"] + assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_string"] + + def test_mixed_points_formats_match(self, set_points_calls): + """Dict and list formats with mixed int/percent points produce same set_points call.""" + assert ( + set_points_calls["line_mixed_dict"] == set_points_calls["line_mixed_list"] + ) + + def test_integer_points_contain_expected_values(self, set_points_calls): + """Integer points appear literally in the generated code.""" + args = set_points_calls["line_dict"] + for val in ("10", "20", "100", "200"): + assert val in args + + def test_percentage_points_use_lv_pct(self, set_points_calls): + """Percentage points are generated using the lv_pct() macro.""" + args = set_points_calls["line_pct_dict"] + assert "lv_pct(50)" in args + assert "lv_pct(75)" in args + + def test_all_lines_present(self, set_points_calls): + """All expected line IDs have a set_points call.""" + expected = { + "line_dict", + "line_list", + "line_string", + "line_pct_dict", + "line_pct_list", + "line_pct_string", + "line_mixed_dict", + "line_mixed_list", + } + assert expected.issubset(set_points_calls.keys()) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 9c4ad4bbf8..39d7472054 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1038,7 +1038,10 @@ lvgl: - 5, 5 - x: !lambda return random_uint32() % 100; y: !lambda return random_uint32() % 100; - - 70, 70 + - x: 10% + y: 50% + - 70%, 70% + - [75%, 75%] - 120, 10 - 180, 60 - 240, 10