mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 13:27:14 +00:00
[lvgl] Allow line points as percentages (#16209)
This commit is contained in:
@@ -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)})"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 <x_int>, <y_int>")
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
84
tests/component_tests/lvgl/config/line_points.yaml
Normal file
84
tests/component_tests/lvgl/config/line_points.yaml
Normal file
@@ -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]
|
||||
147
tests/component_tests/lvgl/test_line.py
Normal file
147
tests/component_tests/lvgl/test_line.py
Normal file
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user