mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 00:18:32 +00:00
Merge branch 'esp8266-native-framework-installer' into esp8266-native-library-backend
This commit is contained in:
@@ -182,8 +182,6 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Modest cap so this smoke test leaves room on the shared runner pool.
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
|
||||
@@ -946,7 +946,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }}
|
||||
matrix:
|
||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||
steps:
|
||||
|
||||
@@ -525,6 +525,52 @@ void IndicatorLine::update_length_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_LVGL_TABLE
|
||||
uint32_t lv_table_get_selected_row(lv_obj_t *obj) {
|
||||
uint32_t row;
|
||||
uint32_t column;
|
||||
lv_table_get_selected_cell(obj, &row, &column);
|
||||
return row;
|
||||
}
|
||||
|
||||
uint32_t lv_table_get_selected_column(lv_obj_t *obj) {
|
||||
uint32_t row;
|
||||
uint32_t column;
|
||||
lv_table_get_selected_cell(obj, &row, &column);
|
||||
return column;
|
||||
}
|
||||
|
||||
void LvTableType::set_obj(lv_obj_t *lv_obj) {
|
||||
LvCompound::set_obj(lv_obj);
|
||||
lv_obj_add_event_cb(
|
||||
lv_obj,
|
||||
[](lv_event_t *e) {
|
||||
auto *table = static_cast<LvTableType *>(lv_event_get_user_data(e));
|
||||
table->update_column_widths_();
|
||||
},
|
||||
LV_EVENT_SIZE_CHANGED, this);
|
||||
}
|
||||
|
||||
void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) {
|
||||
for (auto &i : this->column_pct_) {
|
||||
if (i.col == col) {
|
||||
i.pct = pct;
|
||||
this->update_column_widths_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->column_pct_.push_back({col, pct});
|
||||
this->update_column_widths_();
|
||||
}
|
||||
|
||||
void LvTableType::update_column_widths_() {
|
||||
auto content_width = lv_obj_get_content_width(this->obj);
|
||||
for (const auto &col : this->column_pct_) {
|
||||
lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100);
|
||||
}
|
||||
}
|
||||
#endif // USE_LVGL_TABLE
|
||||
|
||||
#ifdef USE_LVGL_KEY_LISTENER
|
||||
LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) {
|
||||
this->drv_ = lv_indev_create();
|
||||
|
||||
@@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent);
|
||||
void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start,
|
||||
lv_color_t color_end, int width, bool local);
|
||||
#endif
|
||||
#ifdef USE_LVGL_TABLE
|
||||
uint32_t lv_table_get_selected_row(lv_obj_t *obj);
|
||||
uint32_t lv_table_get_selected_column(lv_obj_t *obj);
|
||||
#endif
|
||||
#if LV_COLOR_DEPTH == 16
|
||||
static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565;
|
||||
#elif LV_COLOR_DEPTH == 32
|
||||
@@ -511,6 +515,27 @@ class LvLineType : public LvCompound {
|
||||
FixedVector<lv_point_precise_t> points_{};
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_LVGL_TABLE
|
||||
// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel
|
||||
// count, so percentage column widths must be recomputed by hand whenever the table's own
|
||||
// content width changes.
|
||||
class LvTableType : public LvCompound {
|
||||
public:
|
||||
void set_obj(lv_obj_t *lv_obj) override;
|
||||
// count is the number of percentage-width columns, known at code-generation time.
|
||||
void init_column_pct(size_t count) { this->column_pct_.init(count); }
|
||||
void add_column_width_pct(uint32_t col, uint8_t pct);
|
||||
|
||||
protected:
|
||||
void update_column_widths_();
|
||||
|
||||
struct ColumnPct {
|
||||
uint32_t col;
|
||||
uint8_t pct;
|
||||
};
|
||||
FixedVector<ColumnPct> column_pct_{};
|
||||
};
|
||||
#endif // USE_LVGL_TABLE
|
||||
#if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER)
|
||||
class LvSelectable : public LvCompound {
|
||||
public:
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
from contextlib import ExitStack
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ROWS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||||
from esphome.types import ConfigFragmentType, ConfigType, SafeExpType
|
||||
|
||||
from ..automation import action_to_code
|
||||
from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal
|
||||
from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator
|
||||
from ..lvcode import LocalVariable, lv, lv_add, lv_expr
|
||||
from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .label import CONF_LABEL
|
||||
|
||||
CONF_TABLE = "table"
|
||||
CONF_CELLS = "cells"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_ROW_COUNT = "row_count"
|
||||
CONF_COLUMN_COUNT = "column_count"
|
||||
CONF_MERGE_RIGHT = "merge_right"
|
||||
CONF_TEXT_CROP = "text_crop"
|
||||
CONF_SELECTED_ROW = "selected_row"
|
||||
CONF_SELECTED_COLUMN = "selected_column"
|
||||
|
||||
CELL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_TEXT, default=""): lv_text,
|
||||
# Not templatable: the value selects between two different LVGL calls
|
||||
# (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call.
|
||||
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
|
||||
cv.Optional(CONF_TEXT_CROP): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
# A cell can be given as a bare piece of text, or a dict for more control
|
||||
TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT)
|
||||
|
||||
# A row can be given as a bare list of cells, or a dict for future extension
|
||||
ROW_SCHEMA = cv.maybe_simple_value(
|
||||
cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}),
|
||||
key=CONF_CELLS,
|
||||
)
|
||||
|
||||
|
||||
def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]:
|
||||
"""Like pixels_or_percent, but rejects negative widths, which would
|
||||
defeat the 100%-total check and wrap around in the generated uint8_t pct."""
|
||||
if value == SCHEMA_EXTRACT:
|
||||
return ["pixels", "..%"]
|
||||
return cv.Any(pixels_validator, cv.percentage)(value)
|
||||
|
||||
|
||||
column_width = LValidator(
|
||||
_column_width_validator,
|
||||
lv_coord_t,
|
||||
retmapper=pixels_or_percent.retmapper,
|
||||
animatable=True,
|
||||
)
|
||||
|
||||
COLUMN_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WIDTH): column_width,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_table(config: ConfigType) -> ConfigType:
|
||||
rows = config.get(CONF_ROWS)
|
||||
min_row_count = len(rows) if rows else 0
|
||||
min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0
|
||||
row_count = config.get(CONF_ROW_COUNT)
|
||||
if row_count is not None and row_count < min_row_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows",
|
||||
path=[CONF_ROW_COUNT],
|
||||
)
|
||||
column_count = config.get(CONF_COLUMN_COUNT)
|
||||
if column_count is not None and column_count < min_column_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row",
|
||||
path=[CONF_COLUMN_COUNT],
|
||||
)
|
||||
column_count = column_count if column_count is not None else min_column_count
|
||||
columns = config.get(CONF_COLUMNS)
|
||||
if columns and column_count and len(columns) > column_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}",
|
||||
path=[CONF_COLUMNS],
|
||||
)
|
||||
total_pct = sum(
|
||||
width
|
||||
for column in columns or ()
|
||||
if isinstance((width := column.get(CONF_WIDTH)), float)
|
||||
)
|
||||
if total_pct > 1.0:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%",
|
||||
path=[CONF_COLUMNS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
TABLE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA),
|
||||
cv.Optional(CONF_ROW_COUNT): cv.positive_int,
|
||||
cv.Optional(CONF_COLUMN_COUNT): cv.positive_int,
|
||||
cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA),
|
||||
cv.Optional(CONF_SELECTED_ROW): lv_int,
|
||||
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
|
||||
}
|
||||
).add_extra(_validate_table)
|
||||
|
||||
lv_table_t = LvType(
|
||||
"LvTableType",
|
||||
parents=(LvCompound,),
|
||||
largs=[(cg.uint32, "row"), (cg.uint32, "column")],
|
||||
lvalue=lambda w: [
|
||||
lv_expr.table_get_selected_row(w.obj),
|
||||
lv_expr.table_get_selected_column(w.obj),
|
||||
],
|
||||
has_on_value=True,
|
||||
)
|
||||
|
||||
|
||||
async def set_cell_ctrl(
|
||||
w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType
|
||||
) -> None:
|
||||
for key, ctrl in (
|
||||
(CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"),
|
||||
(CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"),
|
||||
):
|
||||
if key not in cell:
|
||||
continue
|
||||
if cell[key]:
|
||||
lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl))
|
||||
else:
|
||||
lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl))
|
||||
|
||||
|
||||
async def set_selected_cell(w: Widget, config: ConfigType) -> None:
|
||||
selected_row = config.get(CONF_SELECTED_ROW)
|
||||
selected_column = config.get(CONF_SELECTED_COLUMN)
|
||||
if selected_row is None and selected_column is None:
|
||||
return
|
||||
# LV_TABLE_CELL_NONE selects the whole column/row when only one index is given
|
||||
row_value = (
|
||||
await lv_int.process(selected_row)
|
||||
if selected_row is not None
|
||||
else literal("LV_TABLE_CELL_NONE")
|
||||
)
|
||||
column_value = (
|
||||
await lv_int.process(selected_column)
|
||||
if selected_column is not None
|
||||
else literal("LV_TABLE_CELL_NONE")
|
||||
)
|
||||
lv.table_set_selected_cell(w.obj, row_value, column_value)
|
||||
|
||||
|
||||
TABLE_MODIFY_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SELECTED_ROW): lv_int,
|
||||
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TableType(WidgetType):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
CONF_TABLE,
|
||||
lv_table_t,
|
||||
(CONF_MAIN, CONF_ITEMS),
|
||||
TABLE_SCHEMA,
|
||||
modify_schema=TABLE_MODIFY_SCHEMA,
|
||||
)
|
||||
|
||||
def get_uses(self) -> tuple[str]:
|
||||
return (CONF_LABEL,)
|
||||
|
||||
async def to_code(self, w: Widget, config: dict) -> None:
|
||||
rows = config.get(CONF_ROWS)
|
||||
row_count = config.get(CONF_ROW_COUNT)
|
||||
column_count = config.get(CONF_COLUMN_COUNT)
|
||||
if rows is not None:
|
||||
if row_count is None:
|
||||
row_count = len(rows)
|
||||
if column_count is None:
|
||||
column_count = max((len(row[CONF_CELLS]) for row in rows), default=0)
|
||||
if row_count is not None:
|
||||
lv.table_set_row_count(w.obj, row_count)
|
||||
if column_count is not None:
|
||||
lv.table_set_column_count(w.obj, column_count)
|
||||
columns = config.get(CONF_COLUMNS, ())
|
||||
pct_column_count = sum(
|
||||
1 for column in columns if isinstance(column.get(CONF_WIDTH), float)
|
||||
)
|
||||
if pct_column_count:
|
||||
lv_add(w.var.init_column_pct(pct_column_count))
|
||||
for index, column in enumerate(columns):
|
||||
if (width := column.get(CONF_WIDTH)) is None:
|
||||
continue
|
||||
if isinstance(width, float):
|
||||
# A percentage: column_width validation leaves it as a 0.0-1.0
|
||||
# fraction. LVGL's table widget only accepts a literal pixel width, so
|
||||
# the actual width is recomputed at runtime from the table's own size.
|
||||
lv_add(w.var.add_column_width_pct(index, round(width * 100)))
|
||||
else:
|
||||
lv.table_set_column_width(
|
||||
w.obj, index, await column_width.process(width)
|
||||
)
|
||||
for row_index, row in enumerate(rows or ()):
|
||||
for column_index, cell in enumerate(row[CONF_CELLS]):
|
||||
lv.table_set_cell_value(
|
||||
w.obj,
|
||||
row_index,
|
||||
column_index,
|
||||
await lv_text.process(cell[CONF_TEXT]),
|
||||
)
|
||||
await set_cell_ctrl(w, row_index, column_index, cell)
|
||||
await set_selected_cell(w, config)
|
||||
|
||||
|
||||
table_spec = TableType()
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.table.cell.update",
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(lv_table_t),
|
||||
cv.Required(CONF_ROW): lv_int,
|
||||
cv.Required(CONF_COLUMN): lv_int,
|
||||
cv.Optional(CONF_TEXT): lv_text,
|
||||
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
|
||||
cv.Optional(CONF_TEXT_CROP): cv.boolean,
|
||||
}
|
||||
).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)),
|
||||
synchronous=True,
|
||||
)
|
||||
async def table_cell_update_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
widgets = await get_widgets(config)
|
||||
|
||||
async def do_update(w: Widget):
|
||||
row = await lv_int.process(config[CONF_ROW])
|
||||
column = await lv_int.process(config[CONF_COLUMN])
|
||||
fields_set = sum(
|
||||
key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)
|
||||
)
|
||||
with ExitStack() as stack:
|
||||
if fields_set > 1:
|
||||
# row/column feed more than one generated call below: cache them in
|
||||
# local variables so a !lambda value is only evaluated once.
|
||||
row = stack.enter_context(
|
||||
LocalVariable("row", cg.int_, row, modifier="")
|
||||
)
|
||||
column = stack.enter_context(
|
||||
LocalVariable("column", cg.int_, column, modifier="")
|
||||
)
|
||||
if CONF_TEXT in config:
|
||||
lv.table_set_cell_value(
|
||||
w.obj, row, column, await lv_text.process(config[CONF_TEXT])
|
||||
)
|
||||
await set_cell_ctrl(w, row, column, config)
|
||||
|
||||
return await action_to_code(
|
||||
widgets, do_update, action_id, template_arg, args, config
|
||||
)
|
||||
@@ -1181,6 +1181,38 @@ lvgl:
|
||||
- logger.log:
|
||||
format: "bar value %f"
|
||||
args: [x]
|
||||
- table:
|
||||
id: table_id
|
||||
align: top_mid
|
||||
y: 60
|
||||
columns:
|
||||
- width: 40%
|
||||
- width: 80
|
||||
rows:
|
||||
- ["Name", "Value"]
|
||||
- cells:
|
||||
- text: "Temp"
|
||||
merge_right: true
|
||||
- text: "22.5"
|
||||
text_crop: true
|
||||
selected_row: 0
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "table selected row %u col %u"
|
||||
args: [row, column]
|
||||
on_click:
|
||||
then:
|
||||
- lvgl.table.cell.update:
|
||||
id: table_id
|
||||
row: 1
|
||||
column: 1
|
||||
text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100);
|
||||
merge_right: false
|
||||
- lvgl.table.update:
|
||||
id: table_id
|
||||
selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2);
|
||||
selected_column: 0
|
||||
- line:
|
||||
id: lv_line_id
|
||||
align: center
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for the LVGL table widget's C++ code generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.defines import set_widgets_completed
|
||||
from esphome.components.lvgl.lvcode import LvContext
|
||||
from esphome.components.lvgl.schemas import container_schema
|
||||
from esphome.components.lvgl.trigger import generate_triggers
|
||||
from esphome.components.lvgl.widgets import Widget, widget_to_code
|
||||
from esphome.components.lvgl.widgets.table import table_spec
|
||||
from esphome.const import (
|
||||
CONF_AUTOMATION_ID,
|
||||
CONF_ON_VALUE,
|
||||
CONF_THEN,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArguments
|
||||
from esphome.yaml_util import make_data_base
|
||||
|
||||
|
||||
async def _create_table(raw_config: dict) -> Widget:
|
||||
"""Validate `raw_config` as a table widget and generate its creation code."""
|
||||
config = container_schema(table_spec)(raw_config)
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
return await widget_to_code(config, table_spec, parent)
|
||||
|
||||
|
||||
def _statements() -> list[str]:
|
||||
return [str(s) for s in CORE.main_statements]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_row_and_column_count(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements)
|
||||
assert any(
|
||||
"lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_writes_cell_values(setup_core) -> None:
|
||||
await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_sets_cell_control_flags(setup_core) -> None:
|
||||
await _create_table(
|
||||
{
|
||||
"id": "table_ctrl",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{"text": "wide", "merge_right": True},
|
||||
{"text": "cropped", "text_crop": True},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
assert any(
|
||||
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)"
|
||||
in s
|
||||
for s in statements
|
||||
)
|
||||
# text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted.
|
||||
assert not any(
|
||||
"table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None:
|
||||
await _create_table({"id": "table_px", "columns": [{"width": 96}]})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None:
|
||||
"""Regression test: lv_table_set_column_width() only accepts a literal
|
||||
pixel count, so a percentage width must not be passed to it directly -
|
||||
it has to go through the LvTableType helper that recomputes it at
|
||||
runtime from the table's actual content width.
|
||||
"""
|
||||
await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]})
|
||||
statements = _statements()
|
||||
assert any("table_pct->init_column_pct(1)" in s for s in statements)
|
||||
assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements)
|
||||
assert not any(
|
||||
"lv_table_set_column_width(table_pct->obj, 0" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_both_indices(setup_core) -> None:
|
||||
await _create_table(
|
||||
{"id": "table_sel_both", "selected_row": 1, "selected_column": 2}
|
||||
)
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None:
|
||||
await _create_table({"id": "table_sel_row", "selected_row": 1})
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s
|
||||
for s in statements
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_cell_omitted_entirely_when_not_configured(
|
||||
setup_core,
|
||||
) -> None:
|
||||
await _create_table({"id": "table_no_selection", "rows": [["a"]]})
|
||||
statements = _statements()
|
||||
assert not any("lv_table_set_selected_cell" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
|
||||
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
|
||||
set_widgets_completed(True)
|
||||
# Only inspect statements emitted by the action below, not by creation.
|
||||
before = len(_statements())
|
||||
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "table_update", "row": 1, "column": 1, "text": "new value"}
|
||||
)
|
||||
action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id)
|
||||
await entry.coroutine_fun(config, action_id, TemplateArguments(), [])
|
||||
|
||||
statements = _statements()[before:]
|
||||
assert any(
|
||||
'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s
|
||||
for s in statements
|
||||
)
|
||||
# Neither control flag was specified, so neither call should be emitted.
|
||||
assert not any("LV_TABLE_CELL_CTRL" in s for s in statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None:
|
||||
config = container_schema(table_spec)(
|
||||
{
|
||||
"id": "table_on_value",
|
||||
"rows": [["a"]],
|
||||
"on_value": [
|
||||
{"lambda": make_data_base("id(table_on_value).get_selected_row();")}
|
||||
],
|
||||
}
|
||||
)
|
||||
# Auto-generated IDs (trigger/automation/action) are normally resolved to
|
||||
# unique names by esphome's full config pass before code generation; do
|
||||
# that by hand here since this test only exercises the widget/trigger
|
||||
# codegen slice in isolation.
|
||||
automation_conf = config[CONF_ON_VALUE][0]
|
||||
automation_conf[CONF_TRIGGER_ID].resolve([])
|
||||
automation_conf[CONF_AUTOMATION_ID].resolve([])
|
||||
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
|
||||
|
||||
parent = MockObj("parent_obj")
|
||||
async with LvContext():
|
||||
await widget_to_code(config, table_spec, parent)
|
||||
set_widgets_completed(True)
|
||||
await generate_triggers()
|
||||
|
||||
statements = _statements()
|
||||
assert any(
|
||||
"table_on_value->obj" in s
|
||||
and "add_event_cb" in s
|
||||
and "LV_EVENT_VALUE_CHANGED" in s
|
||||
for s in statements
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the LVGL table widget's configuration validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
from esphome.components.lvgl.widgets.table import (
|
||||
CONF_MERGE_RIGHT,
|
||||
CONF_TEXT_CROP,
|
||||
TABLE_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_config_is_valid() -> None:
|
||||
assert TABLE_SCHEMA({}) == {}
|
||||
|
||||
|
||||
def test_row_shorthand_expands_to_plain_cells() -> None:
|
||||
config = TABLE_SCHEMA({"rows": [["Name", "Value"]]})
|
||||
[row] = config["rows"]
|
||||
assert row["cells"] == [{"text": "Name"}, {"text": "Value"}]
|
||||
|
||||
|
||||
def test_row_dict_form_with_cell_overrides() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"Temp",
|
||||
{"text": "22.5", "text_crop": True, "merge_right": True},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
[row] = config["rows"]
|
||||
assert row["cells"][0] == {"text": "Temp"}
|
||||
assert row["cells"][1] == {
|
||||
"text": "22.5",
|
||||
"merge_right": True,
|
||||
"text_crop": True,
|
||||
}
|
||||
|
||||
|
||||
def test_row_count_defaults_are_not_injected_by_the_schema() -> None:
|
||||
# Inference of row/column counts from `rows` happens at code generation
|
||||
# time, not during validation - the schema should leave them unset.
|
||||
config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]})
|
||||
assert "row_count" not in config
|
||||
assert "column_count" not in config
|
||||
|
||||
|
||||
def test_explicit_row_and_column_count_are_kept() -> None:
|
||||
config = TABLE_SCHEMA({"row_count": 5, "column_count": 3})
|
||||
assert config["row_count"] == 5
|
||||
assert config["column_count"] == 3
|
||||
|
||||
|
||||
def test_row_count_too_small_for_given_rows_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="row_count"):
|
||||
TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2})
|
||||
|
||||
|
||||
def test_column_count_too_small_for_given_cells_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="column_count"):
|
||||
TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2})
|
||||
|
||||
|
||||
def test_columns_list_longer_than_column_count_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA(
|
||||
{
|
||||
"column_count": 1,
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_columns_list_matching_inferred_column_count_is_valid() -> None:
|
||||
config = TABLE_SCHEMA(
|
||||
{
|
||||
"rows": [["a", "b"]],
|
||||
"columns": [{"width": 10}, {"width": 20}],
|
||||
}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [10, 20]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "expected"),
|
||||
[
|
||||
(100, 100),
|
||||
("50%", 0.5),
|
||||
("32px", 32),
|
||||
],
|
||||
)
|
||||
def test_column_width_accepts_pixels_and_percent(width, expected) -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": width}]})
|
||||
assert config["columns"][0]["width"] == expected
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_over_100_percent_raises() -> None:
|
||||
with pytest.raises(cv.Invalid, match="columns"):
|
||||
TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]})
|
||||
|
||||
|
||||
def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None:
|
||||
config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]})
|
||||
assert [c["width"] for c in config["columns"]] == [0.6, 0.4]
|
||||
|
||||
|
||||
def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None:
|
||||
# Pixel widths aren't part of the percentage budget, so they shouldn't
|
||||
# count towards the 100% limit.
|
||||
config = TABLE_SCHEMA(
|
||||
{"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]}
|
||||
)
|
||||
assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2]
|
||||
|
||||
|
||||
def test_selected_row_and_selected_column_are_independently_optional() -> None:
|
||||
config = TABLE_SCHEMA({"selected_row": 1})
|
||||
assert config["selected_row"] == 1
|
||||
assert "selected_column" not in config
|
||||
|
||||
|
||||
def test_cell_update_action_requires_at_least_one_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
with pytest.raises(cv.Invalid):
|
||||
entry.schema({"id": "some_table", "row": 0, "column": 0})
|
||||
|
||||
|
||||
def test_cell_update_action_accepts_a_single_field() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
|
||||
config = entry.schema(
|
||||
{"id": "some_table", "row": 0, "column": 0, "merge_right": True}
|
||||
)
|
||||
assert config[CONF_MERGE_RIGHT] is True
|
||||
assert CONF_TEXT_CROP not in config
|
||||
Reference in New Issue
Block a user