diff --git a/esphome/components/lvgl/ha_cards.h b/esphome/components/lvgl/ha_cards.h new file mode 100644 index 0000000000..acc18fa0d1 --- /dev/null +++ b/esphome/components/lvgl/ha_cards.h @@ -0,0 +1,279 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_LVGL + +#include "lvgl_esphome.h" + +namespace esphome { +namespace lvgl { + +/** + * Gauge Card - A circular arc gauge with optional value/name labels + */ +class LvGaugeCardType : public LvCompound { + public: + void set_obj(lv_obj_t *obj) { + LvCompound::set_obj(obj); + } + + void create_arc() { + this->arc_ = lv_arc_create(this->obj); + lv_obj_set_size(this->arc_, LV_PCT(100), LV_PCT(100)); + lv_obj_center(this->arc_); + } + + lv_obj_t *get_arc() { return this->arc_; } + + void create_value_label() { + this->value_label_ = lv_label_create(this->obj); + lv_obj_center(this->value_label_); + } + + lv_obj_t *get_value_label() { return this->value_label_; } + + void create_name_label() { + this->name_label_ = lv_label_create(this->obj); + lv_obj_align(this->name_label_, LV_ALIGN_BOTTOM_MID, 0, -10); + } + + lv_obj_t *get_name_label() { return this->name_label_; } + + void set_value(float value) { + this->value_ = value; + if (this->arc_ != nullptr) { + lv_arc_set_value(this->arc_, static_cast(value)); + } + } + + float get_value() { return this->value_; } + + void update_value_label(float value, const char *format) { + if (this->value_label_ != nullptr) { + lv_label_set_text_fmt(this->value_label_, format, value); + } + } + + protected: + lv_obj_t *arc_{nullptr}; + lv_obj_t *value_label_{nullptr}; + lv_obj_t *name_label_{nullptr}; + float value_{0}; +}; + +/** + * Button Card - A button with icon and label + */ +class LvButtonCardType : public LvCompound { + public: + void set_obj(lv_obj_t *obj) { + LvCompound::set_obj(obj); + } + + void create_icon() { + this->icon_ = lv_img_create(this->obj); + } + + lv_obj_t *get_icon() { return this->icon_; } + + void create_label() { + this->label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_label() { return this->label_; } + + void create_state_label() { + this->state_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_state_label() { return this->state_label_; } + + bool is_on() { return this->state_; } + + void set_state(bool state) { + this->state_ = state; + if (state) { + lv_obj_add_state(this->obj, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(this->obj, LV_STATE_CHECKED); + } + } + + protected: + lv_obj_t *icon_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *state_label_{nullptr}; + bool state_{false}; +}; + +/** + * Tile Card - A modern tile-style widget + */ +class LvTileCardType : public LvCompound { + public: + void set_obj(lv_obj_t *obj) { + LvCompound::set_obj(obj); + } + + void create_icon() { + this->icon_ = lv_img_create(this->obj); + } + + lv_obj_t *get_icon() { return this->icon_; } + + void create_name_label() { + this->name_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_name_label() { return this->name_label_; } + + void create_state_label() { + this->state_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_state_label() { return this->state_label_; } + + void set_state(bool state) { + this->state_ = state; + if (state) { + lv_obj_add_state(this->obj, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(this->obj, LV_STATE_CHECKED); + } + } + + bool is_on() { return this->state_; } + + protected: + lv_obj_t *icon_{nullptr}; + lv_obj_t *name_label_{nullptr}; + lv_obj_t *state_label_{nullptr}; + bool state_{false}; +}; + +/** + * Entity Card - Simple entity state display + */ +class LvEntityCardType : public LvCompound { + public: + void set_obj(lv_obj_t *obj) { + LvCompound::set_obj(obj); + } + + void create_icon() { + this->icon_ = lv_img_create(this->obj); + } + + lv_obj_t *get_icon() { return this->icon_; } + + void create_name_label() { + this->name_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_name_label() { return this->name_label_; } + + void create_value_label() { + this->value_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_value_label() { return this->value_label_; } + + void set_value(const char *value) { + if (this->value_label_ != nullptr) { + lv_label_set_text(this->value_label_, value); + } + } + + protected: + lv_obj_t *icon_{nullptr}; + lv_obj_t *name_label_{nullptr}; + lv_obj_t *value_label_{nullptr}; +}; + +/** + * Thermostat Card - Climate control interface + */ +class LvThermostatCardType : public LvCompound { + public: + void set_obj(lv_obj_t *obj) { + LvCompound::set_obj(obj); + } + + void create_temperature_arc() { + this->temp_arc_ = lv_arc_create(this->obj); + } + + lv_obj_t *get_temperature_arc() { return this->temp_arc_; } + + void create_temperature_label() { + this->temp_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_temperature_label() { return this->temp_label_; } + + void create_setpoint_label() { + this->setpoint_label_ = lv_label_create(this->obj); + } + + lv_obj_t *get_setpoint_label() { return this->setpoint_label_; } + + void create_mode_buttons() { + this->mode_container_ = lv_obj_create(this->obj); + } + + lv_obj_t *get_mode_container() { return this->mode_container_; } + + void create_up_button() { + this->up_btn_ = lv_btn_create(this->obj); + lv_obj_t *label = lv_label_create(this->up_btn_); + lv_label_set_text(label, LV_SYMBOL_UP); + lv_obj_center(label); + } + + lv_obj_t *get_up_button() { return this->up_btn_; } + + void create_down_button() { + this->down_btn_ = lv_btn_create(this->obj); + lv_obj_t *label = lv_label_create(this->down_btn_); + lv_label_set_text(label, LV_SYMBOL_DOWN); + lv_obj_center(label); + } + + lv_obj_t *get_down_button() { return this->down_btn_; } + + void set_current_temperature(float temp) { + this->current_temp_ = temp; + if (this->temp_label_ != nullptr) { + lv_label_set_text_fmt(this->temp_label_, "%.1f°", temp); + } + } + + float get_current_temperature() { return this->current_temp_; } + + void set_target_temperature(float temp) { + this->target_temp_ = temp; + if (this->temp_arc_ != nullptr) { + lv_arc_set_value(this->temp_arc_, static_cast(temp * 10)); + } + if (this->setpoint_label_ != nullptr) { + lv_label_set_text_fmt(this->setpoint_label_, "%.1f°", temp); + } + } + + float get_target_temperature() { return this->target_temp_; } + + protected: + lv_obj_t *temp_arc_{nullptr}; + lv_obj_t *temp_label_{nullptr}; + lv_obj_t *setpoint_label_{nullptr}; + lv_obj_t *mode_container_{nullptr}; + lv_obj_t *up_btn_{nullptr}; + lv_obj_t *down_btn_{nullptr}; + float current_temp_{0}; + float target_temp_{20}; +}; + +} // namespace lvgl +} // namespace esphome + +#endif // USE_LVGL diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index bd6f1fdb61..89f5154ebe 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -407,3 +407,6 @@ class LvKeyboardType : public key_provider::KeyProvider, public LvCompound { #endif // USE_LVGL_KEYBOARD } // namespace lvgl } // namespace esphome + +// Include HA card widget types - must be after LvCompound is defined +#include "ha_cards.h" diff --git a/esphome/components/lvgl/widgets/button_card.py b/esphome/components/lvgl/widgets/button_card.py new file mode 100644 index 0000000000..eb66325442 --- /dev/null +++ b/esphome/components/lvgl/widgets/button_card.py @@ -0,0 +1,137 @@ +""" +Button Card Widget - A Home Assistant style button card for LVGL. + +Displays a button with an icon and label, with optional state indicator. +""" +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ICON, + CONF_ID, + CONF_NAME, + CONF_STATE, +) + +from ..defines import ( + CONF_MAIN, + CONF_SRC, + literal, + lvgl_ns, +) +from ..helpers import add_lv_use, lvgl_components_required +from ..lv_validation import lv_bool, lv_color, lv_image, lv_text +from ..lvcode import lv, lv_add, lv_obj +from ..types import LvBoolean, LvCompound, WidgetType +from . import Widget + +CONF_BUTTON_CARD = "button_card" +CONF_ICON_COLOR = "icon_color" +CONF_ICON_ON_COLOR = "icon_on_color" +CONF_SHOW_STATE = "show_state" +CONF_STATE_TEXT_ON = "state_text_on" +CONF_STATE_TEXT_OFF = "state_text_off" + +# Reference to C++ class +LvButtonCardType = lvgl_ns.class_("LvButtonCardType", LvCompound) + +BUTTON_CARD_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ICON): lv_image, + cv.Optional(CONF_NAME): lv_text, + cv.Optional(CONF_STATE, default=False): lv_bool, + cv.Optional(CONF_ICON_COLOR, default=0xFFFFFF): lv_color, + cv.Optional(CONF_ICON_ON_COLOR, default=0xFFD700): lv_color, + cv.Optional(CONF_SHOW_STATE, default=False): cv.boolean, + cv.Optional(CONF_STATE_TEXT_ON, default="On"): cv.string, + cv.Optional(CONF_STATE_TEXT_OFF, default="Off"): cv.string, + } +) + +BUTTON_CARD_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_STATE): lv_bool, + cv.Optional(CONF_NAME): lv_text, + } +) + +# LvType wrapper for the button card +lv_button_card_t = LvBoolean("LvButtonCardType", parents=(LvCompound,)) + + +class ButtonCardType(WidgetType): + def __init__(self): + super().__init__( + CONF_BUTTON_CARD, + lv_button_card_t, + (CONF_MAIN,), + BUTTON_CARD_SCHEMA, + BUTTON_CARD_MODIFY_SCHEMA, + ) + + def get_uses(self): + return ("btn", "label", "img") + + async def to_code(self, w: Widget, config): + """Generate code for the button card widget.""" + add_lv_use("btn", "label", "img") + + var = w.var + obj = w.obj + + # Only set up structure on initial creation + if CONF_ICON in config or CONF_NAME in config: + # Set up the container as a button with flex layout + lv_obj.add_flag(obj, literal("LV_OBJ_FLAG_CLICKABLE")) + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_COLUMN")) + lv_obj.set_flex_align( + obj, + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + ) + + # Create icon if provided + if icon := config.get(CONF_ICON): + lv_add(var.create_icon()) + icon_obj = var.get_icon() + icon_src = await lv_image.process(icon) + lv.img_set_src(icon_obj, icon_src) + + # Set icon color + if icon_color := config.get(CONF_ICON_COLOR): + color = await lv_color.process(icon_color) + lv.obj_set_style_img_recolor(icon_obj, color, literal("LV_PART_MAIN")) + lv.obj_set_style_img_recolor_opa( + icon_obj, literal("LV_OPA_COVER"), literal("LV_PART_MAIN") + ) + + # Create label if name is provided + if name := config.get(CONF_NAME): + lv_add(var.create_label()) + label_obj = var.get_label() + name_text = await lv_text.process(name) + lv.label_set_text(label_obj, name_text) + + # Create state label if show_state is enabled + if config.get(CONF_SHOW_STATE, False): + lv_add(var.create_state_label()) + state_label = var.get_state_label() + # Set initial state text + state_text = ( + config.get(CONF_STATE_TEXT_OFF, "Off") + ) + lv.label_set_text(state_label, literal(f'"{state_text}"')) + + # Set the state + if (state := config.get(CONF_STATE)) is not None: + state_val = await lv_bool.process(state) + lv_add(var.set_state(state_val)) + + # Update state label if shown + if config.get(CONF_SHOW_STATE, False): + state_label = var.get_state_label() + # This would need to be conditional at runtime + # For now, just set the text based on config + + +button_card_spec = ButtonCardType() diff --git a/esphome/components/lvgl/widgets/entity_card.py b/esphome/components/lvgl/widgets/entity_card.py new file mode 100644 index 0000000000..d6e6df4f7f --- /dev/null +++ b/esphome/components/lvgl/widgets/entity_card.py @@ -0,0 +1,131 @@ +""" +Entity Card Widget - A Home Assistant style entity card for LVGL. + +Displays an entity with icon, name, and value in a simple horizontal layout. +""" +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ICON, + CONF_ID, + CONF_NAME, + CONF_VALUE, +) + +from ..defines import ( + CONF_MAIN, + literal, + lvgl_ns, +) +from ..helpers import add_lv_use +from ..lv_validation import lv_color, lv_image, lv_text +from ..lvcode import lv, lv_add, lv_obj +from ..types import LvCompound, LvText, WidgetType +from . import Widget + +CONF_ENTITY_CARD = "entity_card" +CONF_ICON_COLOR = "icon_color" + +# Reference to C++ class +LvEntityCardType = lvgl_ns.class_("LvEntityCardType", LvCompound) + +ENTITY_CARD_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ICON): lv_image, + cv.Optional(CONF_NAME): lv_text, + cv.Optional(CONF_VALUE): lv_text, + cv.Optional(CONF_ICON_COLOR, default=0x3498DB): lv_color, + } +) + +ENTITY_CARD_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VALUE): lv_text, + cv.Optional(CONF_NAME): lv_text, + } +) + +# LvType wrapper for the entity card +lv_entity_card_t = LvText("LvEntityCardType", parents=(LvCompound,)) + + +class EntityCardType(WidgetType): + def __init__(self): + super().__init__( + CONF_ENTITY_CARD, + lv_entity_card_t, + (CONF_MAIN,), + ENTITY_CARD_SCHEMA, + ENTITY_CARD_MODIFY_SCHEMA, + ) + + def get_uses(self): + return ("obj", "label", "img") + + async def to_code(self, w: Widget, config): + """Generate code for the entity card widget.""" + add_lv_use("obj", "label", "img") + + var = w.var + obj = w.obj + + # Only set up structure on initial creation + if CONF_ICON in config or CONF_NAME in config or CONF_VALUE in config: + # Set up horizontal flex layout + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_ROW")) + lv_obj.set_flex_align( + obj, + literal("LV_FLEX_ALIGN_SPACE_BETWEEN"), + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + ) + + # Set some padding + lv.obj_set_style_pad_all(obj, 10, literal("LV_PART_MAIN")) + lv.obj_set_style_pad_gap(obj, 10, literal("LV_PART_MAIN")) + + # Create icon if provided + if icon := config.get(CONF_ICON): + lv_add(var.create_icon()) + icon_obj = var.get_icon() + icon_src = await lv_image.process(icon) + lv.img_set_src(icon_obj, icon_src) + + # Set icon color + if icon_color := config.get(CONF_ICON_COLOR): + color = await lv_color.process(icon_color) + lv.obj_set_style_img_recolor(icon_obj, color, literal("LV_PART_MAIN")) + lv.obj_set_style_img_recolor_opa( + icon_obj, literal("LV_OPA_COVER"), literal("LV_PART_MAIN") + ) + + # Create name label + if name := config.get(CONF_NAME): + lv_add(var.create_name_label()) + name_label = var.get_name_label() + name_text = await lv_text.process(name) + lv.label_set_text(name_label, name_text) + # Make label take available space + lv_obj.set_flex_grow(name_label, 1) + + # Create value label + if value := config.get(CONF_VALUE): + lv_add(var.create_value_label()) + value_label = var.get_value_label() + value_text = await lv_text.process(value) + lv.label_set_text(value_label, value_text) + + # Update value during modify + if CONF_VALUE in config and CONF_ICON not in config: + value_text = await lv_text.process(config[CONF_VALUE]) + value_label = var.get_value_label() + lv.label_set_text(value_label, value_text) + + # Update name during modify + if CONF_NAME in config and CONF_ICON not in config: + name_text = await lv_text.process(config[CONF_NAME]) + name_label = var.get_name_label() + lv.label_set_text(name_label, name_text) + + +entity_card_spec = EntityCardType() diff --git a/esphome/components/lvgl/widgets/gauge_card.py b/esphome/components/lvgl/widgets/gauge_card.py new file mode 100644 index 0000000000..9a21822dc3 --- /dev/null +++ b/esphome/components/lvgl/widgets/gauge_card.py @@ -0,0 +1,177 @@ +""" +Gauge Card Widget - A Home Assistant style gauge card for LVGL. + +Displays a value in a circular arc gauge with optional value label in the center. +""" +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_ID, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE, +) + +from ..defines import ( + CONF_END_ANGLE, + CONF_INDICATOR, + CONF_KNOB, + CONF_MAIN, + CONF_START_ANGLE, + CONF_WIDGETS, + literal, + lvgl_ns, +) +from ..helpers import add_lv_use, lvgl_components_required +from ..lv_validation import lv_color, lv_float, lv_int, lv_text, size +from ..lvcode import LocalVariable, lv, lv_add, lv_assign, lv_expr, lv_obj +from ..types import LvCompound, LvNumber, LvType, WidgetType, lv_obj_t_ptr +from . import Widget, add_widgets, set_obj_properties, widget_to_code +from .label import CONF_LABEL + +CONF_GAUGE_CARD = "gauge_card" + +# Reference to C++ class +LvGaugeCardType = lvgl_ns.class_("LvGaugeCardType", LvCompound) +CONF_ARC_COLOR = "arc_color" +CONF_BACKGROUND_ARC_COLOR = "background_arc_color" +CONF_SHOW_VALUE = "show_value" +CONF_SHOW_NAME = "show_name" +CONF_NEEDLE_COLOR = "needle_color" +CONF_VALUE_FONT = "value_font" +CONF_NAME_FONT = "name_font" +CONF_SEVERITY = "severity" + +# Severity levels for color changes +SEVERITY_SCHEMA = cv.Schema( + { + cv.Optional("green"): cv.float_, + cv.Optional("yellow"): cv.float_, + cv.Optional("red"): cv.float_, + } +) + +GAUGE_CARD_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_MIN_VALUE, default=0): lv_int, + cv.Optional(CONF_MAX_VALUE, default=100): lv_int, + cv.Optional(CONF_START_ANGLE, default=135): cv.int_range(0, 360), + cv.Optional(CONF_END_ANGLE, default=45): cv.int_range(0, 360), + cv.Optional(CONF_NAME): lv_text, + cv.Optional(CONF_UNIT_OF_MEASUREMENT, default=""): cv.string, + cv.Optional(CONF_FORMAT, default="%.0f"): cv.string, + cv.Optional(CONF_ARC_COLOR, default=0x3498DB): lv_color, + cv.Optional(CONF_BACKGROUND_ARC_COLOR, default=0x404040): lv_color, + cv.Optional(CONF_SHOW_VALUE, default=True): cv.boolean, + cv.Optional(CONF_SHOW_NAME, default=True): cv.boolean, + cv.Optional(CONF_SEVERITY): SEVERITY_SCHEMA, + } +) + +GAUGE_CARD_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VALUE): lv_float, + } +) + + +# LvType wrapper for the gauge card - uses LvNumber for value handling +lv_gauge_card_t = LvNumber("LvGaugeCardType", parents=(LvCompound,)) + + +class GaugeCardType(WidgetType): + def __init__(self): + super().__init__( + CONF_GAUGE_CARD, + lv_gauge_card_t, + (CONF_MAIN, CONF_INDICATOR), + GAUGE_CARD_SCHEMA, + GAUGE_CARD_MODIFY_SCHEMA, + ) + + def get_uses(self): + return ("arc", "label", "obj") + + async def to_code(self, w: Widget, config): + """Generate code for the gauge card widget.""" + lvgl_components_required.add("arc") + add_lv_use("arc", "label") + + var = w.var + obj = w.obj + + # Get configuration values + min_val = config.get(CONF_MIN_VALUE, 0) + max_val = config.get(CONF_MAX_VALUE, 100) + start_angle = config.get(CONF_START_ANGLE, 135) + end_angle = config.get(CONF_END_ANGLE, 45) + + # Only set up the arc structure on initial creation + if CONF_MIN_VALUE in config: + # Set up the container styling for the card + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_COLUMN")) + lv_obj.set_flex_align( + obj, + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + ) + + # Create the main arc gauge + arc_id = f"{config[CONF_ID]}_arc" + lv_add(var.create_arc()) + + arc_obj = var.get_arc() + + # Configure arc + lv.arc_set_range(arc_obj, min_val, max_val) + lv.arc_set_bg_angles(arc_obj, start_angle, end_angle) + lv.arc_set_rotation(arc_obj, 0) + lv.arc_set_mode(arc_obj, literal("LV_ARC_MODE_NORMAL")) + + # Remove the knob for display-only gauge + lv_obj.remove_style(arc_obj, literal("NULL"), literal("LV_PART_KNOB")) + lv_obj.clear_flag(arc_obj, literal("LV_OBJ_FLAG_CLICKABLE")) + + # Set arc colors + if arc_color := config.get(CONF_ARC_COLOR): + color = await lv_color.process(arc_color) + lv.obj_set_style_arc_color(arc_obj, color, literal("LV_PART_INDICATOR")) + + if bg_arc_color := config.get(CONF_BACKGROUND_ARC_COLOR): + bg_color = await lv_color.process(bg_arc_color) + lv.obj_set_style_arc_color(arc_obj, bg_color, literal("LV_PART_MAIN")) + + # Create value label in center if enabled + if config.get(CONF_SHOW_VALUE, True): + lv_add(var.create_value_label()) + value_label = var.get_value_label() + lv_obj.set_align(value_label, literal("LV_ALIGN_CENTER")) + lv.label_set_text(value_label, literal('"--"')) + + # Create name label below if enabled and name is provided + if config.get(CONF_SHOW_NAME, True) and config.get(CONF_NAME): + lv_add(var.create_name_label()) + name_label = var.get_name_label() + name_text = await lv_text.process(config[CONF_NAME]) + lv.label_set_text(name_label, name_text) + + # Set the value + if (value := config.get(CONF_VALUE)) is not None: + value_processed = await lv_float.process(value) + lv_add(var.set_value(value_processed)) + + # Update the value label text + if config.get(CONF_SHOW_VALUE, True): + fmt = config.get(CONF_FORMAT, "%.0f") + unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") + # Format string for the value display + format_str = f'"{fmt}{unit}"' + lv_add(var.update_value_label(value_processed, literal(format_str))) + + +gauge_card_spec = GaugeCardType() diff --git a/esphome/components/lvgl/widgets/thermostat_card.py b/esphome/components/lvgl/widgets/thermostat_card.py new file mode 100644 index 0000000000..bb086074b0 --- /dev/null +++ b/esphome/components/lvgl/widgets/thermostat_card.py @@ -0,0 +1,172 @@ +""" +Thermostat Card Widget - A Home Assistant style thermostat card for LVGL. + +Displays a climate control interface with temperature display, setpoint control, +and mode buttons. +""" +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_MODE, + CONF_NAME, + CONF_STEP, +) + +from ..defines import ( + CONF_INDICATOR, + CONF_KNOB, + CONF_MAIN, + literal, + lvgl_ns, +) +from ..helpers import add_lv_use, lvgl_components_required +from ..lv_validation import lv_color, lv_float, lv_text +from ..lvcode import lv, lv_add, lv_obj +from ..types import LvCompound, LvNumber, WidgetType +from . import Widget + +CONF_THERMOSTAT_CARD = "thermostat_card" +CONF_CURRENT_TEMPERATURE = "current_temperature" +CONF_TARGET_TEMPERATURE = "target_temperature" +CONF_SHOW_CURRENT = "show_current" +CONF_SHOW_SETPOINT = "show_setpoint" +CONF_SHOW_BUTTONS = "show_buttons" +CONF_ARC_COLOR = "arc_color" +CONF_HEATING_COLOR = "heating_color" +CONF_COOLING_COLOR = "cooling_color" +CONF_UNIT = "unit" + +# Reference to C++ class +LvThermostatCardType = lvgl_ns.class_("LvThermostatCardType", LvCompound) + +THERMOSTAT_CARD_SCHEMA = cv.Schema( + { + cv.Optional(CONF_NAME): lv_text, + cv.Optional(CONF_CURRENT_TEMPERATURE): lv_float, + cv.Optional(CONF_TARGET_TEMPERATURE): lv_float, + cv.Optional(CONF_MIN_VALUE, default=5.0): cv.float_, + cv.Optional(CONF_MAX_VALUE, default=35.0): cv.float_, + cv.Optional(CONF_STEP, default=0.5): cv.float_, + cv.Optional(CONF_SHOW_CURRENT, default=True): cv.boolean, + cv.Optional(CONF_SHOW_SETPOINT, default=True): cv.boolean, + cv.Optional(CONF_SHOW_BUTTONS, default=True): cv.boolean, + cv.Optional(CONF_ARC_COLOR, default=0x3498DB): lv_color, + cv.Optional(CONF_HEATING_COLOR, default=0xE74C3C): lv_color, + cv.Optional(CONF_COOLING_COLOR, default=0x3498DB): lv_color, + cv.Optional(CONF_UNIT, default="°"): cv.string, + } +) + +THERMOSTAT_CARD_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_CURRENT_TEMPERATURE): lv_float, + cv.Optional(CONF_TARGET_TEMPERATURE): lv_float, + } +) + +# LvType wrapper for the thermostat card +lv_thermostat_card_t = LvNumber("LvThermostatCardType", parents=(LvCompound,)) + + +class ThermostatCardType(WidgetType): + def __init__(self): + super().__init__( + CONF_THERMOSTAT_CARD, + lv_thermostat_card_t, + (CONF_MAIN, CONF_INDICATOR, CONF_KNOB), + THERMOSTAT_CARD_SCHEMA, + THERMOSTAT_CARD_MODIFY_SCHEMA, + ) + + def get_uses(self): + return ("arc", "label", "btn", "obj") + + def get_min(self, config: dict): + return int(config.get(CONF_MIN_VALUE, 5) * 10) + + def get_max(self, config: dict): + return int(config.get(CONF_MAX_VALUE, 35) * 10) + + async def to_code(self, w: Widget, config): + """Generate code for the thermostat card widget.""" + lvgl_components_required.add("arc") + add_lv_use("arc", "label", "btn") + + var = w.var + obj = w.obj + + # Get configuration values + min_val = config.get(CONF_MIN_VALUE, 5.0) + max_val = config.get(CONF_MAX_VALUE, 35.0) + unit = config.get(CONF_UNIT, "°") + + # Only set up structure on initial creation + if CONF_MIN_VALUE in config: + # Set up the container with flex layout + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_COLUMN")) + lv_obj.set_flex_align( + obj, + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + ) + + # Create the temperature arc + lv_add(var.create_temperature_arc()) + temp_arc = var.get_temperature_arc() + + # Configure arc - use 10x values for precision + lv.arc_set_range(temp_arc, int(min_val * 10), int(max_val * 10)) + lv.arc_set_bg_angles(temp_arc, 135, 45) + lv.arc_set_rotation(temp_arc, 0) + lv.arc_set_mode(temp_arc, literal("LV_ARC_MODE_NORMAL")) + + # Set arc color + if arc_color := config.get(CONF_ARC_COLOR): + color = await lv_color.process(arc_color) + lv.obj_set_style_arc_color(temp_arc, color, literal("LV_PART_INDICATOR")) + + # Make arc interactive + lv_obj.add_flag(temp_arc, literal("LV_OBJ_FLAG_CLICKABLE")) + + # Create current temperature label + if config.get(CONF_SHOW_CURRENT, True): + lv_add(var.create_temperature_label()) + temp_label = var.get_temperature_label() + lv_obj.align(temp_label, literal("LV_ALIGN_CENTER"), 0, -20) + lv.label_set_text(temp_label, literal(f'"--{unit}"')) + + # Create setpoint label + if config.get(CONF_SHOW_SETPOINT, True): + lv_add(var.create_setpoint_label()) + setpoint_label = var.get_setpoint_label() + lv_obj.align(setpoint_label, literal("LV_ALIGN_CENTER"), 0, 20) + lv.label_set_text(setpoint_label, literal(f'"--{unit}"')) + + # Create up/down buttons + if config.get(CONF_SHOW_BUTTONS, True): + lv_add(var.create_up_button()) + up_btn = var.get_up_button() + lv_obj.align(up_btn, literal("LV_ALIGN_RIGHT_MID"), -10, -30) + lv_obj.set_size(up_btn, 40, 40) + + lv_add(var.create_down_button()) + down_btn = var.get_down_button() + lv_obj.align(down_btn, literal("LV_ALIGN_RIGHT_MID"), -10, 30) + lv_obj.set_size(down_btn, 40, 40) + + # Set current temperature + if (current_temp := config.get(CONF_CURRENT_TEMPERATURE)) is not None: + temp_val = await lv_float.process(current_temp) + lv_add(var.set_current_temperature(temp_val)) + + # Set target temperature + if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: + target_val = await lv_float.process(target_temp) + lv_add(var.set_target_temperature(target_val)) + + +thermostat_card_spec = ThermostatCardType() diff --git a/esphome/components/lvgl/widgets/tile_card.py b/esphome/components/lvgl/widgets/tile_card.py new file mode 100644 index 0000000000..5c32d8a4a9 --- /dev/null +++ b/esphome/components/lvgl/widgets/tile_card.py @@ -0,0 +1,142 @@ +""" +Tile Card Widget - A Home Assistant style tile card for LVGL. + +Displays a modern tile-style widget with icon, name, and state. +""" +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ICON, + CONF_ID, + CONF_NAME, + CONF_STATE, +) + +from ..defines import ( + CONF_MAIN, + literal, + lvgl_ns, +) +from ..helpers import add_lv_use +from ..lv_validation import lv_bool, lv_color, lv_image, lv_text +from ..lvcode import lv, lv_add, lv_obj +from ..types import LvBoolean, LvCompound, WidgetType +from . import Widget + +CONF_TILE_CARD = "tile_card" +CONF_ICON_COLOR = "icon_color" +CONF_ICON_ON_COLOR = "icon_on_color" +CONF_STATE_TEXT = "state_text" +CONF_VERTICAL = "vertical" + +# Reference to C++ class +LvTileCardType = lvgl_ns.class_("LvTileCardType", LvCompound) + +TILE_CARD_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ICON): lv_image, + cv.Optional(CONF_NAME): lv_text, + cv.Optional(CONF_STATE, default=False): lv_bool, + cv.Optional(CONF_STATE_TEXT): lv_text, + cv.Optional(CONF_ICON_COLOR, default=0xFFFFFF): lv_color, + cv.Optional(CONF_ICON_ON_COLOR, default=0xFFD700): lv_color, + cv.Optional(CONF_VERTICAL, default=False): cv.boolean, + } +) + +TILE_CARD_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_STATE): lv_bool, + cv.Optional(CONF_STATE_TEXT): lv_text, + cv.Optional(CONF_NAME): lv_text, + } +) + +# LvType wrapper for the tile card +lv_tile_card_t = LvBoolean("LvTileCardType", parents=(LvCompound,)) + + +class TileCardType(WidgetType): + def __init__(self): + super().__init__( + CONF_TILE_CARD, + lv_tile_card_t, + (CONF_MAIN,), + TILE_CARD_SCHEMA, + TILE_CARD_MODIFY_SCHEMA, + ) + + def get_uses(self): + return ("obj", "label", "img") + + async def to_code(self, w: Widget, config): + """Generate code for the tile card widget.""" + add_lv_use("obj", "label", "img") + + var = w.var + obj = w.obj + + # Only set up structure on initial creation + if CONF_ICON in config or CONF_NAME in config: + # Set up the container with flex layout + lv_obj.add_flag(obj, literal("LV_OBJ_FLAG_CLICKABLE")) + + # Choose layout direction based on vertical setting + if config.get(CONF_VERTICAL, False): + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_COLUMN")) + else: + lv_obj.set_flex_flow(obj, literal("LV_FLEX_FLOW_ROW")) + + lv_obj.set_flex_align( + obj, + literal("LV_FLEX_ALIGN_START"), + literal("LV_FLEX_ALIGN_CENTER"), + literal("LV_FLEX_ALIGN_CENTER"), + ) + + # Set some padding + lv.obj_set_style_pad_all(obj, 10, literal("LV_PART_MAIN")) + lv.obj_set_style_pad_gap(obj, 10, literal("LV_PART_MAIN")) + + # Create icon if provided + if icon := config.get(CONF_ICON): + lv_add(var.create_icon()) + icon_obj = var.get_icon() + icon_src = await lv_image.process(icon) + lv.img_set_src(icon_obj, icon_src) + + # Set icon color + if icon_color := config.get(CONF_ICON_COLOR): + color = await lv_color.process(icon_color) + lv.obj_set_style_img_recolor(icon_obj, color, literal("LV_PART_MAIN")) + lv.obj_set_style_img_recolor_opa( + icon_obj, literal("LV_OPA_COVER"), literal("LV_PART_MAIN") + ) + + # Create name label + if name := config.get(CONF_NAME): + lv_add(var.create_name_label()) + name_label = var.get_name_label() + name_text = await lv_text.process(name) + lv.label_set_text(name_label, name_text) + + # Create state label if state_text is provided + if state_text := config.get(CONF_STATE_TEXT): + lv_add(var.create_state_label()) + state_label = var.get_state_label() + state_txt = await lv_text.process(state_text) + lv.label_set_text(state_label, state_txt) + + # Set the state + if (state := config.get(CONF_STATE)) is not None: + state_val = await lv_bool.process(state) + lv_add(var.set_state(state_val)) + + # Update state text if provided during update + if CONF_STATE_TEXT in config and CONF_ICON not in config: + state_text = await lv_text.process(config[CONF_STATE_TEXT]) + state_label = var.get_state_label() + lv.label_set_text(state_label, state_text) + + +tile_card_spec = TileCardType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 5839643638..4158eda103 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1055,6 +1055,91 @@ lvgl: hidden: true mode: text_lower + - id: page4 + layout: + type: flex + flex_flow: column + pad_row: 10 + widgets: + # Test HA-style card widgets + - gauge_card: + id: test_gauge_card + width: 150 + height: 150 + min_value: 0 + max_value: 100 + value: 75 + name: "Temperature" + unit_of_measurement: "°C" + format: "%.1f" + arc_color: 0x3498DB + background_arc_color: 0x404040 + show_value: true + show_name: true + on_value: + logger.log: + format: "Gauge value: %.1f" + args: [x] + + - button_card: + id: test_button_card + width: 100 + height: 100 + icon: cat_image + name: "Light" + state: false + icon_color: 0xFFFFFF + icon_on_color: 0xFFD700 + show_state: true + state_text_on: "On" + state_text_off: "Off" + on_click: + - lvgl.button_card.update: + id: test_button_card + state: true + + - tile_card: + id: test_tile_card + width: 200 + height: 80 + icon: dog_image + name: "Living Room" + state: false + state_text: "Idle" + icon_color: 0xFFFFFF + vertical: false + on_click: + logger.log: "Tile card clicked" + + - entity_card: + id: test_entity_card + width: 250 + height: 60 + icon: cat_image + name: "Sensor" + value: "23.5°C" + icon_color: 0x3498DB + + - thermostat_card: + id: test_thermostat_card + width: 200 + height: 200 + name: "Thermostat" + current_temperature: 21.5 + target_temperature: 22.0 + min_value: 10.0 + max_value: 30.0 + step: 0.5 + show_current: true + show_setpoint: true + show_buttons: true + arc_color: 0x3498DB + unit: "°" + on_value: + logger.log: + format: "Thermostat value: %.1f" + args: [x] + font: - file: "gfonts://Roboto" id: space16