mirror of
https://github.com/esphome/esphome.git
synced 2024-11-24 12:06:26 +01:00
[lvgl] Allow multiple LVGL instances (#7712)
Co-authored-by: clydeps <U5yx99dok9>
This commit is contained in:
parent
80b4c26481
commit
248b0bc378
@ -27,7 +27,7 @@ from .encoders import ENCODERS_CONFIG, encoders_to_code, initial_focus_to_code
|
||||
from .gradient import GRADIENT_SCHEMA, gradients_to_code
|
||||
from .hello_world import get_hello_world
|
||||
from .lv_validation import lv_bool, lv_images_used
|
||||
from .lvcode import LvContext, LvglComponent
|
||||
from .lvcode import LvContext, LvglComponent, lvgl_static
|
||||
from .schemas import (
|
||||
DISP_BG_SCHEMA,
|
||||
FLEX_OBJ_SCHEMA,
|
||||
@ -152,41 +152,70 @@ def generate_lv_conf_h():
|
||||
return LV_CONF_H_FORMAT.format("\n".join(definitions))
|
||||
|
||||
|
||||
def final_validation(config):
|
||||
if pages := config.get(CONF_PAGES):
|
||||
if all(p[df.CONF_SKIP] for p in pages):
|
||||
raise cv.Invalid("At least one page must not be skipped")
|
||||
def multi_conf_validate(configs: list[dict]):
|
||||
displays = [config[df.CONF_DISPLAYS] for config in configs]
|
||||
# flatten the display list
|
||||
display_list = [disp for disps in displays for disp in disps]
|
||||
if len(display_list) != len(set(display_list)):
|
||||
raise cv.Invalid("A display ID may be used in only one LVGL instance")
|
||||
base_config = configs[0]
|
||||
for config in configs[1:]:
|
||||
for item in (
|
||||
df.CONF_LOG_LEVEL,
|
||||
df.CONF_COLOR_DEPTH,
|
||||
df.CONF_BYTE_ORDER,
|
||||
df.CONF_TRANSPARENCY_KEY,
|
||||
):
|
||||
if base_config[item] != config[item]:
|
||||
raise cv.Invalid(
|
||||
f"Config item '{item}' must be the same for all LVGL instances"
|
||||
)
|
||||
|
||||
|
||||
def final_validation(configs):
|
||||
multi_conf_validate(configs)
|
||||
global_config = full_config.get()
|
||||
for display_id in config[df.CONF_DISPLAYS]:
|
||||
path = global_config.get_path_for_id(display_id)[:-1]
|
||||
display = global_config.get_config_for_path(path)
|
||||
if CONF_LAMBDA in display:
|
||||
raise cv.Invalid("Using lambda: in display config not compatible with LVGL")
|
||||
if display[CONF_AUTO_CLEAR_ENABLED]:
|
||||
raise cv.Invalid(
|
||||
"Using auto_clear_enabled: true in display config not compatible with LVGL"
|
||||
)
|
||||
buffer_frac = config[CONF_BUFFER_SIZE]
|
||||
if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config:
|
||||
LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
|
||||
for image_id in lv_images_used:
|
||||
path = global_config.get_path_for_id(image_id)[:-1]
|
||||
image_conf = global_config.get_config_for_path(path)
|
||||
if image_conf[CONF_TYPE] in ("RGBA", "RGB24"):
|
||||
raise cv.Invalid(
|
||||
"Using RGBA or RGB24 in image config not compatible with LVGL", path
|
||||
)
|
||||
for w in focused_widgets:
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]:
|
||||
raise cv.Invalid(
|
||||
"A non adjustable arc may not be focused",
|
||||
path,
|
||||
)
|
||||
for config in configs:
|
||||
if pages := config.get(CONF_PAGES):
|
||||
if 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]:
|
||||
path = global_config.get_path_for_id(display_id)[:-1]
|
||||
display = global_config.get_config_for_path(path)
|
||||
if CONF_LAMBDA in display:
|
||||
raise cv.Invalid(
|
||||
"Using lambda: in display config not compatible with LVGL"
|
||||
)
|
||||
if display[CONF_AUTO_CLEAR_ENABLED]:
|
||||
raise cv.Invalid(
|
||||
"Using auto_clear_enabled: true in display config not compatible with LVGL"
|
||||
)
|
||||
buffer_frac = config[CONF_BUFFER_SIZE]
|
||||
if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config:
|
||||
LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
|
||||
for image_id in lv_images_used:
|
||||
path = global_config.get_path_for_id(image_id)[:-1]
|
||||
image_conf = global_config.get_config_for_path(path)
|
||||
if image_conf[CONF_TYPE] in ("RGBA", "RGB24"):
|
||||
raise cv.Invalid(
|
||||
"Using RGBA or RGB24 in image config not compatible with LVGL", path
|
||||
)
|
||||
for w in focused_widgets:
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
if (
|
||||
df.CONF_ADJUSTABLE in widget_conf
|
||||
and not widget_conf[df.CONF_ADJUSTABLE]
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"A non adjustable arc may not be focused",
|
||||
path,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(configs):
|
||||
config_0 = configs[0]
|
||||
# Global configuration
|
||||
cg.add_library("lvgl/lvgl", "8.4.0")
|
||||
cg.add_define("USE_LVGL")
|
||||
# suppress default enabling of extra widgets
|
||||
@ -203,53 +232,33 @@ async def to_code(config):
|
||||
add_define("LV_MEM_CUSTOM_INCLUDE", '"esphome/components/lvgl/lvgl_hal.h"')
|
||||
|
||||
add_define(
|
||||
"LV_LOG_LEVEL", f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config[df.CONF_LOG_LEVEL]]}"
|
||||
"LV_LOG_LEVEL",
|
||||
f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[df.CONF_LOG_LEVEL]]}",
|
||||
)
|
||||
cg.add_define(
|
||||
"LVGL_LOG_LEVEL",
|
||||
cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config[df.CONF_LOG_LEVEL]}"),
|
||||
cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[df.CONF_LOG_LEVEL]}"),
|
||||
)
|
||||
add_define("LV_COLOR_DEPTH", config[df.CONF_COLOR_DEPTH])
|
||||
add_define("LV_COLOR_DEPTH", config_0[df.CONF_COLOR_DEPTH])
|
||||
for font in helpers.lv_fonts_used:
|
||||
add_define(f"LV_FONT_{font.upper()}")
|
||||
|
||||
if config[df.CONF_COLOR_DEPTH] == 16:
|
||||
if config_0[df.CONF_COLOR_DEPTH] == 16:
|
||||
add_define(
|
||||
"LV_COLOR_16_SWAP",
|
||||
"1" if config[df.CONF_BYTE_ORDER] == "big_endian" else "0",
|
||||
"1" if config_0[df.CONF_BYTE_ORDER] == "big_endian" else "0",
|
||||
)
|
||||
add_define(
|
||||
"LV_COLOR_CHROMA_KEY",
|
||||
await lvalid.lv_color.process(config[df.CONF_TRANSPARENCY_KEY]),
|
||||
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
|
||||
)
|
||||
cg.add_build_flag("-Isrc")
|
||||
|
||||
cg.add_global(lvgl_ns.using)
|
||||
frac = config[CONF_BUFFER_SIZE]
|
||||
if frac >= 0.75:
|
||||
frac = 1
|
||||
elif frac >= 0.375:
|
||||
frac = 2
|
||||
elif frac > 0.19:
|
||||
frac = 4
|
||||
else:
|
||||
frac = 8
|
||||
displays = [await cg.get_variable(display) for display in config[df.CONF_DISPLAYS]]
|
||||
lv_component = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
displays,
|
||||
frac,
|
||||
config[df.CONF_FULL_REFRESH],
|
||||
config[df.CONF_DRAW_ROUNDING],
|
||||
config[df.CONF_RESUME_ON_INPUT],
|
||||
)
|
||||
await cg.register_component(lv_component, config)
|
||||
Widget.create(config[CONF_ID], lv_component, obj_spec, config)
|
||||
|
||||
for font in helpers.esphome_fonts_used:
|
||||
await cg.get_variable(font)
|
||||
cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font))
|
||||
default_font = config[df.CONF_DEFAULT_FONT]
|
||||
default_font = config_0[df.CONF_DEFAULT_FONT]
|
||||
if not lvalid.is_lv_font(default_font):
|
||||
add_define(
|
||||
"LV_FONT_CUSTOM_DECLARE", f"LV_FONT_DECLARE(*{df.DEFAULT_ESPHOME_FONT})"
|
||||
@ -265,39 +274,71 @@ async def to_code(config):
|
||||
add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT)
|
||||
else:
|
||||
add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font))
|
||||
cg.add(lvgl_static.esphome_lvgl_init())
|
||||
|
||||
lv_scr_act = get_scr_act(lv_component)
|
||||
async with LvContext(lv_component):
|
||||
await touchscreens_to_code(lv_component, config)
|
||||
await encoders_to_code(lv_component, config)
|
||||
await theme_to_code(config)
|
||||
await styles_to_code(config)
|
||||
await gradients_to_code(config)
|
||||
await set_obj_properties(lv_scr_act, config)
|
||||
await add_widgets(lv_scr_act, config)
|
||||
await add_pages(lv_component, config)
|
||||
await add_top_layer(lv_component, config)
|
||||
await msgboxes_to_code(lv_component, config)
|
||||
await disp_update(lv_component.get_disp(), config)
|
||||
for config in configs:
|
||||
frac = config[CONF_BUFFER_SIZE]
|
||||
if frac >= 0.75:
|
||||
frac = 1
|
||||
elif frac >= 0.375:
|
||||
frac = 2
|
||||
elif frac > 0.19:
|
||||
frac = 4
|
||||
else:
|
||||
frac = 8
|
||||
displays = [
|
||||
await cg.get_variable(display) for display in config[df.CONF_DISPLAYS]
|
||||
]
|
||||
lv_component = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
displays,
|
||||
frac,
|
||||
config[df.CONF_FULL_REFRESH],
|
||||
config[df.CONF_DRAW_ROUNDING],
|
||||
config[df.CONF_RESUME_ON_INPUT],
|
||||
)
|
||||
await cg.register_component(lv_component, config)
|
||||
Widget.create(config[CONF_ID], lv_component, obj_spec, config)
|
||||
|
||||
lv_scr_act = get_scr_act(lv_component)
|
||||
async with LvContext():
|
||||
await touchscreens_to_code(lv_component, config)
|
||||
await encoders_to_code(lv_component, config)
|
||||
await theme_to_code(config)
|
||||
await styles_to_code(config)
|
||||
await gradients_to_code(config)
|
||||
await set_obj_properties(lv_scr_act, config)
|
||||
await add_widgets(lv_scr_act, config)
|
||||
await add_pages(lv_component, config)
|
||||
await add_top_layer(lv_component, config)
|
||||
await msgboxes_to_code(lv_component, config)
|
||||
await disp_update(lv_component.get_disp(), config)
|
||||
# Set this directly since we are limited in how many methods can be added to the Widget class.
|
||||
Widget.widgets_completed = True
|
||||
async with LvContext(lv_component):
|
||||
await generate_triggers(lv_component)
|
||||
await generate_page_triggers(lv_component, config)
|
||||
await initial_focus_to_code(config)
|
||||
for conf in config.get(CONF_ON_IDLE, ()):
|
||||
templ = await cg.templatable(conf[CONF_TIMEOUT], [], cg.uint32)
|
||||
idle_trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], lv_component, templ)
|
||||
await build_automation(idle_trigger, [], conf)
|
||||
for conf in config.get(df.CONF_ON_PAUSE, ()):
|
||||
pause_trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], lv_component, True)
|
||||
await build_automation(pause_trigger, [], conf)
|
||||
for conf in config.get(df.CONF_ON_RESUME, ()):
|
||||
resume_trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], lv_component, False
|
||||
)
|
||||
await build_automation(resume_trigger, [], conf)
|
||||
async with LvContext():
|
||||
await generate_triggers()
|
||||
for config in configs:
|
||||
lv_component = await cg.get_variable(config[CONF_ID])
|
||||
await generate_page_triggers(config)
|
||||
await initial_focus_to_code(config)
|
||||
for conf in config.get(CONF_ON_IDLE, ()):
|
||||
templ = await cg.templatable(conf[CONF_TIMEOUT], [], cg.uint32)
|
||||
idle_trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], lv_component, templ
|
||||
)
|
||||
await build_automation(idle_trigger, [], conf)
|
||||
for conf in config.get(df.CONF_ON_PAUSE, ()):
|
||||
pause_trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], lv_component, True
|
||||
)
|
||||
await build_automation(pause_trigger, [], conf)
|
||||
for conf in config.get(df.CONF_ON_RESUME, ()):
|
||||
resume_trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], lv_component, False
|
||||
)
|
||||
await build_automation(resume_trigger, [], conf)
|
||||
|
||||
# This must be done after all widgets are created
|
||||
for comp in helpers.lvgl_components_required:
|
||||
cg.add_define(f"USE_LVGL_{comp.upper()}")
|
||||
if "transform_angle" in styles_used:
|
||||
@ -312,7 +353,10 @@ async def to_code(config):
|
||||
|
||||
def display_schema(config):
|
||||
value = cv.ensure_list(cv.use_id(Display))(config)
|
||||
return value or [cv.use_id(Display)(config)]
|
||||
value = value or [cv.use_id(Display)(config)]
|
||||
if len(set(value)) != len(value):
|
||||
raise cv.Invalid("Display IDs must be unique")
|
||||
return value
|
||||
|
||||
|
||||
def add_hello_world(config):
|
||||
@ -324,7 +368,7 @@ def add_hello_world(config):
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
LVGL_SCHEMA = (
|
||||
cv.polling_component_schema("1s")
|
||||
.extend(obj_schema(obj_spec))
|
||||
.extend(
|
||||
@ -393,3 +437,16 @@ CONFIG_SCHEMA = (
|
||||
.extend(DISP_BG_SCHEMA)
|
||||
.add_extra(add_hello_world)
|
||||
)
|
||||
|
||||
|
||||
def lvgl_config_schema(config):
|
||||
"""
|
||||
Can't use cv.ensure_list here because it converts an empty config to an empty list,
|
||||
rather than a default config.
|
||||
"""
|
||||
if not config or isinstance(config, dict):
|
||||
return [LVGL_SCHEMA(config)]
|
||||
return cv.Schema([LVGL_SCHEMA])(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = lvgl_config_schema
|
||||
|
@ -137,20 +137,18 @@ async def disp_update(disp, config: dict):
|
||||
cv.maybe_simple_value(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(lv_obj_t),
|
||||
cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent),
|
||||
},
|
||||
key=CONF_ID,
|
||||
),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent),
|
||||
}
|
||||
),
|
||||
LVGL_SCHEMA,
|
||||
),
|
||||
)
|
||||
async def obj_invalidate_to_code(config, action_id, template_arg, args):
|
||||
lv_comp = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widgets = await get_widgets(config) or [get_scr_act(lv_comp)]
|
||||
if CONF_LVGL_ID in config:
|
||||
lv_comp = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widgets = [get_scr_act(lv_comp)]
|
||||
else:
|
||||
widgets = await get_widgets(config)
|
||||
|
||||
async def do_invalidate(widget: Widget):
|
||||
lv_obj.invalidate(widget.obj)
|
||||
|
@ -1,4 +1,3 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.binary_sensor import (
|
||||
BinarySensor,
|
||||
binary_sensor_schema,
|
||||
@ -6,36 +5,30 @@ from esphome.components.binary_sensor import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET
|
||||
from ..lvcode import EVENT_ARG, LambdaContext, LvContext
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static
|
||||
from ..types import LV_EVENT, lv_pseudo_button_t
|
||||
from ..widgets import Widget, get_widgets, wait_for_widgets
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
binary_sensor_schema(BinarySensor)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
sensor = await new_binary_sensor(config)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
assert isinstance(widget, Widget)
|
||||
await wait_for_widgets()
|
||||
async with LambdaContext(EVENT_ARG) as pressed_ctx:
|
||||
pressed_ctx.add(sensor.publish_state(widget.is_pressed()))
|
||||
async with LvContext(paren) as ctx:
|
||||
async with LvContext() as ctx:
|
||||
ctx.add(sensor.publish_initial_state(widget.is_pressed()))
|
||||
ctx.add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await pressed_ctx.get_lambda(),
|
||||
LV_EVENT.PRESSING,
|
||||
|
@ -4,9 +4,8 @@ from esphome.components.light import LightOutput
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import LvContext
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LvType, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
@ -18,16 +17,15 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
cv.Required(CONF_WIDGET): cv.use_id(lv_led_t),
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LVLight),
|
||||
}
|
||||
).extend(LVGL_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
|
||||
await light.register_light(var, config)
|
||||
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
await wait_for_widgets()
|
||||
async with LvContext(paren) as ctx:
|
||||
async with LvContext() as ctx:
|
||||
ctx.add(var.set_obj(widget.obj))
|
||||
|
@ -178,10 +178,9 @@ class LvContext(LambdaContext):
|
||||
|
||||
added_lambda_count = 0
|
||||
|
||||
def __init__(self, lv_component, args=None):
|
||||
def __init__(self, args=None):
|
||||
self.args = args or LVGL_COMP_ARG
|
||||
super().__init__(parameters=self.args)
|
||||
self.lv_component = lv_component
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await super().__aexit__(exc_type, exc_val, exc_tb)
|
||||
@ -298,6 +297,7 @@ lv_expr = LvExpr("lv_")
|
||||
lv_obj = MockLv("lv_obj_")
|
||||
# Operations on the LVGL component
|
||||
lvgl_comp = MockObj(LVGL_COMP, "->")
|
||||
lvgl_static = MockObj("LvglComponent", "::")
|
||||
|
||||
|
||||
# equivalent to cg.add() for the current code context
|
||||
|
@ -98,19 +98,24 @@ void LvglComponent::set_paused(bool paused, bool show_snow) {
|
||||
this->pause_callbacks_.call(paused);
|
||||
}
|
||||
|
||||
void LvglComponent::esphome_lvgl_init() {
|
||||
lv_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, this);
|
||||
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) {
|
||||
this->add_event_cb(obj, callback, event1);
|
||||
this->add_event_cb(obj, callback, 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) {
|
||||
this->add_event_cb(obj, callback, event1);
|
||||
this->add_event_cb(obj, callback, event2);
|
||||
this->add_event_cb(obj, callback, 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);
|
||||
@ -218,8 +223,10 @@ PauseTrigger::PauseTrigger(LvglComponent *parent, TemplatableValue<bool> paused)
|
||||
}
|
||||
|
||||
#ifdef USE_LVGL_TOUCHSCREEN
|
||||
LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time) {
|
||||
LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) {
|
||||
this->set_parent(parent);
|
||||
lv_indev_drv_init(&this->drv_);
|
||||
this->drv_.disp = parent->get_disp();
|
||||
this->drv_.long_press_repeat_time = long_press_repeat_time;
|
||||
this->drv_.long_press_time = long_press_time;
|
||||
this->drv_.type = LV_INDEV_TYPE_POINTER;
|
||||
@ -235,6 +242,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) {
|
||||
this->touch_pressed_ = !this->parent_->is_paused() && !tpoints.empty();
|
||||
if (this->touch_pressed_)
|
||||
@ -405,9 +413,6 @@ LvglComponent::LvglComponent(std::vector<display::Display *> displays, float buf
|
||||
buffer_frac_(buffer_frac),
|
||||
full_refresh_(full_refresh),
|
||||
resume_on_input_(resume_on_input) {
|
||||
lv_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());
|
||||
auto *display = this->displays_[0];
|
||||
size_t buffer_pixels = display->get_width() * display->get_height() / this->buffer_frac_;
|
||||
auto buf_bytes = buffer_pixels * LV_COLOR_DEPTH / 8;
|
||||
|
@ -146,10 +146,14 @@ class LvglComponent : public PollingComponent {
|
||||
}
|
||||
}
|
||||
|
||||
void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event);
|
||||
void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2);
|
||||
void 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);
|
||||
/**
|
||||
* Initialize the LVGL library and register custom events.
|
||||
*/
|
||||
static void esphome_lvgl_init();
|
||||
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event);
|
||||
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2);
|
||||
static void 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);
|
||||
void add_page(LvPageType *page);
|
||||
void show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time);
|
||||
void show_next_page(lv_scr_load_anim_t anim, uint32_t time);
|
||||
@ -231,7 +235,7 @@ template<typename... Ts> class LvglCondition : public Condition<Ts...>, public P
|
||||
#ifdef USE_LVGL_TOUCHSCREEN
|
||||
class LVTouchListener : public touchscreen::TouchListener, public Parented<LvglComponent> {
|
||||
public:
|
||||
LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time);
|
||||
LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent);
|
||||
void update(const touchscreen::TouchPoints_t &tpoints) override;
|
||||
void release() override {
|
||||
touch_pressed_ = false;
|
||||
|
@ -3,7 +3,7 @@ from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
from ..defines import CONF_ANIMATED, CONF_LVGL_ID, CONF_UPDATE_ON_RELEASE, CONF_WIDGET
|
||||
from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET
|
||||
from ..lv_validation import animated
|
||||
from ..lvcode import (
|
||||
API_EVENT,
|
||||
@ -13,28 +13,23 @@ from ..lvcode import (
|
||||
LvContext,
|
||||
lv,
|
||||
lv_add,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LV_EVENT, LvNumber, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
number.number_schema(LVGLNumber)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
cv.Optional(CONF_ANIMATED, default=True): animated,
|
||||
cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
cv.Optional(CONF_ANIMATED, default=True): animated,
|
||||
cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
var = await number.new_number(
|
||||
@ -58,10 +53,10 @@ async def to_code(config):
|
||||
if not config[CONF_UPDATE_ON_RELEASE]
|
||||
else LV_EVENT.RELEASED
|
||||
)
|
||||
async with LvContext(paren):
|
||||
async with LvContext():
|
||||
lv_add(var.set_control_lambda(await control.get_lambda()))
|
||||
lv_add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj, await event.get_lambda(), UPDATE_EVENT, event_code
|
||||
)
|
||||
)
|
||||
|
@ -1,25 +1,19 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_OPTIONS
|
||||
|
||||
from ..defines import CONF_ANIMATED, CONF_LVGL_ID, CONF_WIDGET, literal
|
||||
from ..defines import CONF_ANIMATED, CONF_WIDGET, literal
|
||||
from ..lvcode import LvContext
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LvSelect, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
LVGLSelect = lvgl_ns.class_("LVGLSelect", select.Select)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
select.select_schema(LVGLSelect)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvSelect),
|
||||
cv.Optional(CONF_ANIMATED, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = select.select_schema(LVGLSelect).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvSelect),
|
||||
cv.Optional(CONF_ANIMATED, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -28,9 +22,8 @@ async def to_code(config):
|
||||
widget = widget[0]
|
||||
options = widget.config.get(CONF_OPTIONS, [])
|
||||
selector = await select.new_select(config, options=options)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
await wait_for_widgets()
|
||||
async with LvContext(paren) as ctx:
|
||||
async with LvContext() as ctx:
|
||||
ctx.add(
|
||||
selector.set_widget(
|
||||
widget.var,
|
||||
|
@ -1,8 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.sensor import Sensor, new_sensor, sensor_schema
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import (
|
||||
API_EVENT,
|
||||
EVENT_ARG,
|
||||
@ -11,34 +10,29 @@ from ..lvcode import (
|
||||
LambdaContext,
|
||||
LvContext,
|
||||
lv_add,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LV_EVENT, LvNumber
|
||||
from ..widgets import Widget, get_widgets, wait_for_widgets
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
sensor_schema(Sensor)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = sensor_schema(Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
sensor = await new_sensor(config)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
assert isinstance(widget, Widget)
|
||||
await wait_for_widgets()
|
||||
async with LambdaContext(EVENT_ARG) as lamb:
|
||||
lv_add(sensor.publish_state(widget.get_value()))
|
||||
async with LvContext(paren, LVGL_COMP_ARG):
|
||||
async with LvContext(LVGL_COMP_ARG):
|
||||
lv_add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await lamb.get_lambda(),
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
|
@ -3,7 +3,7 @@ from esphome.components.switch import Switch, new_switch, switch_schema
|
||||
import esphome.config_validation as cv
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET, literal
|
||||
from ..defines import CONF_WIDGET, literal
|
||||
from ..lvcode import (
|
||||
API_EVENT,
|
||||
EVENT_ARG,
|
||||
@ -13,26 +13,21 @@ from ..lvcode import (
|
||||
LvContext,
|
||||
lv,
|
||||
lv_add,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
LVGLSwitch = lvgl_ns.class_("LVGLSwitch", Switch)
|
||||
CONFIG_SCHEMA = (
|
||||
switch_schema(LVGLSwitch)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
switch = await new_switch(config)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
await wait_for_widgets()
|
||||
@ -45,10 +40,10 @@ async def to_code(config):
|
||||
widget.clear_state(LV_STATE.CHECKED)
|
||||
lv.event_send(widget.obj, API_EVENT, cg.nullptr)
|
||||
control.add(switch.publish_state(literal("v")))
|
||||
async with LvContext(paren) as ctx:
|
||||
async with LvContext() as ctx:
|
||||
lv_add(switch.set_control_lambda(await control.get_lambda()))
|
||||
ctx.add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await checked_ctx.get_lambda(),
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
|
@ -3,7 +3,7 @@ from esphome.components import text
|
||||
from esphome.components.text import new_text
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import (
|
||||
API_EVENT,
|
||||
EVENT_ARG,
|
||||
@ -12,14 +12,14 @@ from ..lvcode import (
|
||||
LvContext,
|
||||
lv,
|
||||
lv_add,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LV_EVENT, LvText, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
LVGLText = lvgl_ns.class_("LVGLText", text.Text)
|
||||
|
||||
CONFIG_SCHEMA = text.TEXT_SCHEMA.extend(LVGL_SCHEMA).extend(
|
||||
CONFIG_SCHEMA = text.TEXT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(LVGLText),
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvText),
|
||||
@ -29,7 +29,6 @@ CONFIG_SCHEMA = text.TEXT_SCHEMA.extend(LVGL_SCHEMA).extend(
|
||||
|
||||
async def to_code(config):
|
||||
textvar = await new_text(config)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
await wait_for_widgets()
|
||||
@ -39,10 +38,10 @@ async def to_code(config):
|
||||
control.add(textvar.publish_state(widget.get_value()))
|
||||
async with LambdaContext(EVENT_ARG) as lamb:
|
||||
lv_add(textvar.publish_state(widget.get_value()))
|
||||
async with LvContext(paren):
|
||||
async with LvContext():
|
||||
lv_add(textvar.set_control_lambda(await control.get_lambda()))
|
||||
lv_add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await lamb.get_lambda(),
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
|
@ -1,4 +1,3 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.text_sensor import (
|
||||
TextSensor,
|
||||
new_text_sensor,
|
||||
@ -6,34 +5,35 @@ from esphome.components.text_sensor import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from ..defines import CONF_LVGL_ID, CONF_WIDGET
|
||||
from ..lvcode import API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import (
|
||||
API_EVENT,
|
||||
EVENT_ARG,
|
||||
UPDATE_EVENT,
|
||||
LambdaContext,
|
||||
LvContext,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..types import LV_EVENT, LvText
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor_schema(TextSensor)
|
||||
.extend(LVGL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvText),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = text_sensor_schema(TextSensor).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvText),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
sensor = await new_text_sensor(config)
|
||||
paren = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
await wait_for_widgets()
|
||||
async with LambdaContext(EVENT_ARG) as pressed_ctx:
|
||||
pressed_ctx.add(sensor.publish_state(widget.get_value()))
|
||||
async with LvContext(paren) as ctx:
|
||||
async with LvContext() as ctx:
|
||||
ctx.add(
|
||||
paren.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await pressed_ctx.get_lambda(),
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
|
@ -33,13 +33,12 @@ def touchscreen_schema(config):
|
||||
return [TOUCHSCREENS_CONFIG(config)]
|
||||
|
||||
|
||||
async def touchscreens_to_code(var, config):
|
||||
async def touchscreens_to_code(lv_component, config):
|
||||
for tconf in config[CONF_TOUCHSCREENS]:
|
||||
lvgl_components_required.add(CONF_TOUCHSCREEN)
|
||||
touchscreen = await cg.get_variable(tconf[CONF_TOUCHSCREEN_ID])
|
||||
lpt = tconf[CONF_LONG_PRESS_TIME].total_milliseconds
|
||||
lprt = tconf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds
|
||||
listener = cg.new_Pvariable(tconf[CONF_ID], lpt, lprt)
|
||||
await cg.register_parented(listener, var)
|
||||
listener = cg.new_Pvariable(tconf[CONF_ID], lpt, lprt, lv_component)
|
||||
lv.indev_drv_register(listener.get_drv())
|
||||
cg.add(touchscreen.register_listener(listener))
|
||||
|
@ -20,17 +20,16 @@ from .lvcode import (
|
||||
lv,
|
||||
lv_add,
|
||||
lv_event_t_ptr,
|
||||
lvgl_static,
|
||||
)
|
||||
from .types import LV_EVENT
|
||||
from .widgets import widget_map
|
||||
|
||||
|
||||
async def generate_triggers(lv_component):
|
||||
async def generate_triggers():
|
||||
"""
|
||||
Generate LVGL triggers for all defined widgets
|
||||
Must be done after all widgets completed
|
||||
:param lv_component: The parent component
|
||||
:return:
|
||||
"""
|
||||
|
||||
for w in widget_map.values():
|
||||
@ -43,11 +42,10 @@ async def generate_triggers(lv_component):
|
||||
conf = conf[0]
|
||||
w.add_flag("LV_OBJ_FLAG_CLICKABLE")
|
||||
event = literal("LV_EVENT_" + LV_EVENT_MAP[event[3:].upper()])
|
||||
await add_trigger(conf, lv_component, w, event)
|
||||
await add_trigger(conf, w, event)
|
||||
for conf in w.config.get(CONF_ON_VALUE, ()):
|
||||
await add_trigger(
|
||||
conf,
|
||||
lv_component,
|
||||
w,
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
API_EVENT,
|
||||
@ -63,7 +61,7 @@ async def generate_triggers(lv_component):
|
||||
lv.obj_align_to(w.obj, target, align, x, y)
|
||||
|
||||
|
||||
async def add_trigger(conf, lv_component, w, *events):
|
||||
async def add_trigger(conf, w, *events):
|
||||
tid = conf[CONF_TRIGGER_ID]
|
||||
trigger = cg.new_Pvariable(tid)
|
||||
args = w.get_args() + [(lv_event_t_ptr, "event")]
|
||||
@ -72,4 +70,4 @@ async def add_trigger(conf, lv_component, w, *events):
|
||||
async with LambdaContext(EVENT_ARG, where=tid) as context:
|
||||
with LvConditional(w.is_selected()):
|
||||
lv_add(trigger.trigger(*value, literal("event")))
|
||||
lv_add(lv_component.add_event_cb(w.obj, await context.get_lambda(), *events))
|
||||
lv_add(lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *events))
|
||||
|
@ -20,6 +20,7 @@ from ..lvcode import (
|
||||
add_line_marks,
|
||||
lv_add,
|
||||
lvgl_comp,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import LVGL_SCHEMA
|
||||
from ..types import LvglAction, lv_page_t
|
||||
@ -139,7 +140,7 @@ async def add_pages(lv_component, config):
|
||||
await add_widgets(page, pconf)
|
||||
|
||||
|
||||
async def generate_page_triggers(lv_component, config):
|
||||
async def generate_page_triggers(config):
|
||||
for pconf in config.get(CONF_PAGES, ()):
|
||||
page = (await get_widgets(pconf))[0]
|
||||
for ev in (CONF_ON_LOAD, CONF_ON_UNLOAD):
|
||||
@ -149,7 +150,7 @@ async def generate_page_triggers(lv_component, config):
|
||||
async with LambdaContext(EVENT_ARG, where=id) as context:
|
||||
lv_add(trigger.trigger())
|
||||
lv_add(
|
||||
lv_component.add_event_cb(
|
||||
lvgl_static.add_event_cb(
|
||||
page.obj,
|
||||
await context.get_lambda(),
|
||||
literal(f"LV_EVENT_SCREEN_{ev[3:].upper()}_START"),
|
||||
|
@ -1,5 +1,12 @@
|
||||
display:
|
||||
- platform: sdl
|
||||
id: sdl0
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 480
|
||||
height: 320
|
||||
- platform: sdl
|
||||
id: sdl1
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 480
|
||||
@ -7,5 +14,30 @@ display:
|
||||
|
||||
touchscreen:
|
||||
- platform: sdl
|
||||
display: sdl0
|
||||
sdl_id: sdl0
|
||||
|
||||
lvgl:
|
||||
- id: lvgl_0
|
||||
displays: sdl0
|
||||
- id: lvgl_1
|
||||
displays: sdl1
|
||||
on_idle:
|
||||
timeout: 8s
|
||||
then:
|
||||
if:
|
||||
condition:
|
||||
lvgl.is_idle:
|
||||
lvgl_id: lvgl_1
|
||||
timeout: 5s
|
||||
then:
|
||||
logger.log: Lvgl2 is idle
|
||||
widgets:
|
||||
- button:
|
||||
align: center
|
||||
widgets:
|
||||
- label:
|
||||
text: Click ME
|
||||
on_click:
|
||||
logger.log: Clicked
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user