import sys from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action from esphome.config_validation import Invalid, Schema from esphome.const import ( CONF_DEFAULT, CONF_GROUP, CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_STATE, CONF_TYPE, ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj from ..defines import ( CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, CONF_FLEX_FLOW, CONF_GRID_COLUMN_ALIGN, CONF_GRID_COLUMNS, CONF_GRID_ROW_ALIGN, CONF_GRID_ROWS, CONF_LAYOUT, CONF_MAIN, CONF_PAD_COLUMN, CONF_PAD_ROW, CONF_SCALE, CONF_STYLES, CONF_WIDGETS, OBJ_FLAGS, PARTS, STATES, TYPE_FLEX, TYPE_GRID, LValidator, call_lambda, join_enums, literal, ) from ..helpers import add_lv_use from ..lvcode import ( LvConditional, add_line_marks, lv, lv_add, lv_assign, lv_expr, lv_obj, lv_Pvariable, ) from ..types import ( LV_STATE, LvCompound, LvType, ObjUpdateAction, lv_coord_t, lv_obj_t, lv_obj_t_ptr, ) EVENT_LAMB = "event_lamb__" theme_widget_map = {} styles_used = set() class WidgetType: """ Describes a type of Widget, e.g. "bar" or "line" """ def __init__( self, name: str, w_type: LvType, parts: tuple, schema=None, modify_schema=None, lv_name=None, is_mock: bool = False, ): """ :param name: The widget name, e.g. "bar" :param w_type: The C type of the widget :param parts: What parts this widget supports :param schema: The config schema for defining a widget :param modify_schema: A schema to update the widget, defaults to the same as the schema :param lv_name: The name of the LVGL widget in the LVGL library, if different from the name :param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget """ self.name = name self.lv_name = lv_name or name self.w_type = w_type self.parts = parts if not isinstance(schema, Schema): schema = Schema(schema or {}) self.schema = schema if modify_schema is None: modify_schema = schema if not isinstance(modify_schema, Schema): modify_schema = Schema(modify_schema) self.modify_schema = modify_schema self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") # Local import to avoid circular import from ..automation import update_to_code from ..schemas import WIDGET_TYPES, base_update_schema if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, base_update_schema(self, self.parts).extend(self.modify_schema), synchronous=True, )(update_to_code) @property def animated(self): return False @property def required_component(self): return None def is_compound(self): return self.w_type.inherits_from(LvCompound) async def create_to_code(self, config: dict, parent: MockObj) -> "Widget": """ Generate code for a widget creation. :param config: The configuration for the widget :param parent: The parent to which it should be attached """ creator = await self.obj_creator(parent, config) add_lv_use(self.name) add_lv_use(*self.get_uses()) wid = config[CONF_ID] add_line_marks(wid) if self.is_compound(): var = cg.new_Pvariable(wid) lv_add(var.set_obj(creator)) await self.on_create(var.obj, config) else: var = lv_Pvariable(lv_obj_t, wid) lv_assign(var, creator) await self.on_create(var, config) w = Widget.create(wid, var, self, config) if theme := theme_widget_map.get(self.name): for part, states in theme.items(): part = "LV_PART_" + part.upper() for state, style in states.items(): state = "LV_STATE_" + state.upper() if state == "LV_STATE_DEFAULT": lv_state = literal(part) elif part == "LV_PART_MAIN": lv_state = literal(state) else: lv_state = join_enums((state, part)) w.add_style(style, lv_state) await set_obj_properties(w, config) await add_widgets(w, config) await self.to_code(w, config) return w async def to_code(self, w: "Widget", config: dict): """ Update a widget, also called when creating :param config: :return: """ async def obj_creator(self, parent: MockObj, config: dict): """ Create an instance of the widget type :param parent: The parent to which it should be attached :param config: Its configuration :return: Generated code as a single text line """ return lv_expr.call(f"{self.lv_name}_create", parent) async def on_create(self, var: MockObj, config: dict): """ Called from to_code when the widget is created, to set up any initial properties :param var: The variable representing the widget :param config: Its configuration """ def get_uses(self): """ Get a list of other widgets used by this one :return: """ return () def get_max(self, config: dict): return sys.maxsize def get_min(self, config: dict): return -sys.maxsize def get_step(self, config: dict): return 1 def get_scale(self, config: dict): return 1.0 def validate(self, value): """ Provides an opportunity for custom validation for a given widget type :param value: :return: """ return value def final_validate(self, widget, update_config, widget_config, path): """ Allow final validation for a given widget type update action :param widget: A widget :param update_config: The configuration for the update action :param widget_config: The configuration for the widget itself :param path: The path to the widget, for error reporting """ class Widget: """ Represents a Widget. This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ widgets_completed = False def __init__(self, var, wtype: WidgetType, config: dict = None): self.var = var self.type = wtype self.config = config self.scale = 1.0 self.range_from = -sys.maxsize self.range_to = sys.maxsize if wtype.is_compound(): self.obj = MockObj(f"{self.var}->obj") else: self.obj = var self.outer = None self.move_to_foreground = False # Properties for linear equations self.slope = None self.y_int = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): w = Widget(var, wtype, config) widget_map[name] = w return w def add_state(self, state): if "|" in state: state = f"(lv_state_t)({state})" return lv_obj.add_state(self.obj, literal(state)) def clear_state(self, state): if "|" in state: state = f"(lv_state_t)({state})" return lv_obj.remove_state(self.obj, literal(state)) def has_state(self, state): return (lv_expr.obj_get_state(self.obj) & literal(state)) != 0 def is_pressed(self): return self.has_state(LV_STATE.PRESSED) def is_checked(self): return self.has_state(LV_STATE.CHECKED) def add_flag(self, flag): if "|" in flag: flag = f"(lv_obj_flag_t)({flag})" return lv_obj.add_flag(self.obj, literal(flag)) def clear_flag(self, flag): if "|" in flag: flag = f"(lv_obj_flag_t)({flag})" return lv_obj.remove_flag(self.obj, literal(flag)) def add_style(self, style_id, state=LV_STATE.DEFAULT): if "|" in state: state = f"(lv_state_t)({state})" lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( self, prop, value, animated: bool = None, lv_name=None, processor=None ): """ Set a property of the widget. :param prop: The property name :param value: The value :param animated: If the change should be animated :param lv_name: The base type of the widget e.g. "obj" """ from ..schemas import ALL_STYLES, remap_property if isinstance(value, dict): value = value.get(prop) if value is None: return if not processor and isinstance(ALL_STYLES.get(prop), LValidator): processor = ALL_STYLES[prop] if isinstance(processor, LValidator): processor = processor.process if processor: value = await processor(value) elif value is None: return prop = remap_property(prop) if isinstance(value, TimePeriod): value = value.total_milliseconds elif isinstance(value, str): value = literal(value) elif isinstance(value, ID): value = MockObj(value) lv_name = lv_name or self.type.lv_name if animated is None or self.type.animated is not True: lv.call(f"{lv_name}_set_{prop}", self.obj, value) else: lv.call( f"{lv_name}_set_{prop}", self.obj, value, literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) def get_property(self, prop, ltype=None): ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") def set_style(self, prop, value, state=LV_STATE.DEFAULT): if value is None: return styles_used.add(prop) if isinstance(value, str): value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) def __type_base(self): wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") def __str__(self): return f"({self.var}, {self.type})" def get_args(self): if isinstance(self.type.w_type, LvType): return self.type.w_type.args return [(lv_obj_t_ptr, "obj")] def get_value(self): if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): return result[0] return result return self.obj def get_values(self): if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): return result return [result] return [self.obj] def get_number_value(self): value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) def is_selected(self): """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons :return: """ return def get_scale(self): return self.type.get_scale(self.config) # Map of widgets to their config, used for trigger generation widget_map: dict[ID, Widget] = {} def is_widget_completed(name: ID) -> bool: return name in widget_map class LvScrActType(WidgetType): """ A "widget" representing the active screen. """ def __init__(self): super().__init__("lv_screen_active()", lv_obj_t, (), is_mock=True) async def to_code(self, w, config: dict): pass def get_screen_active(lv_comp: MockObj) -> Widget: return Widget(lv_comp.get_screen_active(), LvScrActType(), {}) def get_widget_generator(wid): """ Used to wait for a widget during code generation. :param wid: :return: """ while True: if obj := widget_map.get(wid): return obj if Widget.widgets_completed: raise Invalid( f"Widget {wid} not found, yet all widgets should be defined by now" ) yield async def get_widget_(wid): if obj := widget_map.get(wid): return obj return await FakeAwaitable(get_widget_generator(wid)) def widgets_wait_generator(): while True: if Widget.widgets_completed: return yield async def wait_for_widgets(): if Widget.widgets_completed: return await FakeAwaitable(widgets_wait_generator()) async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]: if not config: return [] if not isinstance(config, list): config = [config] return [await get_widget_(c[id]) for c in config if id in c] def collect_props(config): """ Collect all properties from a configuration :param config: :return: """ from ..schemas import ALL_STYLES props = {} for prop in [*ALL_STYLES, *OBJ_FLAGS, CONF_STYLES, CONF_GROUP]: if prop in config: if prop == CONF_SCALE: props[CONF_SCALE + "_x"] = config[prop] props[CONF_SCALE + "_y"] = config[prop] else: props[prop] = config[prop] return props def collect_states(config): """ Collect prperties for each state of a widget :param config: :return: """ states = {CONF_DEFAULT: collect_props(config)} for state in STATES: if state in config: states[state] = collect_props(config[state]) return states def collect_parts(config): """ Collect properties and states for all widget parts :param config: :return: """ parts = {CONF_MAIN: collect_states(config)} for part in PARTS: if part in config: parts[part] = collect_states(config[part]) return parts def _size_to_str(value): if isinstance(value, float): return f"lv_pct({int(value * 100)})" return str(value) async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property if layout := config.get(CONF_LAYOUT): layout_type: str = layout[CONF_TYPE] add_lv_use(layout_type) lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) if (pad_row := layout.get(CONF_PAD_ROW)) is not None: w.set_style(CONF_PAD_ROW, pad_row) if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: w.set_style(CONF_PAD_COLUMN, pad_column) if layout_type == TYPE_GRID: wid = config[CONF_ID] rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) w.set_style("grid_row_dsc_array", row_array) columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) w.set_style("grid_column_dsc_array", column_array) w.set_style( CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) ) w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) if layout_type == TYPE_FLEX: lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) main = literal(layout[CONF_FLEX_ALIGN_MAIN]) cross = layout[CONF_FLEX_ALIGN_CROSS] if cross == "LV_FLEX_ALIGN_STRETCH": cross = "LV_FLEX_ALIGN_CENTER" cross = literal(cross) track = literal(layout[CONF_FLEX_ALIGN_TRACK]) lv_obj.set_flex_align(w.obj, main, cross, track) parts = collect_parts(config) for part, states in parts.items(): part = "LV_PART_" + part.upper() for state, props in states.items(): state = "LV_STATE_" + state.upper() if state == "LV_STATE_DEFAULT": lv_state = literal(part) elif part == "LV_PART_MAIN": lv_state = literal(state) else: lv_state = join_enums((state, part)) for style_id in props.get(CONF_STYLES, ()): w.add_style(style_id, lv_state) for prop, value in { k: v for k, v in props.items() if k in ALL_STYLES }.items(): if isinstance(ALL_STYLES[prop], LValidator): value = await ALL_STYLES[prop].process(value) prop_r = remap_property(prop) w.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) props = parts[CONF_MAIN][CONF_DEFAULT] lambs = {} flag_set = set() flag_clr = set() for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): if isinstance(value, cv.Lambda): lambs[prop] = value elif value: flag_set.add(prop) else: flag_clr.add(prop) if flag_set: adds = join_enums(flag_set, "LV_OBJ_FLAG_") w.add_flag(adds) if flag_clr: clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") w.clear_flag(clrs) for key, value in lambs.items(): lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) flag = f"LV_OBJ_FLAG_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_flag(flag) cond.else_() w.clear_flag(flag) if states := config.get(CONF_STATE): adds = set() clears = set() lambs = {} for key, value in states.items(): if isinstance(value, cv.Lambda): lambs[key] = value elif value: adds.add(key) else: clears.add(key) if adds: adds = join_enums(adds, "LV_STATE_") w.add_state(adds) if clears: clears = join_enums(clears, "LV_STATE_") w.clear_state(clears) for key, value in lambs.items(): lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) state = f"LV_STATE_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_state(state) cond.else_() w.clear_state(state) for property in OBJ_PROPERTIES: await w.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): """ Add all widgets to an object :param parent: The enclosing obj :param config: The configuration :return: """ for w in config.get(CONF_WIDGETS, ()): w_type, w_cnfig = next(iter(w.items())) await widget_to_code(w_cnfig, w_type, parent.obj) async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget: """ Converts a Widget definition to C code. :param w_cnfig: The widget configuration :param w_type: The Widget type :param parent: The parent to which the widget should be added :return: """ from ..schemas import WIDGET_TYPES spec: WidgetType = ( w_type if isinstance(w_type, WidgetType) else WIDGET_TYPES[w_type] ) return await spec.create_to_code(w_cnfig, parent) class NumberType(WidgetType): def get_max(self, config: dict): return int(config.get(CONF_MAX_VALUE, 100)) def get_min(self, config: dict): return int(config.get(CONF_MIN_VALUE, 0))