mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 18:18:43 +00:00
Merge branch 'integration' into memory_api
This commit is contained in:
@@ -108,7 +108,7 @@ LV_CONF_H_FORMAT = """\
|
||||
|
||||
|
||||
def generate_lv_conf_h():
|
||||
definitions = [as_macro(m, v) for m, v in df.lv_defines.items()]
|
||||
definitions = [as_macro(m, v) for m, v in df.get_data(df.KEY_LV_DEFINES).items()]
|
||||
definitions.sort()
|
||||
return LV_CONF_H_FORMAT.format("\n".join(definitions))
|
||||
|
||||
@@ -140,11 +140,11 @@ def multi_conf_validate(configs: list[dict]):
|
||||
)
|
||||
|
||||
|
||||
def final_validation(configs):
|
||||
if len(configs) != 1:
|
||||
multi_conf_validate(configs)
|
||||
def final_validation(config_list):
|
||||
if len(config_list) != 1:
|
||||
multi_conf_validate(config_list)
|
||||
global_config = full_config.get()
|
||||
for config in configs:
|
||||
for config in config_list:
|
||||
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
|
||||
raise cv.Invalid("At least one page must not be skipped")
|
||||
for display_id in config[df.CONF_DISPLAYS]:
|
||||
@@ -190,6 +190,14 @@ def final_validation(configs):
|
||||
raise cv.Invalid(
|
||||
f"Widget '{w}' does not have any dynamic properties to refresh",
|
||||
)
|
||||
# Do per-widget type final validation for update actions
|
||||
for widget_type, update_configs in df.get_data(df.KEY_UPDATED_WIDGETS).items():
|
||||
for conf in update_configs:
|
||||
for id_conf in conf.get(CONF_ID, ()):
|
||||
name = id_conf[CONF_ID]
|
||||
path = global_config.get_path_for_id(name)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
widget_type.final_validate(name, conf, widget_conf, path[1:])
|
||||
|
||||
|
||||
async def to_code(configs):
|
||||
@@ -276,6 +284,7 @@ async def to_code(configs):
|
||||
config[df.CONF_FULL_REFRESH],
|
||||
config[CONF_DRAW_ROUNDING],
|
||||
config[df.CONF_RESUME_ON_INPUT],
|
||||
config[df.CONF_UPDATE_WHEN_DISPLAY_IDLE],
|
||||
)
|
||||
await cg.register_component(lv_component, config)
|
||||
Widget.create(config[CONF_ID], lv_component, LvScrActType(), config)
|
||||
@@ -373,6 +382,9 @@ LVGL_SCHEMA = cv.All(
|
||||
df.CONF_DEFAULT_FONT, default="montserrat_14"
|
||||
): lvalid.lv_font,
|
||||
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome import codegen as cg, config_validation as cv
|
||||
from esphome.const import CONF_ITEMS
|
||||
from esphome.core import ID, Lambda
|
||||
from esphome.core import CORE, ID, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj
|
||||
from esphome.cpp_types import uint32
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
@@ -20,11 +20,27 @@ from .helpers import requires_component
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
lvgl_ns = cg.esphome_ns.namespace("lvgl")
|
||||
|
||||
lv_defines = {} # Dict of #defines to provide as build flags
|
||||
DOMAIN = "lvgl"
|
||||
KEY_LV_DEFINES = "lv_defines"
|
||||
KEY_UPDATED_WIDGETS = "updated_widgets"
|
||||
|
||||
|
||||
def get_data(key, default=None):
|
||||
"""
|
||||
Get a data structure from the global data store by key
|
||||
:param key: A key for the data
|
||||
:param default: The default data - the default is an empty dict
|
||||
:return:
|
||||
"""
|
||||
return CORE.data.setdefault(DOMAIN, {}).setdefault(
|
||||
key, default if default is not None else {}
|
||||
)
|
||||
|
||||
|
||||
def add_define(macro, value="1"):
|
||||
if macro in lv_defines and lv_defines[macro] != value:
|
||||
lv_defines = get_data(KEY_LV_DEFINES)
|
||||
value = str(value)
|
||||
if lv_defines.setdefault(macro, value) != value:
|
||||
LOGGER.error(
|
||||
"Redefinition of %s - was %s now %s", macro, lv_defines[macro], value
|
||||
)
|
||||
@@ -542,6 +558,7 @@ CONF_TOUCHSCREENS = "touchscreens"
|
||||
CONF_TRANSPARENCY_KEY = "transparency_key"
|
||||
CONF_THEME = "theme"
|
||||
CONF_UPDATE_ON_RELEASE = "update_on_release"
|
||||
CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle"
|
||||
CONF_VISIBLE_ROW_COUNT = "visible_row_count"
|
||||
CONF_WIDGET = "widget"
|
||||
CONF_WIDGETS = "widgets"
|
||||
|
||||
@@ -106,6 +106,7 @@ void LvglComponent::dump_config() {
|
||||
this->disp_drv_.hor_res, this->disp_drv_.ver_res, 100 / this->buffer_frac_, this->rotation,
|
||||
(int) this->draw_rounding);
|
||||
}
|
||||
|
||||
void LvglComponent::set_paused(bool paused, bool show_snow) {
|
||||
this->paused_ = paused;
|
||||
this->show_snow_ = show_snow;
|
||||
@@ -124,32 +125,38 @@ void LvglComponent::esphome_lvgl_init() {
|
||||
lv_update_event = static_cast<lv_event_code_t>(lv_event_register_id());
|
||||
lv_api_event = static_cast<lv_event_code_t>(lv_event_register_id());
|
||||
}
|
||||
|
||||
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) {
|
||||
lv_obj_add_event_cb(obj, callback, event, nullptr);
|
||||
}
|
||||
|
||||
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
|
||||
lv_event_code_t event2) {
|
||||
add_event_cb(obj, callback, event1);
|
||||
add_event_cb(obj, callback, event2);
|
||||
}
|
||||
|
||||
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
|
||||
lv_event_code_t event2, lv_event_code_t event3) {
|
||||
add_event_cb(obj, callback, event1);
|
||||
add_event_cb(obj, callback, event2);
|
||||
add_event_cb(obj, callback, event3);
|
||||
}
|
||||
|
||||
void LvglComponent::add_page(LvPageType *page) {
|
||||
this->pages_.push_back(page);
|
||||
page->set_parent(this);
|
||||
lv_disp_set_default(this->disp_);
|
||||
page->setup(this->pages_.size() - 1);
|
||||
}
|
||||
|
||||
void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time) {
|
||||
if (index >= this->pages_.size())
|
||||
return;
|
||||
this->current_page_ = index;
|
||||
lv_scr_load_anim(this->pages_[this->current_page_]->obj, anim, time, 0, false);
|
||||
}
|
||||
|
||||
void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) {
|
||||
if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_))
|
||||
return;
|
||||
@@ -158,6 +165,7 @@ void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) {
|
||||
} while (this->pages_[this->current_page_]->skip); // skip empty pages()
|
||||
this->show_page(this->current_page_, anim, time);
|
||||
}
|
||||
|
||||
void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) {
|
||||
if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_))
|
||||
return;
|
||||
@@ -166,8 +174,10 @@ void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) {
|
||||
} while (this->pages_[this->current_page_]->skip); // skip empty pages()
|
||||
this->show_page(this->current_page_, anim, time);
|
||||
}
|
||||
|
||||
size_t LvglComponent::get_current_page() const { return this->current_page_; }
|
||||
bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; }
|
||||
|
||||
void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) {
|
||||
auto width = lv_area_get_width(area);
|
||||
auto height = lv_area_get_height(area);
|
||||
@@ -222,7 +232,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) {
|
||||
}
|
||||
|
||||
void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
|
||||
if (!this->paused_) {
|
||||
if (!this->is_paused()) {
|
||||
auto now = millis();
|
||||
this->draw_buffer_(area, color_p);
|
||||
ESP_LOGVV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", area->x1, area->y1, lv_area_get_width(area),
|
||||
@@ -230,6 +240,7 @@ void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv
|
||||
}
|
||||
lv_disp_flush_ready(disp_drv);
|
||||
}
|
||||
|
||||
IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue<uint32_t> timeout) : timeout_(std::move(timeout)) {
|
||||
parent->add_on_idle_callback([this](uint32_t idle_time) {
|
||||
if (!this->is_idle_ && idle_time > this->timeout_.value()) {
|
||||
@@ -377,6 +388,27 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) {
|
||||
}
|
||||
#endif // USE_LVGL_KEYBOARD
|
||||
|
||||
void LvglComponent::draw_end_() {
|
||||
if (this->draw_end_callback_ != nullptr)
|
||||
this->draw_end_callback_->trigger();
|
||||
if (this->update_when_display_idle_) {
|
||||
for (auto *disp : this->displays_)
|
||||
disp->update();
|
||||
}
|
||||
}
|
||||
|
||||
bool LvglComponent::is_paused() const {
|
||||
if (this->paused_)
|
||||
return true;
|
||||
if (this->update_when_display_idle_) {
|
||||
for (auto *disp : this->displays_) {
|
||||
if (!disp->is_idle())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void LvglComponent::write_random_() {
|
||||
int iterations = 6 - lv_disp_get_inactive_time(this->disp_) / 60000;
|
||||
if (iterations <= 0)
|
||||
@@ -426,12 +458,13 @@ void LvglComponent::write_random_() {
|
||||
* presses a key or clicks on the screen.
|
||||
*/
|
||||
LvglComponent::LvglComponent(std::vector<display::Display *> displays, float buffer_frac, bool full_refresh,
|
||||
int draw_rounding, bool resume_on_input)
|
||||
int draw_rounding, bool resume_on_input, bool update_when_display_idle)
|
||||
: draw_rounding(draw_rounding),
|
||||
displays_(std::move(displays)),
|
||||
buffer_frac_(buffer_frac),
|
||||
full_refresh_(full_refresh),
|
||||
resume_on_input_(resume_on_input) {
|
||||
resume_on_input_(resume_on_input),
|
||||
update_when_display_idle_(update_when_display_idle) {
|
||||
lv_disp_draw_buf_init(&this->draw_buf_, nullptr, nullptr, 0);
|
||||
lv_disp_drv_init(&this->disp_drv_);
|
||||
this->disp_drv_.draw_buf = &this->draw_buf_;
|
||||
@@ -487,7 +520,7 @@ void LvglComponent::setup() {
|
||||
if (this->draw_start_callback_ != nullptr) {
|
||||
this->disp_drv_.render_start_cb = render_start_cb;
|
||||
}
|
||||
if (this->draw_end_callback_ != nullptr) {
|
||||
if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) {
|
||||
this->disp_drv_.monitor_cb = monitor_cb;
|
||||
}
|
||||
#if LV_USE_LOG
|
||||
@@ -509,14 +542,15 @@ void LvglComponent::setup() {
|
||||
|
||||
void LvglComponent::update() {
|
||||
// update indicators
|
||||
if (this->paused_) {
|
||||
if (this->is_paused()) {
|
||||
return;
|
||||
}
|
||||
this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_));
|
||||
}
|
||||
|
||||
void LvglComponent::loop() {
|
||||
if (this->paused_) {
|
||||
if (this->show_snow_)
|
||||
if (this->is_paused()) {
|
||||
if (this->paused_ && this->show_snow_)
|
||||
this->write_random_();
|
||||
} else {
|
||||
lv_timer_handler_run_in_period(5);
|
||||
|
||||
@@ -151,7 +151,7 @@ class LvglComponent : public PollingComponent {
|
||||
|
||||
public:
|
||||
LvglComponent(std::vector<display::Display *> displays, float buffer_frac, bool full_refresh, int draw_rounding,
|
||||
bool resume_on_input);
|
||||
bool resume_on_input, bool update_when_display_idle);
|
||||
static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p);
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
|
||||
@@ -171,7 +171,9 @@ class LvglComponent : public PollingComponent {
|
||||
// @param paused If true, pause the display. If false, resume the display.
|
||||
// @param show_snow If true, show the snow effect when paused.
|
||||
void set_paused(bool paused, bool show_snow);
|
||||
bool is_paused() const { return this->paused_; }
|
||||
|
||||
// Returns true if the display is explicitly paused, or a blocking display update is in progress.
|
||||
bool is_paused() const;
|
||||
// If the display is paused and we have resume_on_input_ set to true, resume the display.
|
||||
void maybe_wakeup() {
|
||||
if (this->paused_ && this->resume_on_input_) {
|
||||
@@ -210,10 +212,10 @@ class LvglComponent : public PollingComponent {
|
||||
void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; }
|
||||
|
||||
protected:
|
||||
// these functions are never called unless the callbacks are non-null since the
|
||||
// LVGL callbacks that call them are not set unless the start/end callbacks are non-null
|
||||
void draw_end_();
|
||||
// Not checking for non-null callback since the
|
||||
// LVGL callback that calls it is not set in that case
|
||||
void draw_start_() const { this->draw_start_callback_->trigger(); }
|
||||
void draw_end_() const { this->draw_end_callback_->trigger(); }
|
||||
|
||||
void write_random_();
|
||||
void draw_buffer_(const lv_area_t *area, lv_color_t *ptr);
|
||||
@@ -222,6 +224,7 @@ class LvglComponent : public PollingComponent {
|
||||
size_t buffer_frac_{1};
|
||||
bool full_refresh_{};
|
||||
bool resume_on_input_{};
|
||||
bool update_when_display_idle_{};
|
||||
|
||||
lv_disp_draw_buf_t draw_buf_{};
|
||||
lv_disp_drv_t disp_drv_{};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
from esphome.components.time import RealTimeClock
|
||||
@@ -311,19 +313,36 @@ def automation_schema(typ: LvType):
|
||||
}
|
||||
|
||||
|
||||
def base_update_schema(widget_type, parts):
|
||||
def _update_widget(widget_type: WidgetType) -> Callable[[dict], dict]:
|
||||
"""
|
||||
Create a schema for updating a widgets style properties, states and flags
|
||||
During validation of update actions, create a map of action types to affected widgets
|
||||
for use in final validation.
|
||||
:param widget_type:
|
||||
:return:
|
||||
"""
|
||||
|
||||
def validator(value: dict) -> dict:
|
||||
df.get_data(df.KEY_UPDATED_WIDGETS).setdefault(widget_type, []).append(value)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def base_update_schema(widget_type: WidgetType | LvType, parts):
|
||||
"""
|
||||
Create a schema for updating a widget's style properties, states and flags.
|
||||
:param widget_type: The type of the ID
|
||||
:param parts: The allowable parts to specify
|
||||
:return:
|
||||
"""
|
||||
return part_schema(parts).extend(
|
||||
|
||||
w_type = widget_type.w_type if isinstance(widget_type, WidgetType) else widget_type
|
||||
schema = part_schema(parts).extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.ensure_list(
|
||||
cv.maybe_simple_value(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(widget_type),
|
||||
cv.Required(CONF_ID): cv.use_id(w_type),
|
||||
},
|
||||
key=CONF_ID,
|
||||
)
|
||||
@@ -332,11 +351,9 @@ def base_update_schema(widget_type, parts):
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_modify_schema(widget_type):
|
||||
return base_update_schema(widget_type.w_type, widget_type.parts).extend(
|
||||
widget_type.modify_schema
|
||||
)
|
||||
if isinstance(widget_type, WidgetType):
|
||||
schema.add_extra(_update_widget(widget_type))
|
||||
return schema
|
||||
|
||||
|
||||
def obj_schema(widget_type: WidgetType):
|
||||
|
||||
@@ -152,18 +152,18 @@ class WidgetType:
|
||||
|
||||
# Local import to avoid circular import
|
||||
from .automation import update_to_code
|
||||
from .schemas import WIDGET_TYPES, create_modify_schema
|
||||
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
|
||||
# Register the update action automatically, adding widget-specific properties
|
||||
register_action(
|
||||
f"lvgl.{self.name}.update",
|
||||
ObjUpdateAction,
|
||||
create_modify_schema(self),
|
||||
base_update_schema(self, self.parts).extend(self.modify_schema),
|
||||
)(update_to_code)
|
||||
|
||||
@property
|
||||
@@ -182,7 +182,6 @@ class WidgetType:
|
||||
Generate code for a given widget
|
||||
:param w: The widget
|
||||
:param config: Its configuration
|
||||
:return: Generated code as a list of text lines
|
||||
"""
|
||||
|
||||
async def obj_creator(self, parent: MockObjClass, config: dict):
|
||||
@@ -228,6 +227,15 @@ class WidgetType:
|
||||
"""
|
||||
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 NumberType(WidgetType):
|
||||
def get_max(self, config: dict):
|
||||
|
||||
@@ -382,7 +382,7 @@ async def set_obj_properties(w: Widget, config):
|
||||
clrs = join_enums(flag_clr, "LV_OBJ_FLAG_")
|
||||
w.clear_flag(clrs)
|
||||
for key, value in lambs.items():
|
||||
lamb = await cg.process_lambda(value, [], return_type=cg.bool_)
|
||||
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)
|
||||
@@ -407,7 +407,7 @@ async def set_obj_properties(w: Widget, config):
|
||||
clears = join_enums(clears, "LV_STATE_")
|
||||
w.clear_state(clears)
|
||||
for key, value in lambs.items():
|
||||
lamb = await cg.process_lambda(value, [], return_type=cg.bool_)
|
||||
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)
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
from esphome.const import CONF_BUTTON
|
||||
from esphome import config_validation as cv
|
||||
from esphome.const import CONF_BUTTON, CONF_TEXT
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
from ..defines import CONF_MAIN
|
||||
from ..defines import CONF_MAIN, CONF_WIDGETS
|
||||
from ..helpers import add_lv_use
|
||||
from ..lv_validation import lv_text
|
||||
from ..lvcode import lv, lv_expr
|
||||
from ..schemas import TEXT_SCHEMA
|
||||
from ..types import LvBoolean, WidgetType
|
||||
from . import Widget
|
||||
from .label import label_spec
|
||||
|
||||
lv_button_t = LvBoolean("lv_btn_t")
|
||||
|
||||
|
||||
class ButtonType(WidgetType):
|
||||
def __init__(self):
|
||||
super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), lv_name="btn")
|
||||
super().__init__(
|
||||
CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn"
|
||||
)
|
||||
|
||||
def validate(self, value):
|
||||
if CONF_TEXT in value:
|
||||
if CONF_WIDGETS in value:
|
||||
raise cv.Invalid("Cannot use both text and widgets in a button")
|
||||
add_lv_use("label")
|
||||
return value
|
||||
|
||||
def get_uses(self):
|
||||
return ("btn",)
|
||||
|
||||
async def to_code(self, w, config):
|
||||
return []
|
||||
def on_create(self, var: MockObj, config: dict):
|
||||
if CONF_TEXT in config:
|
||||
lv.label_create(var)
|
||||
return var
|
||||
|
||||
async def to_code(self, w: Widget, config):
|
||||
if text := config.get(CONF_TEXT):
|
||||
label_widget = Widget.create(
|
||||
None, lv_expr.obj_get_child(w.obj, 0), label_spec
|
||||
)
|
||||
await label_widget.set_property(CONF_TEXT, await lv_text.process(text))
|
||||
|
||||
def final_validate(self, widget, update_config, widget_config, path):
|
||||
if CONF_TEXT in update_config and CONF_TEXT not in widget_config:
|
||||
raise cv.Invalid(
|
||||
"Button must have 'text:' configured to allow updating text", path
|
||||
)
|
||||
|
||||
|
||||
button_spec = ButtonType()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import git, yaml_util
|
||||
@@ -20,18 +21,41 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = CONF_PACKAGES
|
||||
|
||||
|
||||
def validate_git_package(config: dict):
|
||||
if CONF_URL not in config:
|
||||
return config
|
||||
config = BASE_SCHEMA(config)
|
||||
new_config = config
|
||||
def valid_package_contents(package_config: dict):
|
||||
"""Validates that a package_config that will be merged looks as much as possible to a valid config
|
||||
to fail early on obvious mistakes."""
|
||||
if isinstance(package_config, dict):
|
||||
if CONF_URL in package_config:
|
||||
# If a URL key is found, then make sure the config conforms to a remote package schema:
|
||||
return REMOTE_PACKAGE_SCHEMA(package_config)
|
||||
|
||||
# Validate manually since Voluptuous would regenerate dicts and lose metadata
|
||||
# such as ESPHomeDataBase
|
||||
for k, v in package_config.items():
|
||||
if not isinstance(k, str):
|
||||
raise cv.Invalid("Package content keys must be strings")
|
||||
if isinstance(v, (dict, list)):
|
||||
continue # e.g. script: [] or logger: {level: debug}
|
||||
if v is None:
|
||||
continue # e.g. web_server:
|
||||
raise cv.Invalid("Invalid component content in package definition")
|
||||
return package_config
|
||||
|
||||
raise cv.Invalid("Package contents must be a dict")
|
||||
|
||||
|
||||
def expand_file_to_files(config: dict):
|
||||
if CONF_FILE in config:
|
||||
new_config = config
|
||||
new_config[CONF_FILES] = [config[CONF_FILE]]
|
||||
del new_config[CONF_FILE]
|
||||
return new_config
|
||||
return new_config
|
||||
return config
|
||||
|
||||
|
||||
def validate_yaml_filename(value):
|
||||
@@ -45,7 +69,7 @@ def validate_yaml_filename(value):
|
||||
|
||||
def validate_source_shorthand(value):
|
||||
if not isinstance(value, str):
|
||||
raise cv.Invalid("Shorthand only for strings")
|
||||
raise cv.Invalid("Git URL shorthand only for strings")
|
||||
|
||||
git_file = git.GitFile.from_shorthand(value)
|
||||
|
||||
@@ -56,10 +80,17 @@ def validate_source_shorthand(value):
|
||||
if git_file.ref:
|
||||
conf[CONF_REF] = git_file.ref
|
||||
|
||||
return BASE_SCHEMA(conf)
|
||||
return REMOTE_PACKAGE_SCHEMA(conf)
|
||||
|
||||
|
||||
BASE_SCHEMA = cv.All(
|
||||
def deprecate_single_package(config):
|
||||
_LOGGER.warning(
|
||||
"Including a single package under `packages:` is deprecated. Use a list instead."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
REMOTE_PACKAGE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
@@ -90,23 +121,30 @@ BASE_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_FILE, CONF_FILES),
|
||||
expand_file_to_files,
|
||||
)
|
||||
|
||||
PACKAGE_SCHEMA = cv.All(
|
||||
cv.Any(validate_source_shorthand, BASE_SCHEMA, dict), validate_git_package
|
||||
PACKAGE_SCHEMA = cv.Any( # A package definition is either:
|
||||
validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or
|
||||
REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or
|
||||
valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
|
||||
# which will have to be fully validated later as per each component's schema.
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Any(
|
||||
CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
|
||||
cv.Schema(
|
||||
{
|
||||
str: PACKAGE_SCHEMA,
|
||||
str: PACKAGE_SCHEMA, # a named dict of package definitions, or
|
||||
}
|
||||
),
|
||||
[PACKAGE_SCHEMA],
|
||||
[PACKAGE_SCHEMA], # a list of package definitions, or
|
||||
cv.All( # a single package definition (deprecated)
|
||||
cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _process_base_package(config: dict, skip_update: bool = False) -> dict:
|
||||
def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
|
||||
# When skip_update is True, use NEVER_REFRESH to prevent updates
|
||||
actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH]
|
||||
repo_dir, revert = git.clone_or_update(
|
||||
@@ -185,7 +223,7 @@ def _process_base_package(config: dict, skip_update: bool = False) -> dict:
|
||||
def _process_package(package_config, config, skip_update: bool = False):
|
||||
recursive_package = package_config
|
||||
if CONF_URL in package_config:
|
||||
package_config = _process_base_package(package_config, skip_update)
|
||||
package_config = _process_remote_package(package_config, skip_update)
|
||||
if isinstance(package_config, dict):
|
||||
recursive_package = do_packages_pass(package_config, skip_update)
|
||||
return merge_config(recursive_package, config)
|
||||
|
||||
@@ -141,6 +141,24 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name,
|
||||
EntityBase *obj, std::string &area, std::string &node,
|
||||
std::string &friendly_name) {
|
||||
#else
|
||||
void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj,
|
||||
std::string &area, std::string &node, std::string &friendly_name) {
|
||||
#endif
|
||||
stream->print(metric_name);
|
||||
stream->print(ESPHOME_F("{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
}
|
||||
|
||||
// Type-specific implementation
|
||||
#ifdef USE_SENSOR
|
||||
void PrometheusHandler::sensor_type_(AsyncResponseStream *stream) {
|
||||
@@ -303,13 +321,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat
|
||||
if (obj->is_internal() && !this->include_internal_)
|
||||
return;
|
||||
// State
|
||||
stream->print(ESPHOME_F("esphome_light_state{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\"} "));
|
||||
stream->print(obj->remote_values.is_on());
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
@@ -318,78 +330,45 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat
|
||||
float brightness, r, g, b, w;
|
||||
color.as_brightness(&brightness);
|
||||
color.as_rgbw(&r, &g, &b, &w);
|
||||
stream->print(ESPHOME_F("esphome_light_color{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",channel=\"brightness\"} "));
|
||||
stream->print(brightness);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
stream->print(ESPHOME_F("esphome_light_color{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",channel=\"r\"} "));
|
||||
stream->print(r);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
stream->print(ESPHOME_F("esphome_light_color{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",channel=\"g\"} "));
|
||||
stream->print(g);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
stream->print(ESPHOME_F("esphome_light_color{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",channel=\"b\"} "));
|
||||
stream->print(b);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
stream->print(ESPHOME_F("esphome_light_color{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",channel=\"w\"} "));
|
||||
stream->print(w);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
// Effect
|
||||
std::string effect = obj->get_effect_name();
|
||||
if (effect == "None") {
|
||||
stream->print(ESPHOME_F("esphome_light_effect_active{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
stream->print(ESPHOME_F("\",effect=\"None\"} 0\n"));
|
||||
} else {
|
||||
stream->print(ESPHOME_F("esphome_light_effect_active{id=\""));
|
||||
stream->print(relabel_id_(obj).c_str());
|
||||
add_area_label_(stream, area);
|
||||
add_node_label_(stream, node);
|
||||
add_friendly_name_label_(stream, friendly_name);
|
||||
stream->print(ESPHOME_F("\",name=\""));
|
||||
stream->print(relabel_name_(obj).c_str());
|
||||
if (obj->get_traits().supports_color_capability(light::ColorCapability::BRIGHTNESS)) {
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",channel=\"brightness\"} "));
|
||||
stream->print(brightness);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
}
|
||||
if (obj->get_traits().supports_color_capability(light::ColorCapability::RGB)) {
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",channel=\"r\"} "));
|
||||
stream->print(r);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",channel=\"g\"} "));
|
||||
stream->print(g);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",channel=\"b\"} "));
|
||||
stream->print(b);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
}
|
||||
if (obj->get_traits().supports_color_capability(light::ColorCapability::WHITE)) {
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",channel=\"w\"} "));
|
||||
stream->print(w);
|
||||
stream->print(ESPHOME_F("\n"));
|
||||
}
|
||||
// Skip effect metrics if light has no effects
|
||||
if (!obj->get_effects().empty()) {
|
||||
// Effect
|
||||
std::string effect = obj->get_effect_name();
|
||||
print_metric_labels_(stream, ESPHOME_F("esphome_light_effect_active"), obj, area, node, friendly_name);
|
||||
stream->print(ESPHOME_F("\",effect=\""));
|
||||
stream->print(effect.c_str());
|
||||
stream->print(ESPHOME_F("\"} 1\n"));
|
||||
// Only vary based on effect
|
||||
if (effect == "None") {
|
||||
stream->print(ESPHOME_F("None\"} 0\n"));
|
||||
} else {
|
||||
stream->print(effect.c_str());
|
||||
stream->print(ESPHOME_F("\"} 1\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -66,6 +66,14 @@ class PrometheusHandler : public AsyncWebHandler, public Component {
|
||||
void add_area_label_(AsyncResponseStream *stream, std::string &area);
|
||||
void add_node_label_(AsyncResponseStream *stream, std::string &node);
|
||||
void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name);
|
||||
/// Print metric name and common labels (id, area, node, friendly_name, name)
|
||||
#ifdef USE_ESP8266
|
||||
void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj,
|
||||
std::string &area, std::string &node, std::string &friendly_name);
|
||||
#else
|
||||
void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area,
|
||||
std::string &node, std::string &friendly_name);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
/// Return the type for prometheus
|
||||
|
||||
@@ -654,7 +654,7 @@ void ThermostatClimate::trigger_supplemental_action_() {
|
||||
|
||||
void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction action) {
|
||||
// setup_complete_ helps us ensure an action is called immediately after boot
|
||||
if ((action == this->humidification_action_) && this->setup_complete_) {
|
||||
if ((action == this->humidification_action) && this->setup_complete_) {
|
||||
// already in target mode
|
||||
return;
|
||||
}
|
||||
@@ -683,7 +683,7 @@ void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction
|
||||
this->prev_humidity_control_trigger_->stop_action();
|
||||
this->prev_humidity_control_trigger_ = nullptr;
|
||||
}
|
||||
this->humidification_action_ = action;
|
||||
this->humidification_action = action;
|
||||
this->prev_humidity_control_trigger_ = trig;
|
||||
if (trig != nullptr) {
|
||||
trig->trigger();
|
||||
@@ -1114,7 +1114,7 @@ bool ThermostatClimate::dehumidification_required_() {
|
||||
}
|
||||
// if we get here, the current humidity is between target + hysteresis and target - hysteresis,
|
||||
// so the action should not change
|
||||
return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY;
|
||||
return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY;
|
||||
}
|
||||
|
||||
bool ThermostatClimate::humidification_required_() {
|
||||
@@ -1127,7 +1127,7 @@ bool ThermostatClimate::humidification_required_() {
|
||||
}
|
||||
// if we get here, the current humidity is between target - hysteresis and target + hysteresis,
|
||||
// so the action should not change
|
||||
return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY;
|
||||
return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY;
|
||||
}
|
||||
|
||||
void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config) {
|
||||
|
||||
@@ -207,6 +207,9 @@ class ThermostatClimate : public climate::Climate, public Component {
|
||||
void validate_target_temperature_high();
|
||||
void validate_target_humidity();
|
||||
|
||||
/// The current humidification action
|
||||
HumidificationAction humidification_action{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE};
|
||||
|
||||
protected:
|
||||
/// Override control to change settings of the climate device.
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
@@ -301,9 +304,6 @@ class ThermostatClimate : public climate::Climate, public Component {
|
||||
/// The current supplemental action
|
||||
climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF};
|
||||
|
||||
/// The current humidification action
|
||||
HumidificationAction humidification_action_{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE};
|
||||
|
||||
/// Default standard preset to use on start up
|
||||
climate::ClimatePreset default_preset_{};
|
||||
|
||||
|
||||
+13
-13
@@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
std::unique_ptr<SchedulerItem> item;
|
||||
{
|
||||
LockGuard guard{this->lock_};
|
||||
item = std::move(this->items_[0]);
|
||||
this->pop_raw_();
|
||||
item = this->pop_raw_locked_();
|
||||
}
|
||||
|
||||
const char *name = item->get_name();
|
||||
@@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
// Don't run on failed components
|
||||
if (item->component != nullptr && item->component->is_failed()) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->pop_raw_();
|
||||
this->recycle_item_(this->pop_raw_locked_());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
{
|
||||
LockGuard guard{this->lock_};
|
||||
if (is_item_removed_(item.get())) {
|
||||
this->pop_raw_();
|
||||
this->recycle_item_(this->pop_raw_locked_());
|
||||
this->to_remove_--;
|
||||
continue;
|
||||
}
|
||||
@@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
// Single-threaded or multi-threaded with atomics: can check without lock
|
||||
if (is_item_removed_(item.get())) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->pop_raw_();
|
||||
this->recycle_item_(this->pop_raw_locked_());
|
||||
this->to_remove_--;
|
||||
continue;
|
||||
}
|
||||
@@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) {
|
||||
|
||||
LockGuard guard{this->lock_};
|
||||
|
||||
auto executed_item = std::move(this->items_[0]);
|
||||
// Only pop after function call, this ensures we were reachable
|
||||
// during the function call and know if we were cancelled.
|
||||
this->pop_raw_();
|
||||
auto executed_item = this->pop_raw_locked_();
|
||||
|
||||
if (executed_item->remove) {
|
||||
// We were removed/cancelled in the function call, stop
|
||||
// We were removed/cancelled in the function call, recycle and continue
|
||||
this->to_remove_--;
|
||||
this->recycle_item_(std::move(executed_item));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() {
|
||||
return this->items_.size();
|
||||
|
||||
// We must hold the lock for the entire cleanup operation because:
|
||||
// 1. We're modifying items_ (via pop_raw_) which requires exclusive access
|
||||
// 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
|
||||
// 2. We're decrementing to_remove_ which is also modified by other threads
|
||||
// (though all modifications are already under lock)
|
||||
// 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
|
||||
@@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() {
|
||||
if (!item->remove)
|
||||
break;
|
||||
this->to_remove_--;
|
||||
this->pop_raw_();
|
||||
this->recycle_item_(this->pop_raw_locked_());
|
||||
}
|
||||
return this->items_.size();
|
||||
}
|
||||
void HOT Scheduler::pop_raw_() {
|
||||
std::unique_ptr<Scheduler::SchedulerItem> HOT Scheduler::pop_raw_locked_() {
|
||||
std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
|
||||
// Instead of destroying, recycle the item
|
||||
this->recycle_item_(std::move(this->items_.back()));
|
||||
// Move the item out before popping - this is the item that was at the front of the heap
|
||||
auto item = std::move(this->items_.back());
|
||||
|
||||
this->items_.pop_back();
|
||||
return item;
|
||||
}
|
||||
|
||||
// Helper to execute a scheduler item
|
||||
|
||||
@@ -218,7 +218,9 @@ class Scheduler {
|
||||
// Returns the number of items remaining after cleanup
|
||||
// IMPORTANT: This method should only be called from the main thread (loop task).
|
||||
size_t cleanup_();
|
||||
void pop_raw_();
|
||||
// Remove and return the front item from the heap
|
||||
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
|
||||
std::unique_ptr<SchedulerItem> pop_raw_locked_();
|
||||
|
||||
private:
|
||||
// Helper to cancel items by name - must be called with lock held
|
||||
|
||||
+7
-178
@@ -19,21 +19,11 @@ from esphome.core import (
|
||||
TimePeriodNanoseconds,
|
||||
TimePeriodSeconds,
|
||||
)
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last
|
||||
from esphome.types import Expression, SafeExpType, TemplateArgsType
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import ESPHomeDataBase
|
||||
|
||||
# Keys for lambda deduplication storage in CORE.data
|
||||
_KEY_LAMBDA_DEDUP = "lambda_dedup"
|
||||
_KEY_LAMBDA_DEDUP_DECLARATIONS = "lambda_dedup_declarations"
|
||||
|
||||
# Regex patterns for static variable detection (compiled once)
|
||||
_RE_CPP_SINGLE_LINE_COMMENT = re.compile(r"//.*?$", re.MULTILINE)
|
||||
_RE_CPP_MULTI_LINE_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_RE_STATIC_VARIABLE = re.compile(r"\bstatic\s+(?!cast|assert|pointer_cast)\w+\s+\w+")
|
||||
|
||||
|
||||
class RawExpression(Expression):
|
||||
__slots__ = ("text",)
|
||||
@@ -198,7 +188,7 @@ class LambdaExpression(Expression):
|
||||
|
||||
def __init__(
|
||||
self, parts, parameters, capture: str = "=", return_type=None, source=None
|
||||
) -> None:
|
||||
):
|
||||
self.parts = parts
|
||||
if not isinstance(parameters, ParameterListExpression):
|
||||
parameters = ParameterListExpression(*parameters)
|
||||
@@ -207,21 +197,16 @@ class LambdaExpression(Expression):
|
||||
self.capture = capture
|
||||
self.return_type = safe_exp(return_type) if return_type is not None else None
|
||||
|
||||
def format_body(self) -> str:
|
||||
"""Format the lambda body with source directive and content."""
|
||||
body = ""
|
||||
if self.source is not None:
|
||||
body += f"{self.source.as_line_directive}\n"
|
||||
body += self.content
|
||||
return body
|
||||
|
||||
def __str__(self) -> str:
|
||||
def __str__(self):
|
||||
# Stateless lambdas (empty capture) implicitly convert to function pointers
|
||||
# when assigned to function pointer types - no unary + needed
|
||||
cpp = f"[{self.capture}]({self.parameters})"
|
||||
if self.return_type is not None:
|
||||
cpp += f" -> {self.return_type}"
|
||||
cpp += f" {{\n{self.format_body()}\n}}"
|
||||
cpp += " {\n"
|
||||
if self.source is not None:
|
||||
cpp += f"{self.source.as_line_directive}\n"
|
||||
cpp += f"{self.content}\n}}"
|
||||
return indent_all_but_first_and_last(cpp)
|
||||
|
||||
@property
|
||||
@@ -229,37 +214,6 @@ class LambdaExpression(Expression):
|
||||
return "".join(str(part) for part in self.parts)
|
||||
|
||||
|
||||
class SharedFunctionLambdaExpression(LambdaExpression):
|
||||
"""A lambda expression that references a shared deduplicated function.
|
||||
|
||||
This class wraps a function pointer but maintains the LambdaExpression
|
||||
interface so calling code works unchanged.
|
||||
"""
|
||||
|
||||
__slots__ = ("_func_name",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func_name: str,
|
||||
parameters: TemplateArgsType,
|
||||
return_type: SafeExpType | None = None,
|
||||
) -> None:
|
||||
# Initialize parent with empty parts since we're just a function reference
|
||||
super().__init__(
|
||||
[], parameters, capture="", return_type=return_type, source=None
|
||||
)
|
||||
self._func_name = func_name
|
||||
|
||||
def __str__(self) -> str:
|
||||
# Just return the function name - it's already a function pointer
|
||||
return self._func_name
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
# No content, just a function reference
|
||||
return ""
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class Literal(Expression, metaclass=abc.ABCMeta):
|
||||
__slots__ = ()
|
||||
@@ -629,25 +583,6 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
|
||||
CORE.add_global(expression, prepend)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def flush_lambda_dedup_declarations() -> None:
|
||||
"""Flush all deferred lambda deduplication declarations to global scope.
|
||||
|
||||
This is a coroutine that runs with FINAL priority (after all components)
|
||||
to ensure all referenced variables are declared before the shared
|
||||
lambda functions that use them.
|
||||
"""
|
||||
if _KEY_LAMBDA_DEDUP_DECLARATIONS not in CORE.data:
|
||||
return
|
||||
|
||||
declarations = CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS]
|
||||
for func_declaration in declarations:
|
||||
add_global(RawStatement(func_declaration))
|
||||
|
||||
# Clear the list so we don't add them again
|
||||
CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS] = []
|
||||
|
||||
|
||||
def add_library(name: str, version: str | None, repository: str | None = None):
|
||||
"""Add a library to the codegen library storage.
|
||||
|
||||
@@ -721,97 +656,10 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]:
|
||||
return await CORE.get_variable_with_full_id(id_)
|
||||
|
||||
|
||||
def _has_static_variables(code: str) -> bool:
|
||||
"""Check if code contains static variable definitions.
|
||||
|
||||
Static variables in lambdas should not be deduplicated because each lambda
|
||||
instance should have its own static variable state.
|
||||
|
||||
Args:
|
||||
code: The lambda body code to check
|
||||
|
||||
Returns:
|
||||
True if code contains static variable definitions
|
||||
"""
|
||||
# Remove C++ comments to avoid false positives
|
||||
# Remove single-line comments (// ...)
|
||||
code_no_comments = _RE_CPP_SINGLE_LINE_COMMENT.sub("", code)
|
||||
# Remove multi-line comments (/* ... */)
|
||||
code_no_comments = _RE_CPP_MULTI_LINE_COMMENT.sub("", code_no_comments)
|
||||
|
||||
# Match: static <type> <identifier>
|
||||
# But not: static_cast, static_assert, static_pointer_cast
|
||||
return bool(_RE_STATIC_VARIABLE.search(code_no_comments))
|
||||
|
||||
|
||||
def _get_shared_lambda_name(lambda_expr: LambdaExpression) -> str | None:
|
||||
"""Get the shared function name for a lambda expression.
|
||||
|
||||
If an identical lambda was already generated, returns the existing shared
|
||||
function name. Otherwise, creates a new shared function and returns its name.
|
||||
|
||||
Lambdas with static variables are not deduplicated to preserve their
|
||||
independent state.
|
||||
|
||||
Args:
|
||||
lambda_expr: The lambda expression to deduplicate
|
||||
|
||||
Returns:
|
||||
The name of the shared function for this lambda (either existing or newly created),
|
||||
or None if the lambda should not be deduplicated (e.g., contains static variables)
|
||||
"""
|
||||
# Create a unique key from the lambda content, parameters, and return type
|
||||
content = lambda_expr.content
|
||||
|
||||
# Don't deduplicate lambdas with static variables - each instance needs its own state
|
||||
if _has_static_variables(content):
|
||||
return None
|
||||
param_str = str(lambda_expr.parameters)
|
||||
return_str = (
|
||||
str(lambda_expr.return_type) if lambda_expr.return_type is not None else "void"
|
||||
)
|
||||
|
||||
# Use tuple of (content, params, return_type) as key
|
||||
lambda_key = (content, param_str, return_str)
|
||||
|
||||
# Initialize deduplication storage in CORE.data if not exists
|
||||
if _KEY_LAMBDA_DEDUP not in CORE.data:
|
||||
CORE.data[_KEY_LAMBDA_DEDUP] = {}
|
||||
# Register the flush job to run after all components (FINAL priority)
|
||||
# This ensures all variables are declared before shared lambda functions
|
||||
CORE.add_job(flush_lambda_dedup_declarations)
|
||||
|
||||
lambda_cache = CORE.data[_KEY_LAMBDA_DEDUP]
|
||||
|
||||
# Check if we've seen this lambda before
|
||||
if lambda_key in lambda_cache:
|
||||
# Return name of existing shared function
|
||||
return lambda_cache[lambda_key]
|
||||
|
||||
# First occurrence - create a shared function
|
||||
# Use the cache size as the function number
|
||||
func_name = f"shared_lambda_{len(lambda_cache)}"
|
||||
|
||||
# Build the function declaration using lambda's body formatting
|
||||
func_declaration = (
|
||||
f"{return_str} {func_name}({param_str}) {{\n{lambda_expr.format_body()}\n}}"
|
||||
)
|
||||
|
||||
# Store the declaration to be added later (after all variable declarations)
|
||||
# We can't add it immediately because it might reference variables not yet declared
|
||||
CORE.data.setdefault(_KEY_LAMBDA_DEDUP_DECLARATIONS, []).append(func_declaration)
|
||||
|
||||
# Store in cache
|
||||
lambda_cache[lambda_key] = func_name
|
||||
|
||||
# Return the function name (this is the first occurrence, but we still generate shared function)
|
||||
return func_name
|
||||
|
||||
|
||||
async def process_lambda(
|
||||
value: Lambda,
|
||||
parameters: TemplateArgsType,
|
||||
capture: str = "=",
|
||||
capture: str = "",
|
||||
return_type: SafeExpType = None,
|
||||
) -> LambdaExpression | None:
|
||||
"""Process the given lambda value into a LambdaExpression.
|
||||
@@ -854,30 +702,11 @@ async def process_lambda(
|
||||
parts[i * 3 + 1] = var
|
||||
parts[i * 3 + 2] = ""
|
||||
|
||||
# All id() references are global variables in generated C++ code.
|
||||
# Global variables should not be captured - they're accessible everywhere.
|
||||
# Use empty capture instead of capture-by-value.
|
||||
if capture == "=":
|
||||
capture = ""
|
||||
|
||||
if isinstance(value, ESPHomeDataBase) and value.esp_range is not None:
|
||||
location = value.esp_range.start_mark
|
||||
location.line += value.content_offset
|
||||
else:
|
||||
location = None
|
||||
|
||||
# Lambda deduplication: Only deduplicate stateless lambdas (empty capture).
|
||||
# Stateful lambdas cannot be shared as they capture different contexts.
|
||||
# Lambdas with static variables are also not deduplicated to preserve independent state.
|
||||
if capture == "":
|
||||
lambda_expr = LambdaExpression(
|
||||
parts, parameters, capture, return_type, location
|
||||
)
|
||||
func_name = _get_shared_lambda_name(lambda_expr)
|
||||
if func_name is not None:
|
||||
# Return a shared function reference instead of inline lambda
|
||||
return SharedFunctionLambdaExpression(func_name, parameters, return_type)
|
||||
|
||||
return LambdaExpression(parts, parameters, capture, return_type, location)
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package",
|
||||
"packages",
|
||||
[
|
||||
{"package1": "github://esphome/non-existant-repo/file1.yml@main"},
|
||||
{"package2": "github://esphome/non-existant-repo/file1.yml"},
|
||||
@@ -107,12 +107,12 @@ def test_package_invalid_dict(basic_esphome, basic_wifi):
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_package_shorthand(package):
|
||||
CONFIG_SCHEMA(package)
|
||||
def test_package_shorthand(packages):
|
||||
CONFIG_SCHEMA(packages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package",
|
||||
"packages",
|
||||
[
|
||||
# not github
|
||||
{"package1": "someplace://esphome/non-existant-repo/file1.yml@main"},
|
||||
@@ -133,9 +133,9 @@ def test_package_shorthand(package):
|
||||
[3],
|
||||
],
|
||||
)
|
||||
def test_package_invalid(package):
|
||||
def test_package_invalid(packages):
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA(package)
|
||||
CONFIG_SCHEMA(packages)
|
||||
|
||||
|
||||
def test_package_include(basic_wifi, basic_esphome):
|
||||
@@ -155,6 +155,33 @@ def test_package_include(basic_wifi, basic_esphome):
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_single_package(
|
||||
basic_esphome,
|
||||
basic_wifi,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""
|
||||
Tests the simple case where a single package is added to the top-level config as is.
|
||||
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
|
||||
This tests the case where the user just put packages: !include package.yaml, not
|
||||
part of a list or mapping of packages.
|
||||
This behavior is deprecated, the test also checks if a warning is issued.
|
||||
"""
|
||||
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}}
|
||||
|
||||
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual == expected
|
||||
|
||||
assert (
|
||||
"Including a single package under `packages:` is deprecated. Use a list instead."
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_package_append(basic_wifi, basic_esphome):
|
||||
"""
|
||||
Tests the case where a key is present in both a package and top-level config.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Tests for the text component."""
|
||||
|
||||
from esphome.core import CORE
|
||||
"""Tests for the binary sensor component."""
|
||||
|
||||
|
||||
def test_text_is_setup(generate_main):
|
||||
@@ -58,22 +56,15 @@ def test_text_config_value_mode_set(generate_main):
|
||||
assert "it_3->traits.set_mode(text::TEXT_MODE_PASSWORD);" in main_cpp
|
||||
|
||||
|
||||
def test_text_config_lambda_is_set(generate_main) -> None:
|
||||
def test_text_config_lamda_is_set(generate_main):
|
||||
"""
|
||||
Test if lambda is set for lambda mode (optimized with stateless lambda and deduplication)
|
||||
Test if lambda is set for lambda mode (optimized with stateless lambda)
|
||||
"""
|
||||
# Given
|
||||
|
||||
# When
|
||||
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
|
||||
|
||||
# Get both global and main sections to find the shared lambda definition
|
||||
full_cpp = CORE.cpp_global_section + main_cpp
|
||||
|
||||
# Then
|
||||
# Lambda is deduplicated into a shared function (reference in main section)
|
||||
assert "it_4->set_template(shared_lambda_" in main_cpp
|
||||
# Lambda body should be in the code somewhere
|
||||
assert 'return std::string{"Hello"};' in full_cpp
|
||||
# Verify the shared lambda function is defined (in global section)
|
||||
assert "esphome::optional<std::string> shared_lambda_" in full_cpp
|
||||
assert "it_4->set_template([]() -> esphome::optional<std::string> {" in main_cpp
|
||||
assert 'return std::string{"Hello"};' in main_cpp
|
||||
|
||||
@@ -16,6 +16,18 @@ binary_sensor:
|
||||
platform: template
|
||||
- id: left_sensor
|
||||
platform: template
|
||||
- platform: lvgl
|
||||
id: button_checker
|
||||
name: LVGL button
|
||||
widget: button_button
|
||||
on_state:
|
||||
then:
|
||||
- lvgl.checkbox.update:
|
||||
id: checkbox_id
|
||||
state:
|
||||
checked: !lambda |-
|
||||
auto y = x; // block inlining of one line return
|
||||
return y;
|
||||
|
||||
lvgl:
|
||||
log_level: debug
|
||||
@@ -414,6 +426,14 @@ lvgl:
|
||||
logger.log: Long pressed repeated
|
||||
- buttons:
|
||||
- id: button_e
|
||||
- button:
|
||||
id: button_with_text
|
||||
text: Button
|
||||
on_click:
|
||||
lvgl.button.update:
|
||||
id: button_with_text
|
||||
text: Clicked
|
||||
|
||||
- button:
|
||||
layout: 2x1
|
||||
id: button_button
|
||||
|
||||
@@ -60,6 +60,7 @@ display:
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
update_when_display_idle: true
|
||||
displays:
|
||||
- tft_display
|
||||
- second_display
|
||||
|
||||
@@ -112,6 +112,25 @@ cover:
|
||||
}
|
||||
return COVER_CLOSED;
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
name: "Binary Light"
|
||||
output: test_output
|
||||
- platform: monochromatic
|
||||
name: "Brightness Light"
|
||||
output: test_output
|
||||
- platform: rgb
|
||||
name: "RGB Light"
|
||||
red: test_output
|
||||
green: test_output
|
||||
blue: test_output
|
||||
- platform: rgbw
|
||||
name: "RGBW Light"
|
||||
red: test_output
|
||||
green: test_output
|
||||
blue: test_output
|
||||
white: test_output
|
||||
|
||||
lock:
|
||||
- platform: template
|
||||
id: template_lock1
|
||||
@@ -122,6 +141,14 @@ lock:
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
optimistic: true
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: test_output
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: |-
|
||||
// no-op for CI/build tests
|
||||
(void)state;
|
||||
select:
|
||||
- platform: template
|
||||
id: template_select1
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"""Tests for lambda deduplication in cpp_generator."""
|
||||
|
||||
from esphome import cpp_generator as cg
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_deduplicate_identical_lambdas() -> None:
|
||||
"""Test that identical stateless lambdas are deduplicated."""
|
||||
# Create two identical lambda expressions
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
# Try to deduplicate them
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
# Both should get the same function name (deduplication happened)
|
||||
assert func_name1 == func_name2
|
||||
assert func_name1 == "shared_lambda_0"
|
||||
|
||||
|
||||
def test_different_lambdas_not_deduplicated() -> None:
|
||||
"""Test that different lambdas get different function names."""
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["return 24;"], # Different content
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
# Different lambdas should get different function names
|
||||
assert func_name1 != func_name2
|
||||
assert func_name1 == "shared_lambda_0"
|
||||
assert func_name2 == "shared_lambda_1"
|
||||
|
||||
|
||||
def test_different_return_types_not_deduplicated() -> None:
|
||||
"""Test that lambdas with different return types are not deduplicated."""
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["return 42;"], # Same content
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("float"), # Different return type
|
||||
)
|
||||
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
# Different return types = different functions
|
||||
assert func_name1 != func_name2
|
||||
|
||||
|
||||
def test_different_parameters_not_deduplicated() -> None:
|
||||
"""Test that lambdas with different parameters are not deduplicated."""
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return x;"],
|
||||
parameters=[("int", "x")],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["return x;"], # Same content
|
||||
parameters=[("float", "x")], # Different parameter type
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
# Different parameters = different functions
|
||||
assert func_name1 != func_name2
|
||||
|
||||
|
||||
def test_flush_lambda_dedup_declarations() -> None:
|
||||
"""Test that deferred declarations are properly stored for later flushing."""
|
||||
# Create a lambda which will create a deferred declaration
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
cg._get_shared_lambda_name(lambda1)
|
||||
|
||||
# Check that declaration was stored
|
||||
assert cg._KEY_LAMBDA_DEDUP_DECLARATIONS in CORE.data
|
||||
assert len(CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS]) == 1
|
||||
|
||||
# Verify the declaration content is correct
|
||||
declaration = CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS][0]
|
||||
assert "shared_lambda_0" in declaration
|
||||
assert "return 42;" in declaration
|
||||
|
||||
# Note: The actual flushing happens via CORE.add_job with FINAL priority
|
||||
# during real code generation, so we don't test that here
|
||||
|
||||
|
||||
def test_shared_function_lambda_expression() -> None:
|
||||
"""Test SharedFunctionLambdaExpression behaves correctly."""
|
||||
shared_lambda = cg.SharedFunctionLambdaExpression(
|
||||
func_name="shared_lambda_0",
|
||||
parameters=[],
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
# Should output just the function name
|
||||
assert str(shared_lambda) == "shared_lambda_0"
|
||||
|
||||
# Should have empty capture (stateless)
|
||||
assert shared_lambda.capture == ""
|
||||
|
||||
# Should have empty content (just a reference)
|
||||
assert shared_lambda.content == ""
|
||||
|
||||
|
||||
def test_lambda_deduplication_counter() -> None:
|
||||
"""Test that lambda counter increments correctly."""
|
||||
# Create 3 different lambdas
|
||||
for i in range(3):
|
||||
lambda_expr = cg.LambdaExpression(
|
||||
parts=[f"return {i};"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
func_name = cg._get_shared_lambda_name(lambda_expr)
|
||||
assert func_name == f"shared_lambda_{i}"
|
||||
|
||||
|
||||
def test_lambda_format_body() -> None:
|
||||
"""Test that format_body correctly formats lambda body with source."""
|
||||
# Without source
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["return 42;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=None,
|
||||
source=None,
|
||||
)
|
||||
assert lambda1.format_body() == "return 42;"
|
||||
|
||||
# With source would need a proper source object, skip for now
|
||||
|
||||
|
||||
def test_stateful_lambdas_not_deduplicated() -> None:
|
||||
"""Test that stateful lambdas (non-empty capture) are not deduplicated."""
|
||||
# _get_shared_lambda_name is only called for stateless lambdas (capture == "")
|
||||
# Stateful lambdas bypass deduplication entirely in process_lambda
|
||||
|
||||
# Verify that a stateful lambda would NOT get deduplicated
|
||||
# by checking it's not in the stateless dedup cache
|
||||
stateful_lambda = cg.LambdaExpression(
|
||||
parts=["return x + y;"],
|
||||
parameters=[],
|
||||
capture="=", # Non-empty capture means stateful
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
# Stateful lambdas should NOT be passed to _get_shared_lambda_name
|
||||
# This is enforced by the `if capture == ""` check in process_lambda
|
||||
# We verify the lambda has a non-empty capture
|
||||
assert stateful_lambda.capture != ""
|
||||
assert stateful_lambda.capture == "="
|
||||
|
||||
|
||||
def test_static_variable_detection() -> None:
|
||||
"""Test detection of static variables in lambda code."""
|
||||
# Should detect static variables
|
||||
assert cg._has_static_variables("static int counter = 0;")
|
||||
assert cg._has_static_variables("static bool flag = false; return flag;")
|
||||
assert cg._has_static_variables(" static float value = 1.0; ")
|
||||
|
||||
# Should NOT detect static_cast, static_assert, etc. (with underscores)
|
||||
assert not cg._has_static_variables("return static_cast<int>(value);")
|
||||
assert not cg._has_static_variables("static_assert(sizeof(int) == 4);")
|
||||
assert not cg._has_static_variables("auto ptr = static_pointer_cast<Foo>(bar);")
|
||||
|
||||
# Edge case: 'cast', 'assert', 'pointer_cast' are NOT C++ keywords
|
||||
# Someone could use them as type names, but we should NOT flag them
|
||||
# because they're not actually static variables with state
|
||||
# NOTE: These are valid C++ but extremely unlikely in ESPHome lambdas
|
||||
assert not cg._has_static_variables("static cast obj;") # 'cast' as type name
|
||||
assert not cg._has_static_variables("static assert value;") # 'assert' as type name
|
||||
assert not cg._has_static_variables(
|
||||
"static pointer_cast ptr;"
|
||||
) # 'pointer_cast' as type
|
||||
|
||||
# Should NOT detect in comments
|
||||
assert not cg._has_static_variables("// static int x = 0;\nreturn 42;")
|
||||
assert not cg._has_static_variables("/* static int y = 0; */ return 42;")
|
||||
|
||||
# Should detect even with comments elsewhere
|
||||
assert cg._has_static_variables("// comment\nstatic int x = 0;\nreturn x;")
|
||||
|
||||
# Should NOT detect non-static code
|
||||
assert not cg._has_static_variables("int counter = 0; return counter++;")
|
||||
assert not cg._has_static_variables("return 42;")
|
||||
|
||||
# Should handle newlines between static and type/variable
|
||||
assert cg._has_static_variables("static int\nfoo = 0;")
|
||||
assert cg._has_static_variables("static\nint\nbar = 0;")
|
||||
assert cg._has_static_variables(
|
||||
"static int \n foo = 0;"
|
||||
) # Mixed spaces/newlines
|
||||
|
||||
|
||||
def test_lambdas_with_static_not_deduplicated() -> None:
|
||||
"""Test that lambdas with static variables are not deduplicated."""
|
||||
# Two identical lambdas with static variables
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["static int counter = 0; return counter++;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["static int counter = 0; return counter++;"],
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
# Should return None (not deduplicated)
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
assert func_name1 is None
|
||||
assert func_name2 is None
|
||||
|
||||
|
||||
def test_lambdas_without_static_still_deduplicated() -> None:
|
||||
"""Test that lambdas without static variables are still deduplicated."""
|
||||
# Two identical lambdas WITHOUT static variables
|
||||
lambda1 = cg.LambdaExpression(
|
||||
parts=["int counter = 0; return counter++;"], # No static
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
lambda2 = cg.LambdaExpression(
|
||||
parts=["int counter = 0; return counter++;"], # No static
|
||||
parameters=[],
|
||||
capture="",
|
||||
return_type=cg.RawExpression("int"),
|
||||
)
|
||||
|
||||
# Should be deduplicated (same function name)
|
||||
func_name1 = cg._get_shared_lambda_name(lambda1)
|
||||
func_name2 = cg._get_shared_lambda_name(lambda2)
|
||||
|
||||
assert func_name1 is not None
|
||||
assert func_name2 is not None
|
||||
assert func_name1 == func_name2
|
||||
Reference in New Issue
Block a user