diff --git a/CODEOWNERS b/CODEOWNERS index ac793e19af..1da55891fd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,6 +65,7 @@ esphome/components/debug/* @OttoWinter esphome/components/delonghi/* @grob6000 esphome/components/dfplayer/* @glmnet esphome/components/dht/* @OttoWinter +esphome/components/display_menu_base/* @numo68 esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/dsmr/* @glmnet @zuidwijk @@ -110,6 +111,7 @@ esphome/components/integration/* @OttoWinter esphome/components/interval/* @esphome/core esphome/components/json/* @OttoWinter esphome/components/kalman_combinator/* @Cat-Ion +esphome/components/lcd_menu/* @numo68 esphome/components/ledc/* @OttoWinter esphome/components/light/* @esphome/core esphome/components/lilygo_t5_47/touchscreen/* @jesserockz diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py new file mode 100644 index 0000000000..eb66737fdb --- /dev/null +++ b/esphome/components/display_menu_base/__init__.py @@ -0,0 +1,430 @@ +import re +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation, core +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_TRIGGER_ID, + CONF_ON_VALUE, + CONF_COMMAND, + CONF_NUMBER, + CONF_FORMAT, + CONF_MODE, + CONF_ACTIVE, +) +from esphome.automation import maybe_simple_id +from esphome.components.select import Select +from esphome.components.number import Number +from esphome.components.switch import Switch + +CODEOWNERS = ["@numo68"] + +display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") + +CONF_DISPLAY_ID = "display_id" + +CONF_ROTARY = "rotary" +CONF_JOYSTICK = "joystick" +CONF_LABEL = "label" +CONF_MENU = "menu" +CONF_BACK = "back" +CONF_TEXT = "text" +CONF_SELECT = "select" +CONF_SWITCH = "switch" +CONF_CUSTOM = "custom" +CONF_ITEMS = "items" +CONF_ON_TEXT = "on_text" +CONF_OFF_TEXT = "off_text" +CONF_VALUE_LAMBDA = "value_lambda" +CONF_IMMEDIATE_EDIT = "immediate_edit" +CONF_ROOT_ITEM_ID = "root_item_id" +CONF_ON_ENTER = "on_enter" +CONF_ON_LEAVE = "on_leave" +CONF_ON_NEXT = "on_next" +CONF_ON_PREV = "on_prev" + +DisplayMenuComponent = display_menu_base_ns.class_("DisplayMenuComponent", cg.Component) + +MenuItem = display_menu_base_ns.class_("MenuItem") +MenuItemConstPtr = MenuItem.operator("ptr").operator("const") +MenuItemMenu = display_menu_base_ns.class_("MenuItemMenu") +MenuItemSelect = display_menu_base_ns.class_("MenuItemSelect") +MenuItemNumber = display_menu_base_ns.class_("MenuItemNumber") +MenuItemSwitch = display_menu_base_ns.class_("MenuItemSwitch") +MenuItemCommand = display_menu_base_ns.class_("MenuItemCommand") +MenuItemCustom = display_menu_base_ns.class_("MenuItemCustom") + +UpAction = display_menu_base_ns.class_("UpAction", automation.Action) +DownAction = display_menu_base_ns.class_("DownAction", automation.Action) +LeftAction = display_menu_base_ns.class_("LeftAction", automation.Action) +RightAction = display_menu_base_ns.class_("RightAction", automation.Action) +EnterAction = display_menu_base_ns.class_("EnterAction", automation.Action) +ShowAction = display_menu_base_ns.class_("ShowAction", automation.Action) +HideAction = display_menu_base_ns.class_("HideAction", automation.Action) +ShowMainAction = display_menu_base_ns.class_("ShowMainAction", automation.Action) + +IsActiveCondition = display_menu_base_ns.class_( + "IsActiveCondition", automation.Condition +) + +MULTI_CONF = True + +MenuItemType = display_menu_base_ns.enum("MenuItemType") + +MENU_ITEM_TYPES = { + CONF_LABEL: MenuItemType.MENU_ITEM_LABEL, + CONF_MENU: MenuItemType.MENU_ITEM_MENU, + CONF_BACK: MenuItemType.MENU_ITEM_BACK, + CONF_SELECT: MenuItemType.MENU_ITEM_SELECT, + CONF_NUMBER: MenuItemType.MENU_ITEM_NUMBER, + CONF_SWITCH: MenuItemType.MENU_ITEM_SWITCH, + CONF_COMMAND: MenuItemType.MENU_ITEM_COMMAND, + CONF_CUSTOM: MenuItemType.MENU_ITEM_CUSTOM, +} + +MENU_ITEMS_WITH_SPECIALIZED_CLASSES = ( + CONF_MENU, + CONF_SELECT, + CONF_NUMBER, + CONF_SWITCH, + CONF_COMMAND, + CONF_CUSTOM, +) + +MenuMode = display_menu_base_ns.enum("MenuMode") + +MENU_MODES = { + CONF_ROTARY: MenuMode.MENU_MODE_ROTARY, + CONF_JOYSTICK: MenuMode.MENU_MODE_JOYSTICK, +} + +DisplayMenuOnEnterTrigger = display_menu_base_ns.class_( + "DisplayMenuOnEnterTrigger", automation.Trigger +) + +DisplayMenuOnLeaveTrigger = display_menu_base_ns.class_( + "DisplayMenuOnLeaveTrigger", automation.Trigger +) + +DisplayMenuOnValueTrigger = display_menu_base_ns.class_( + "DisplayMenuOnValueTrigger", automation.Trigger +) + +DisplayMenuOnNextTrigger = display_menu_base_ns.class_( + "DisplayMenuOnNextTrigger", automation.Trigger +) + +DisplayMenuOnPrevTrigger = display_menu_base_ns.class_( + "DisplayMenuOnPrevTrigger", automation.Trigger +) + + +def validate_format(format): + if re.search(r"^%([+-])*(\d+)*(\.\d+)*[fg]$", format) is None: + raise cv.Invalid( + f"{CONF_FORMAT}: has to specify a printf-like format string specifying exactly one f or g type conversion, '{format}' provided" + ) + + return format + + +# Use a simple indirection to circumvent the recursion limitation +def menu_item_schema(value): + return MENU_ITEM_SCHEMA(value) + + +MENU_ITEM_COMMON_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TEXT): cv.templatable(cv.string), + } +) + +MENU_ITEM_ENTER_LEAVE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( + { + cv.Optional(CONF_ON_ENTER): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnEnterTrigger + ), + } + ), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnLeaveTrigger + ), + } + ), + } +) + +MENU_ITEM_VALUE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( + { + cv.Optional(CONF_ON_VALUE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnValueTrigger + ), + } + ), + } +) + +MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA = MENU_ITEM_ENTER_LEAVE_SCHEMA.extend( + { + cv.Optional(CONF_ON_VALUE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnValueTrigger + ), + } + ), + } +) + +MENU_ITEM_SCHEMA = cv.typed_schema( + { + CONF_LABEL: MENU_ITEM_COMMON_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItem), + } + ), + CONF_BACK: MENU_ITEM_COMMON_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItem), + } + ), + CONF_MENU: MENU_ITEM_ENTER_LEAVE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemMenu), + cv.Required(CONF_ITEMS): cv.All( + cv.ensure_list(menu_item_schema), cv.Length(min=1) + ), + } + ), + CONF_SELECT: MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemSelect), + cv.Required(CONF_SELECT): cv.use_id(Select), + cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, + cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, + } + ), + CONF_NUMBER: MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemNumber), + cv.Required(CONF_NUMBER): cv.use_id(Number), + cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, + cv.Optional(CONF_FORMAT, default="%.1f"): cv.All( + cv.string_strict, + validate_format, + ), + cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, + } + ), + CONF_SWITCH: MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemSwitch), + cv.Required(CONF_SWITCH): cv.use_id(Switch), + cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, + cv.Optional(CONF_ON_TEXT, default="On"): cv.string_strict, + cv.Optional(CONF_OFF_TEXT, default="Off"): cv.string_strict, + cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, + } + ), + CONF_COMMAND: MENU_ITEM_VALUE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCommand), + } + ), + CONF_CUSTOM: MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCustom), + cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, + cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_ON_NEXT): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnNextTrigger + ), + } + ), + cv.Optional(CONF_ON_PREV): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnPrevTrigger + ), + } + ), + } + ), + }, + default_type="label", + lower=True, +) + +DISPLAY_MENU_BASE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.GenerateID(CONF_ROOT_ITEM_ID): cv.declare_id(MenuItemMenu), + cv.Optional(CONF_MODE, default=CONF_ROTARY): cv.enum(MENU_MODES), + cv.Optional(CONF_ON_ENTER): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnEnterTrigger + ), + } + ), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + DisplayMenuOnLeaveTrigger + ), + } + ), + cv.Required(CONF_ITEMS): cv.All( + cv.ensure_list(MENU_ITEM_SCHEMA), cv.Length(min=1) + ), + } +).extend(cv.COMPONENT_SCHEMA) + +MENU_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(DisplayMenuComponent), + } +) + + +@automation.register_action("display_menu.up", UpAction, MENU_ACTION_SCHEMA) +async def menu_up_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.down", DownAction, MENU_ACTION_SCHEMA) +async def menu_down_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.left", LeftAction, MENU_ACTION_SCHEMA) +async def menu_left_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.right", RightAction, MENU_ACTION_SCHEMA) +async def menu_right_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.enter", EnterAction, MENU_ACTION_SCHEMA) +async def menu_enter_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.show", ShowAction, MENU_ACTION_SCHEMA) +async def menu_show_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action("display_menu.hide", HideAction, MENU_ACTION_SCHEMA) +async def menu_hide_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_action( + "display_menu.show_main", ShowMainAction, MENU_ACTION_SCHEMA +) +async def menu_show_main_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) + + +@automation.register_condition( + "display_menu.is_active", + IsActiveCondition, + automation.maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(DisplayMenuComponent), + } + ), +) +async def display_menu_is_active_to_code(config, condition_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) + + +async def menu_item_to_code(menu, config, parent): + if config[CONF_TYPE] in MENU_ITEMS_WITH_SPECIALIZED_CLASSES: + item = cg.new_Pvariable(config[CONF_ID]) + else: + item = cg.new_Pvariable(config[CONF_ID], MENU_ITEM_TYPES[config[CONF_TYPE]]) + cg.add(parent.add_item(item)) + if CONF_TEXT in config: + if isinstance(config[CONF_TEXT], core.Lambda): + template_ = await cg.templatable( + config[CONF_TEXT], [(MenuItemConstPtr, "it")], cg.std_string + ) + cg.add(item.set_text(template_)) + else: + cg.add(item.set_text(config[CONF_TEXT])) + if CONF_VALUE_LAMBDA in config: + template_ = await cg.process_lambda( + config[CONF_VALUE_LAMBDA], + [(MenuItemConstPtr, "it")], + return_type=cg.std_string, + ) + cg.add(item.set_value_lambda(template_)) + if CONF_ITEMS in config: + for c in config[CONF_ITEMS]: + await menu_item_to_code(menu, c, item) + if CONF_IMMEDIATE_EDIT in config: + cg.add(item.set_immediate_edit(config[CONF_IMMEDIATE_EDIT])) + if config[CONF_TYPE] == CONF_SELECT: + var = await cg.get_variable(config[CONF_SELECT]) + cg.add(item.set_select_variable(var)) + if config[CONF_TYPE] == CONF_NUMBER: + var = await cg.get_variable(config[CONF_NUMBER]) + cg.add(item.set_number_variable(var)) + cg.add(item.set_format(config[CONF_FORMAT])) + if config[CONF_TYPE] == CONF_SWITCH: + var = await cg.get_variable(config[CONF_SWITCH]) + cg.add(item.set_switch_variable(var)) + cg.add(item.set_on_text(config[CONF_ON_TEXT])) + cg.add(item.set_off_text(config[CONF_OFF_TEXT])) + for conf in config.get(CONF_ON_ENTER, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + for conf in config.get(CONF_ON_LEAVE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + for conf in config.get(CONF_ON_VALUE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + for conf in config.get(CONF_ON_NEXT, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + for conf in config.get(CONF_ON_PREV, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + + +async def display_menu_to_code(menu, config): + cg.add(menu.set_active(config[CONF_ACTIVE])) + root_item = cg.new_Pvariable(config[CONF_ROOT_ITEM_ID]) + cg.add(menu.set_root_item(root_item)) + cg.add(menu.set_mode(config[CONF_MODE])) + for c in config[CONF_ITEMS]: + await menu_item_to_code(menu, c, root_item) + for conf in config.get(CONF_ON_ENTER, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + for conf in config.get(CONF_ON_LEAVE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) + await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h new file mode 100644 index 0000000000..d5394a1e0c --- /dev/null +++ b/esphome/components/display_menu_base/automation.h @@ -0,0 +1,133 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "display_menu_base.h" + +namespace esphome { +namespace display_menu_base { + +template class UpAction : public Action { + public: + explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->up(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class DownAction : public Action { + public: + explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->down(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class LeftAction : public Action { + public: + explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->left(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class RightAction : public Action { + public: + explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->right(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class EnterAction : public Action { + public: + explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->enter(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class ShowAction : public Action { + public: + explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->show(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class HideAction : public Action { + public: + explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->hide(); } + + protected: + DisplayMenuComponent *menu_; +}; + +template class ShowMainAction : public Action { + public: + explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} + + void play(Ts... x) override { this->menu_->show_main(); } + + protected: + DisplayMenuComponent *menu_; +}; +template class IsActiveCondition : public Condition { + public: + explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} + bool check(Ts... x) override { return this->menu_->is_active(); } + + protected: + DisplayMenuComponent *menu_; +}; + +class DisplayMenuOnEnterTrigger : public Trigger { + public: + explicit DisplayMenuOnEnterTrigger(MenuItem *parent) { + parent->add_on_enter_callback([this, parent]() { this->trigger(parent); }); + } +}; + +class DisplayMenuOnLeaveTrigger : public Trigger { + public: + explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) { + parent->add_on_leave_callback([this, parent]() { this->trigger(parent); }); + } +}; + +class DisplayMenuOnValueTrigger : public Trigger { + public: + explicit DisplayMenuOnValueTrigger(MenuItem *parent) { + parent->add_on_value_callback([this, parent]() { this->trigger(parent); }); + } +}; + +class DisplayMenuOnNextTrigger : public Trigger { + public: + explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) { + parent->add_on_next_callback([this, parent]() { this->trigger(parent); }); + } +}; + +class DisplayMenuOnPrevTrigger : public Trigger { + public: + explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) { + parent->add_on_prev_callback([this, parent]() { this->trigger(parent); }); + } +}; + +} // namespace display_menu_base +} // namespace esphome diff --git a/esphome/components/display_menu_base/display_menu_base.cpp b/esphome/components/display_menu_base/display_menu_base.cpp new file mode 100644 index 0000000000..57da3cec35 --- /dev/null +++ b/esphome/components/display_menu_base/display_menu_base.cpp @@ -0,0 +1,315 @@ +#include "display_menu_base.h" +#include + +namespace esphome { +namespace display_menu_base { + +void DisplayMenuComponent::up() { + if (this->check_healthy_and_active_()) { + bool changed = false; + + if (this->editing_) { + switch (this->mode_) { + case MENU_MODE_ROTARY: + changed = this->get_selected_item_()->select_prev(); + break; + default: + break; + } + } else { + changed = this->cursor_up_(); + } + + if (changed) + this->draw_and_update(); + } +} + +void DisplayMenuComponent::down() { + if (this->check_healthy_and_active_()) { + bool changed = false; + + if (this->editing_) { + switch (this->mode_) { + case MENU_MODE_ROTARY: + changed = this->get_selected_item_()->select_next(); + break; + default: + break; + } + } else { + changed = this->cursor_down_(); + } + + if (changed) + this->draw_and_update(); + } +} + +void DisplayMenuComponent::left() { + if (this->check_healthy_and_active_()) { + bool changed = false; + + switch (this->get_selected_item_()->get_type()) { + case MENU_ITEM_SELECT: + case MENU_ITEM_SWITCH: + case MENU_ITEM_NUMBER: + case MENU_ITEM_CUSTOM: + switch (this->mode_) { + case MENU_MODE_ROTARY: + if (this->editing_) { + this->finish_editing_(); + changed = true; + } + break; + case MENU_MODE_JOYSTICK: + if (this->editing_ || this->get_selected_item_()->get_immediate_edit()) + changed = this->get_selected_item_()->select_prev(); + break; + default: + break; + } + break; + case MENU_ITEM_BACK: + changed = this->leave_menu_(); + break; + default: + break; + } + + if (changed) + this->draw_and_update(); + } +} + +void DisplayMenuComponent::right() { + if (this->check_healthy_and_active_()) { + bool changed = false; + + switch (this->get_selected_item_()->get_type()) { + case MENU_ITEM_SELECT: + case MENU_ITEM_SWITCH: + case MENU_ITEM_NUMBER: + case MENU_ITEM_CUSTOM: + switch (this->mode_) { + case MENU_MODE_JOYSTICK: + if (this->editing_ || this->get_selected_item_()->get_immediate_edit()) + changed = this->get_selected_item_()->select_next(); + default: + break; + } + break; + case MENU_ITEM_MENU: + changed = this->enter_menu_(); + break; + default: + break; + } + + if (changed) + this->draw_and_update(); + } +} + +void DisplayMenuComponent::enter() { + if (this->check_healthy_and_active_()) { + bool changed = false; + MenuItem *item = this->get_selected_item_(); + + if (this->editing_) { + this->finish_editing_(); + changed = true; + } else { + switch (item->get_type()) { + case MENU_ITEM_MENU: + changed = this->enter_menu_(); + break; + case MENU_ITEM_BACK: + changed = this->leave_menu_(); + break; + case MENU_ITEM_SELECT: + case MENU_ITEM_SWITCH: + case MENU_ITEM_CUSTOM: + if (item->get_immediate_edit()) { + changed = item->select_next(); + } else { + this->editing_ = true; + item->on_enter(); + changed = true; + } + break; + case MENU_ITEM_NUMBER: + // A number cannot be immediate in the rotary mode + if (!item->get_immediate_edit() || this->mode_ == MENU_MODE_ROTARY) { + this->editing_ = true; + item->on_enter(); + changed = true; + } + break; + case MENU_ITEM_COMMAND: + changed = item->select_next(); + break; + default: + break; + } + } + + if (changed) + this->draw_and_update(); + } +} + +void DisplayMenuComponent::draw() { + if (this->check_healthy_and_active_()) + this->draw_menu(); +} + +void DisplayMenuComponent::show_main() { + bool disp_changed = false; + + if (this->is_failed()) + return; + + this->process_initial_(); + + if (this->active_ && this->editing_) + this->finish_editing_(); + + if (this->displayed_item_ != this->root_item_) { + this->displayed_item_->on_leave(); + disp_changed = true; + } + + this->reset_(); + this->active_ = true; + + if (disp_changed) { + this->displayed_item_->on_enter(); + } + + this->draw_and_update(); +} + +void DisplayMenuComponent::show() { + if (this->is_failed()) + return; + + this->process_initial_(); + + if (!this->active_) { + this->active_ = true; + this->draw_and_update(); + } +} + +void DisplayMenuComponent::hide() { + if (this->check_healthy_and_active_()) { + if (this->editing_) + this->finish_editing_(); + this->active_ = false; + this->update(); + } +} + +void DisplayMenuComponent::reset_() { + this->displayed_item_ = this->root_item_; + this->cursor_index_ = this->top_index_ = 0; + this->selection_stack_.clear(); +} + +void DisplayMenuComponent::process_initial_() { + if (!this->root_on_enter_called_) { + this->root_item_->on_enter(); + this->root_on_enter_called_ = true; + } +} + +bool DisplayMenuComponent::check_healthy_and_active_() { + if (this->is_failed()) + return false; + + this->process_initial_(); + + return this->active_; +} + +bool DisplayMenuComponent::cursor_up_() { + bool changed = false; + + if (this->cursor_index_ > 0) { + changed = true; + + --this->cursor_index_; + + if (this->cursor_index_ < this->top_index_) + this->top_index_ = this->cursor_index_; + } + + return changed; +} + +bool DisplayMenuComponent::cursor_down_() { + bool changed = false; + + if (this->cursor_index_ + 1 < this->displayed_item_->items_size()) { + changed = true; + + ++this->cursor_index_; + + if (this->cursor_index_ >= this->top_index_ + this->rows_) + this->top_index_ = this->cursor_index_ - this->rows_ + 1; + } + + return changed; +} + +bool DisplayMenuComponent::enter_menu_() { + this->displayed_item_->on_leave(); + this->displayed_item_ = static_cast(this->get_selected_item_()); + this->selection_stack_.push_front({this->top_index_, this->cursor_index_}); + this->cursor_index_ = this->top_index_ = 0; + this->displayed_item_->on_enter(); + + return true; +} + +bool DisplayMenuComponent::leave_menu_() { + bool changed = false; + + if (this->displayed_item_->get_parent() != nullptr) { + this->displayed_item_->on_leave(); + this->displayed_item_ = this->displayed_item_->get_parent(); + this->top_index_ = this->selection_stack_.front().first; + this->cursor_index_ = this->selection_stack_.front().second; + this->selection_stack_.pop_front(); + this->displayed_item_->on_enter(); + changed = true; + } + + return changed; +} + +void DisplayMenuComponent::finish_editing_() { + switch (this->get_selected_item_()->get_type()) { + case MENU_ITEM_SELECT: + case MENU_ITEM_NUMBER: + case MENU_ITEM_SWITCH: + case MENU_ITEM_CUSTOM: + this->get_selected_item_()->on_leave(); + break; + default: + break; + } + + this->editing_ = false; +} + +void DisplayMenuComponent::draw_menu() { + for (size_t i = 0; i < this->rows_ && this->top_index_ + i < this->displayed_item_->items_size(); ++i) { + this->draw_item(this->displayed_item_->get_item(this->top_index_ + i), i, + this->top_index_ + i == this->cursor_index_); + } +} + +} // namespace display_menu_base +} // namespace esphome diff --git a/esphome/components/display_menu_base/display_menu_base.h b/esphome/components/display_menu_base/display_menu_base.h new file mode 100644 index 0000000000..46bb0a8192 --- /dev/null +++ b/esphome/components/display_menu_base/display_menu_base.h @@ -0,0 +1,77 @@ +#pragma once + +#include "esphome/core/component.h" + +#include "menu_item.h" + +#include + +namespace esphome { +namespace display_menu_base { + +enum MenuMode { + MENU_MODE_ROTARY, + MENU_MODE_JOYSTICK, +}; + +class MenuItem; + +/** Class to display a hierarchical menu. + * + */ +class DisplayMenuComponent : public Component { + public: + void set_root_item(MenuItemMenu *item) { this->displayed_item_ = this->root_item_ = item; } + void set_active(bool active) { this->active_ = active; } + void set_mode(MenuMode mode) { this->mode_ = mode; } + void set_rows(uint8_t rows) { this->rows_ = rows; } + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + void up(); + void down(); + void left(); + void right(); + void enter(); + + void show_main(); + void show(); + void hide(); + + void draw(); + + bool is_active() const { return this->active_; } + + protected: + void reset_(); + void process_initial_(); + bool check_healthy_and_active_(); + MenuItem *get_selected_item_() { return this->displayed_item_->get_item(this->cursor_index_); } + bool cursor_up_(); + bool cursor_down_(); + bool enter_menu_(); + bool leave_menu_(); + void finish_editing_(); + virtual void draw_menu(); + virtual void draw_item(const MenuItem *item, uint8_t row, bool selected) = 0; + virtual void update() {} + virtual void draw_and_update() { + draw_menu(); + update(); + } + + uint8_t rows_; + bool active_; + MenuMode mode_; + MenuItemMenu *root_item_{nullptr}; + + MenuItemMenu *displayed_item_{nullptr}; + uint8_t top_index_{0}; + uint8_t cursor_index_{0}; + std::forward_list> selection_stack_{}; + bool editing_{false}; + bool root_on_enter_called_{false}; +}; + +} // namespace display_menu_base +} // namespace esphome diff --git a/esphome/components/display_menu_base/menu_item.cpp b/esphome/components/display_menu_base/menu_item.cpp new file mode 100644 index 0000000000..bbe6ec0e89 --- /dev/null +++ b/esphome/components/display_menu_base/menu_item.cpp @@ -0,0 +1,179 @@ +#include "menu_item.h" + +#include + +namespace esphome { +namespace display_menu_base { + +void MenuItem::on_enter() { this->on_enter_callbacks_.call(); } + +void MenuItem::on_leave() { this->on_leave_callbacks_.call(); } + +void MenuItem::on_value_() { this->on_value_callbacks_.call(); } + +#ifdef USE_SELECT +std::string MenuItemSelect::get_value_text() const { + std::string result; + + if (this->value_getter_.has_value()) { + result = this->value_getter_.value()(this); + } else { + if (this->select_var_ != nullptr) { + result = this->select_var_->state; + } + } + + return result; +} + +bool MenuItemSelect::select_next() { + bool changed = false; + + if (this->select_var_ != nullptr) { + this->select_var_->make_call().select_next(true).perform(); + changed = true; + } + + return changed; +} + +bool MenuItemSelect::select_prev() { + bool changed = false; + + if (this->select_var_ != nullptr) { + this->select_var_->make_call().select_previous(true).perform(); + changed = true; + } + + return changed; +} +#endif // USE_SELECT + +#ifdef USE_NUMBER +std::string MenuItemNumber::get_value_text() const { + std::string result; + + if (this->value_getter_.has_value()) { + result = this->value_getter_.value()(this); + } else { + char data[32]; + snprintf(data, sizeof(data), this->format_.c_str(), get_number_value_()); + result = data; + } + + return result; +} + +bool MenuItemNumber::select_next() { + bool changed = false; + + if (this->number_var_ != nullptr) { + float last = this->number_var_->state; + this->number_var_->make_call().number_increment(false).perform(); + + if (this->number_var_->state != last) { + this->on_value_(); + changed = true; + } + } + + return changed; +} + +bool MenuItemNumber::select_prev() { + bool changed = false; + + if (this->number_var_ != nullptr) { + float last = this->number_var_->state; + this->number_var_->make_call().number_decrement(false).perform(); + + if (this->number_var_->state != last) { + this->on_value_(); + changed = true; + } + } + + return changed; +} + +float MenuItemNumber::get_number_value_() const { + float result = 0.0; + + if (this->number_var_ != nullptr) { + if (!this->number_var_->has_state() || this->number_var_->state < this->number_var_->traits.get_min_value()) { + result = this->number_var_->traits.get_min_value(); + } else if (this->number_var_->state > this->number_var_->traits.get_max_value()) { + result = this->number_var_->traits.get_max_value(); + } else { + result = this->number_var_->state; + } + } + + return result; +} +#endif // USE_NUMBER + +#ifdef USE_SWITCH +std::string MenuItemSwitch::get_value_text() const { + std::string result; + + if (this->value_getter_.has_value()) { + result = this->value_getter_.value()(this); + } else { + result = this->get_switch_state_() ? this->switch_on_text_ : this->switch_off_text_; + } + + return result; +} + +bool MenuItemSwitch::select_next() { return this->toggle_switch_(); } + +bool MenuItemSwitch::select_prev() { return this->toggle_switch_(); } + +bool MenuItemSwitch::get_switch_state_() const { return (this->switch_var_ != nullptr && this->switch_var_->state); } + +bool MenuItemSwitch::toggle_switch_() { + bool changed = false; + + if (this->switch_var_ != nullptr) { + this->switch_var_->toggle(); + this->on_value_(); + changed = true; + } + + return changed; +} +#endif // USE_SWITCH + +std::string MenuItemCustom::get_value_text() const { + return (this->value_getter_.has_value()) ? this->value_getter_.value()(this) : ""; +} + +bool MenuItemCommand::select_next() { + this->on_value_(); + return true; +} + +bool MenuItemCommand::select_prev() { + this->on_value_(); + return true; +} + +bool MenuItemCustom::select_next() { + this->on_next_(); + this->on_value_(); + return true; +} + +bool MenuItemCustom::select_prev() { + this->on_prev_(); + this->on_value_(); + return true; +} + +void MenuItemCustom::on_next_() { this->on_next_callbacks_.call(); } + +void MenuItemCustom::on_prev_() { this->on_prev_callbacks_.call(); } + +} // namespace display_menu_base +} // namespace esphome diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h new file mode 100644 index 0000000000..a30f31e88f --- /dev/null +++ b/esphome/components/display_menu_base/menu_item.h @@ -0,0 +1,187 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/automation.h" + +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#include + +namespace esphome { +namespace display_menu_base { + +enum MenuItemType { + MENU_ITEM_LABEL, + MENU_ITEM_MENU, + MENU_ITEM_BACK, + MENU_ITEM_SELECT, + MENU_ITEM_NUMBER, + MENU_ITEM_SWITCH, + MENU_ITEM_COMMAND, + MENU_ITEM_CUSTOM, +}; + +class MenuItem; +class MenuItemMenu; +using value_getter_t = std::function; + +class MenuItem { + public: + explicit MenuItem(MenuItemType t) : item_type_(t) {} + void set_parent(MenuItemMenu *parent) { this->parent_ = parent; } + MenuItemMenu *get_parent() { return this->parent_; } + MenuItemType get_type() const { return this->item_type_; } + template void set_text(V val) { this->text_ = val; } + void add_on_enter_callback(std::function &&cb) { this->on_enter_callbacks_.add(std::move(cb)); } + void add_on_leave_callback(std::function &&cb) { this->on_leave_callbacks_.add(std::move(cb)); } + void add_on_value_callback(std::function &&cb) { this->on_value_callbacks_.add(std::move(cb)); } + + std::string get_text() const { return const_cast(this)->text_.value(this); } + virtual bool get_immediate_edit() const { return false; } + virtual bool has_value() const { return false; } + virtual std::string get_value_text() const { return ""; } + + virtual bool select_next() { return false; } + virtual bool select_prev() { return false; } + + void on_enter(); + void on_leave(); + + protected: + void on_value_(); + + MenuItemType item_type_; + MenuItemMenu *parent_{nullptr}; + TemplatableValue text_; + + CallbackManager on_enter_callbacks_{}; + CallbackManager on_leave_callbacks_{}; + CallbackManager on_value_callbacks_{}; +}; + +class MenuItemMenu : public MenuItem { + public: + explicit MenuItemMenu() : MenuItem(MENU_ITEM_MENU) {} + void add_item(MenuItem *item) { + item->set_parent(this); + this->items_.push_back(item); + } + size_t items_size() const { return this->items_.size(); } + MenuItem *get_item(size_t i) { return this->items_[i]; } + + protected: + std::vector items_; +}; + +class MenuItemEditable : public MenuItem { + public: + explicit MenuItemEditable(MenuItemType t) : MenuItem(t) {} + void set_immediate_edit(bool val) { this->immediate_edit_ = val; } + bool get_immediate_edit() const override { return this->immediate_edit_; } + void set_value_lambda(value_getter_t &&getter) { this->value_getter_ = getter; } + + protected: + bool immediate_edit_{false}; + optional value_getter_{}; +}; + +#ifdef USE_SELECT +class MenuItemSelect : public MenuItemEditable { + public: + explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} + void set_select_variable(select::Select *var) { this->select_var_ = var; } + + bool has_value() const override { return true; } + std::string get_value_text() const override; + + bool select_next() override; + bool select_prev() override; + + protected: + select::Select *select_var_{nullptr}; +}; +#endif + +#ifdef USE_NUMBER +class MenuItemNumber : public MenuItemEditable { + public: + explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} + void set_number_variable(number::Number *var) { this->number_var_ = var; } + void set_format(const std::string &fmt) { this->format_ = fmt; } + + bool has_value() const override { return true; } + std::string get_value_text() const override; + + bool select_next() override; + bool select_prev() override; + + protected: + float get_number_value_() const; + + number::Number *number_var_{nullptr}; + std::string format_; +}; +#endif + +#ifdef USE_SWITCH +class MenuItemSwitch : public MenuItemEditable { + public: + explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} + void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } + void set_on_text(const std::string &t) { this->switch_on_text_ = t; } + void set_off_text(const std::string &t) { this->switch_off_text_ = t; } + + bool has_value() const override { return true; } + std::string get_value_text() const override; + + bool select_next() override; + bool select_prev() override; + + protected: + bool get_switch_state_() const; + bool toggle_switch_(); + + switch_::Switch *switch_var_{nullptr}; + std::string switch_on_text_; + std::string switch_off_text_; +}; +#endif + +class MenuItemCommand : public MenuItem { + public: + explicit MenuItemCommand() : MenuItem(MENU_ITEM_COMMAND) {} + + bool select_next() override; + bool select_prev() override; +}; + +class MenuItemCustom : public MenuItemEditable { + public: + explicit MenuItemCustom() : MenuItemEditable(MENU_ITEM_CUSTOM) {} + void add_on_next_callback(std::function &&cb) { this->on_next_callbacks_.add(std::move(cb)); } + void add_on_prev_callback(std::function &&cb) { this->on_prev_callbacks_.add(std::move(cb)); } + + bool has_value() const override { return this->value_getter_.has_value(); } + std::string get_value_text() const override; + + bool select_next() override; + bool select_prev() override; + + protected: + void on_next_(); + void on_prev_(); + + CallbackManager on_next_callbacks_{}; + CallbackManager on_prev_callbacks_{}; +}; + +} // namespace display_menu_base +} // namespace esphome diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py new file mode 100644 index 0000000000..a356c85ba7 --- /dev/null +++ b/esphome/components/lcd_menu/__init__.py @@ -0,0 +1,74 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_DIMENSIONS, +) +from esphome.core.entity_helpers import inherit_property_from +from esphome.components import lcd_base +from esphome.components.display_menu_base import ( + DISPLAY_MENU_BASE_SCHEMA, + DisplayMenuComponent, + display_menu_to_code, +) + +CODEOWNERS = ["@numo68"] + +AUTO_LOAD = ["display_menu_base"] + +lcd_menu_ns = cg.esphome_ns.namespace("lcd_menu") + +CONF_DISPLAY_ID = "display_id" + +CONF_MARK_SELECTED = "mark_selected" +CONF_MARK_EDITING = "mark_editing" +CONF_MARK_SUBMENU = "mark_submenu" +CONF_MARK_BACK = "mark_back" + +MINIMUM_COLUMNS = 12 + +LCDCharacterMenuComponent = lcd_menu_ns.class_( + "LCDCharacterMenuComponent", DisplayMenuComponent +) + +MULTI_CONF = True + + +def validate_lcd_dimensions(config): + if config[CONF_DIMENSIONS][0] < MINIMUM_COLUMNS: + raise cv.Invalid( + f"LCD display must have at least {MINIMUM_COLUMNS} columns to be usable with the menu" + ) + return config + + +CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LCDCharacterMenuComponent), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(lcd_base.LCDDisplay), + cv.Optional(CONF_MARK_SELECTED, default=0x3E): cv.uint8_t, + cv.Optional(CONF_MARK_EDITING, default=0x2A): cv.uint8_t, + cv.Optional(CONF_MARK_SUBMENU, default=0x7E): cv.uint8_t, + cv.Optional(CONF_MARK_BACK, default=0x5E): cv.uint8_t, + } + ) +) + +FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_DIMENSIONS, CONF_DISPLAY_ID), + validate_lcd_dimensions, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + disp = await cg.get_variable(config[CONF_DISPLAY_ID]) + cg.add(var.set_display(disp)) + cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) + await display_menu_to_code(var, config) + cg.add(var.set_mark_selected(config[CONF_MARK_SELECTED])) + cg.add(var.set_mark_editing(config[CONF_MARK_EDITING])) + cg.add(var.set_mark_submenu(config[CONF_MARK_SUBMENU])) + cg.add(var.set_mark_back(config[CONF_MARK_BACK])) diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp new file mode 100644 index 0000000000..74ada5e584 --- /dev/null +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -0,0 +1,74 @@ +#include "lcd_menu.h" +#include "esphome/core/log.h" +#include + +namespace esphome { +namespace lcd_menu { + +static const char *const TAG = "lcd_menu"; + +void LCDCharacterMenuComponent::setup() { + if (this->display_->is_failed()) { + this->mark_failed(); + return; + } + + display_menu_base::DisplayMenuComponent::setup(); +} + +float LCDCharacterMenuComponent::get_setup_priority() const { return setup_priority::PROCESSOR - 1.0f; } + +void LCDCharacterMenuComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LCD Menu"); + ESP_LOGCONFIG(TAG, " Columns: %u, Rows: %u", this->columns_, this->rows_); + ESP_LOGCONFIG(TAG, " Mark characters: %02x, %02x, %02x, %02x", this->mark_selected_, this->mark_editing_, + this->mark_submenu_, this->mark_back_); + if (this->is_failed()) { + ESP_LOGE(TAG, "The connected display failed, the menu is disabled!"); + } +} + +void LCDCharacterMenuComponent::draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) { + char data[this->columns_ + 1]; // Bounded to 65 through the config + + memset(data, ' ', this->columns_); + + if (selected) { + data[0] = (this->editing_ || (this->mode_ == display_menu_base::MENU_MODE_JOYSTICK && item->get_immediate_edit())) + ? this->mark_editing_ + : this->mark_selected_; + } + + switch (item->get_type()) { + case display_menu_base::MENU_ITEM_MENU: + data[this->columns_ - 1] = this->mark_submenu_; + break; + case display_menu_base::MENU_ITEM_BACK: + data[this->columns_ - 1] = this->mark_back_; + break; + default: + break; + } + + auto text = item->get_text(); + size_t n = std::min(text.size(), (size_t) this->columns_ - 2); + memcpy(data + 1, item->get_text().c_str(), n); + + if (item->has_value()) { + std::string value = item->get_value_text(); + + // Maximum: start mark, at least two chars of label, space, '[', value, ']', + // end mark. Config guarantees columns >= 12 + size_t val_width = std::min((size_t) this->columns_ - 7, value.length()); + memcpy(data + this->columns_ - val_width - 4, " [", 2); + memcpy(data + this->columns_ - val_width - 2, value.c_str(), val_width); + data[this->columns_ - 2] = ']'; + } + + data[this->columns_] = '\0'; + + this->display_->print(0, row, data); +} + +} // namespace lcd_menu +} // namespace esphome diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h new file mode 100644 index 0000000000..d0dbca7b2f --- /dev/null +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/components/lcd_base/lcd_display.h" +#include "esphome/components/display_menu_base/display_menu_base.h" + +#include +#include + +namespace esphome { +namespace lcd_menu { + +/** Class to display a hierarchical menu. + * + */ +class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent { + public: + void set_display(lcd_base::LCDDisplay *display) { this->display_ = display; } + void set_dimensions(uint8_t columns, uint8_t rows) { + this->columns_ = columns; + set_rows(rows); + } + void set_mark_selected(uint8_t c) { this->mark_selected_ = c; } + void set_mark_editing(uint8_t c) { this->mark_editing_ = c; } + void set_mark_submenu(uint8_t c) { this->mark_submenu_ = c; } + void set_mark_back(uint8_t c) { this->mark_back_ = c; } + + void setup() override; + float get_setup_priority() const override; + + void dump_config() override; + + protected: + void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; + void update() override { this->display_->update(); } + + lcd_base::LCDDisplay *display_; + uint8_t columns_; + char mark_selected_; + char mark_editing_; + char mark_submenu_; + char mark_back_; +}; + +} // namespace lcd_menu +} // namespace esphome diff --git a/tests/test1.yaml b/tests/test1.yaml index b286c37deb..eacaf9b38c 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -871,8 +871,10 @@ sensor: value: !lambda "return -1;" on_clockwise: - logger.log: Clockwise + - display_menu.down: on_anticlockwise: - logger.log: Anticlockwise + - display_menu.up: - platform: pulse_width name: Pulse Width pin: GPIO12 @@ -1289,6 +1291,16 @@ binary_sensor: pin: GPIO27 threshold: 1000 id: btn_left + on_press: + - if: + condition: + display_menu.is_active: + then: + - display_menu.enter: + else: + - display_menu.left: + - display_menu.right: + - display_menu.show: - platform: template name: Garage Door Open id: garage_door @@ -2331,6 +2343,7 @@ color: display: - platform: lcd_gpio + id: my_lcd_gpio dimensions: 18x4 data_pins: - GPIO19 @@ -3009,3 +3022,85 @@ button: name: Midea Power Inverse on_press: midea_ac.power_toggle: + +lcd_menu: + display_id: my_lcd_gpio + mark_back: 0x5e + mark_selected: 0x3e + mark_editing: 0x2a + mark_submenu: 0x7e + active: false + mode: rotary + on_enter: + then: + lambda: 'ESP_LOGI("lcd_menu", "root enter");' + on_leave: + then: + lambda: 'ESP_LOGI("lcd_menu", "root leave");' + items: + - type: back + text: 'Back' + - type: label + - type: menu + text: 'Submenu 1' + items: + - type: back + text: 'Back' + - type: menu + text: 'Submenu 21' + items: + - type: back + text: 'Back' + - type: command + text: 'Show Main' + on_value: + then: + - display_menu.show_main: + - type: select + text: 'Enum Item' + immediate_edit: true + select: test_select + on_enter: + then: + lambda: 'ESP_LOGI("lcd_menu", "select enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + on_leave: + then: + lambda: 'ESP_LOGI("lcd_menu", "select leave: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + on_value: + then: + lambda: 'ESP_LOGI("lcd_menu", "select value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + - type: number + text: 'Number' + number: test_number + on_enter: + then: + lambda: 'ESP_LOGI("lcd_menu", "number enter: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + on_leave: + then: + lambda: 'ESP_LOGI("lcd_menu", "number leave: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + on_value: + then: + lambda: 'ESP_LOGI("lcd_menu", "number value: %s, %s", it->get_text().c_str(), it->get_value_text().c_str());' + - type: command + text: 'Hide' + on_value: + then: + - display_menu.hide: + - type: switch + text: 'Switch' + switch: my_switch + on_text: 'Bright' + off_text: 'Dark' + immediate_edit: false + on_value: + then: + lambda: 'ESP_LOGI("lcd_menu", "switch value: %s", it->get_value_text().c_str());' + - type: custom + text: !lambda 'return "Custom";' + value_lambda: 'return "Val";' + on_next: + then: + lambda: 'ESP_LOGI("lcd_menu", "custom next: %s", it->get_text().c_str());' + on_prev: + then: + lambda: 'ESP_LOGI("lcd_menu", "custom prev: %s", it->get_text().c_str());'