Light and Versatile Embedded Graphics Library ~
Tasmota now supports openHASP-compatible templates - named HASPmota - which makes it much easier to start with LVGL. Check the HASPmota documentation.
This feature is included in tasmota32-lvgl.bin
Check the LVGL API Reference
Supported version: LVGL v9.2.0, LodePNG v20201017, Freetype 2.13.2
LVGL (Light and Versatile Graphics Library) is Tasmota's next generation display. It is powerful, lightweight and simple to use. It combines:
- LVGL's powerful graphics and GUI library for embedded
- Tasmota's stability, richness of features and frugality on resources
- Berry's powerful language similar to MicroPython
LVGL is an open-source graphics library providing everything you need to create embedded GUI with easy-to-use graphical elements, beautiful visual effects and low memory footprint.
Berry is an ultra-lightweight dynamically typed embedded scripting language. It is designed for lower-performance embedded devices
After compiling Tasmota with LVGL support and configuring uDisplay (see below), you can start using LVGL through the Berry console.
Using LVGL~
This example uses the M5Stack Fire device.
Use the following template to define the relevant SPI GPIOs. Also set one of the unused GPIOs to Option A3
.
The complete configuration for M5Stack Fire is:
Template {"NAME":"M5stack Fire","GPIO":[6210,1,1,1,1,1,1,1,1,1,768,1,1,1,736,672,0,640,608,704,0,3872,3872,800,0,0,0,0,992,1024,1,0,0,34,33,32],"FLAG":0,"BASE":1,"CMND":"Module 0"}
Save the following file as display.ini
in the file system:
:H,ILI9341,320,240,16,SPI,1,*,*,*,*,*,*,*,40
:B,60,1
:I
EF,3,03,80,02
CF,3,00,C1,30
ED,4,64,03,12,81
E8,3,85,00,78
CB,5,39,2C,00,34,02
F7,1,20
EA,2,00,00
C0,1,23
C1,1,10
C5,2,3e,28
C7,1,86
36,1,48
37,1,00
3A,1,55
B1,2,00,18
B6,3,08,82,27
F2,1,00
26,1,01
E0,0F,0F,31,2B,0C,0E,08,4E,F1,37,07,10,03,0E,09,00
E1,0F,00,0E,14,03,11,07,31,C1,48,08,0F,0C,31,36,0F
11,80
29,80
:o,28
:O,29
:A,2A,2B,2C
:R,36
:0,08,00,00,00
:1,68,00,00,01
:2,C8,00,00,02
:3,A8,00,00,03
#
Then open the Berry console and copy/paste the following: (alternatively create an autoexec.be
file with this content):
#- start LVGL and init environment -#
lv.start()
hres = lv.get_hor_res() # should be 320
vres = lv.get_ver_res() # should be 240
scr = lv.scr_act() # default screan object
f20 = lv.montserrat_font(20) # load embedded Montserrat 20
#- Background with a gradient from black #000000 (bottom) to dark blue #0000A0 (top) -#
scr.set_style_bg_color(lv.color(0x0000A0), lv.PART_MAIN | lv.STATE_DEFAULT)
scr.set_style_bg_grad_color(lv.color(0x000000), lv.PART_MAIN | lv.STATE_DEFAULT)
scr.set_style_bg_grad_dir(lv.GRAD_DIR_VER, lv.PART_MAIN | lv.STATE_DEFAULT)
#- Upper state line -#
stat_line = lv.label(scr)
if f20 != nil stat_line.set_style_text_font(f20, lv.PART_MAIN | lv.STATE_DEFAULT) end
stat_line.set_long_mode(lv.LABEL_LONG_SCROLL) # auto scrolling if text does not fit
stat_line.set_width(hres)
stat_line.set_align(lv.TEXT_ALIGN_LEFT) # align text left
stat_line.set_style_bg_color(lv.color(0xD00000), lv.PART_MAIN | lv.STATE_DEFAULT) # background #000088
stat_line.set_style_bg_opa(lv.OPA_COVER, lv.PART_MAIN | lv.STATE_DEFAULT) # 100% background opacity
stat_line.set_style_text_color(lv.color(0xFFFFFF), lv.PART_MAIN | lv.STATE_DEFAULT) # text color #FFFFFF
stat_line.set_text("Tasmota")
stat_line.refr_size() # new in LVGL8
stat_line.refr_pos() # new in LVGL8
#- display wifi strength indicator icon (for professionals ;) -#
wifi_icon = lv_wifi_arcs_icon(stat_line) # the widget takes care of positioning and driver stuff
clock_icon = lv_clock_icon(stat_line)
#- create a style for the buttons -#
btn_style = lv.style()
btn_style.set_radius(10) # radius of rounded corners
btn_style.set_bg_opa(lv.OPA_COVER) # 100% background opacity
if f20 != nil btn_style.set_text_font(f20) end # set font to Montserrat 20
btn_style.set_bg_color(lv.color(0x1fa3ec)) # background color #1FA3EC (Tasmota Blue)
btn_style.set_border_color(lv.color(0x0000FF)) # border color #0000FF
btn_style.set_text_color(lv.color(0xFFFFFF)) # text color white #FFFFFF
#- create buttons -#
prev_btn = lv.btn(scr) # create button with main screen as parent
prev_btn.set_pos(20,vres-40) # position of button
prev_btn.set_size(80, 35) # size of button
prev_btn.add_style(btn_style, lv.PART_MAIN | lv.STATE_DEFAULT) # style of button
prev_label = lv.label(prev_btn) # create a label as sub-object
prev_label.set_text("<") # set label text
prev_label.center()
next_btn = lv.btn(scr) # right button
next_btn.set_pos(220,vres-40)
next_btn.set_size(80, 35)
next_btn.add_style(btn_style, lv.PART_MAIN | lv.STATE_DEFAULT)
next_label = lv.label(next_btn)
next_label.set_text(">")
next_label.center()
home_btn = lv.btn(scr) # center button
home_btn.set_pos(120,vres-40)
home_btn.set_size(80, 35)
home_btn.add_style(btn_style, lv.PART_MAIN | lv.STATE_DEFAULT)
home_label = lv.label(home_btn)
home_label.set_text(lv.SYMBOL_OK) # set text as Home icon
home_label.center()
#- callback function when a button is pressed, react to EVENT_CLICKED event -#
def btn_clicked_cb(obj, event)
var btn = "Unknown"
if obj == prev_btn btn = "Prev"
elif obj == next_btn btn = "Next"
elif obj == home_btn btn = "Home"
end
# get the coordinates
var indev = event.get_indev()
var point = lv.point()
indev.get_point(point)
# get local coordinates
var area = lv.area()
obj.get_coords(area)
print(f"{btn} button pressed at ({point.x},{point.y}) local ({point.x - area.x1},{point.y - area.y1})")
end
prev_btn.add_event_cb(btn_clicked_cb, lv.EVENT_CLICKED, 0)
next_btn.add_event_cb(btn_clicked_cb, lv.EVENT_CLICKED, 0)
home_btn.add_event_cb(btn_clicked_cb, lv.EVENT_CLICKED, 0)
You should see this:
Setting an input device is simple, we are now configuring the three buttons as the equivalent of a rotary encoder: left/ok/right.
To control focus, you need to create a group, put the focusable items in the group, and assign the input device to the group:
g = lv.group()
g.add_obj(prev_btn)
g.add_obj(home_btn)
g.add_obj(next_btn)
rotary = lv.register_button_encoder(true) #- buttons are inverted -#
rotary.set_group(g)
Touch Screen Support~
Touch screen are supported natively via Universal Display driver.
Let's go into the details of this example.
Starting LVGL~
Start LVGL
lv.start()
Note: when you create an LVGL object, you need to use the lv
module. For example, creating a label object is done with lv.lv_label
. As a convenience, classes can also be named with a shorter name lv.label
which is equivalent to lv.lv_label
. The internal class name is still lv_label
.
Use lv.montserrat_font(<size>)
to load a pre-defined montserrat font. Embedded sizes are: 10, 14, 20, 28. You can also load a font from the file-system but you need to convert them first. See: https://docs.lvgl.io/latest/en/html/overview/font.html
hres = lv.get_hor_res() # should be 320
vres = lv.get_ver_res() # should be 240
scr = lv.scr_act() # default screan object
f20 = lv.montserrat_font(20) # load embedded Montserrat 20
Set the background color~
#- Background with a gradient from black #000000 (bottom) to dark blue #0000A0 (top) -#
scr.set_style_bg_color(lv.color(0x0000A0), lv.PART_MAIN | lv.STATE_DEFAULT)
scr.set_style_bg_grad_color(lv.color(0x000000), lv.PART_MAIN | lv.STATE_DEFAULT)
scr.set_style_bg_grad_dir(lv.GRAD_DIR_VER, lv.PART_MAIN | lv.STATE_DEFAULT)
The display is composed of a virtual screen object scr
. To change the background you need to change the style of this object. You can either create a full style object or change the style inside the object. This is what we do here. Hence methods: set_style_<xxx>
In this example we do a vertical color gradient from dark blue (up) to black (down).
Colors~
Color are created via lv.color(<int>)
with 0xRRGGBB as 24 bits color. Internally the color is converted to the display color depth so rounding errors may happen:
> lv.color(0x808080)
lv_color(0x838183 - native:0x1084)
The line above shows the internal color converted back to 24 bits RGB (rounding errors occur) and the native 15 bits RGB internal color.
Create the upper text line~
#- Upper state line -#
stat_line = lv.label(scr)
if f20 != nil stat_line.set_style_text_font(f20, lv.PART_MAIN | lv.STATE_DEFAULT) end
stat_line.set_long_mode(lv.LABEL_LONG_SCROLL) # auto scrolling if text does not fit
stat_line.set_width(hres)
stat_line.set_align(lv.TEXT_ALIGN_LEFT) # align text left
stat_line.set_style_bg_color(lv.color(0xD00000), lv.PART_MAIN | lv.STATE_DEFAULT) # background #000088
stat_line.set_style_bg_opa(lv.OPA_COVER, lv.PART_MAIN | lv.STATE_DEFAULT) # 100% background opacity
stat_line.set_style_text_color(lv.color(0xFFFFFF), lv.PART_MAIN | lv.STATE_DEFAULT) # text color #FFFFFF
stat_line.set_text("Tasmota")
stat_line.refr_size() # new in LVGL8
stat_line.refr_pos() # new in LVGL8
Let's decompose:
stat_line = lv.label(scr)
Creates an object of type lv_label
with parent scr
(screen).
if f20 != nil stat_line.set_style_text_font(f20, lv.PART_MAIN | lv.STATE_DEFAULT) end
If f20
is correctly loaded, set the font to Montserrat 20. Styles are associated to parts of objects and to states. Here we associate to the main part for state default.
stat_line.set_long_mode(lv.LABEL_LONG_SCROLL) # auto scrolling if text does not fit
Set the label to auto roll from right to left and vice versa if the text does not fit in the display.
stat_line.set_width(hres)
stat_line.set_align(lv.TEXT_ALIGN_LEFT) # align text left
Set the width to full screen resolution, and align text to the left.
stat_line.set_style_bg_color(lv.color(0xD00000), lv.PART_MAIN | lv.STATE_DEFAULT) # background #000088
stat_line.set_style_bg_opa(lv.OPA_COVER, lv.PART_MAIN | lv.STATE_DEFAULT) # 100% background opacity
stat_line.set_style_text_color(lv.color(0xFFFFFF), lv.PART_MAIN | lv.STATE_DEFAULT) # text color #FFFFFF
Set background color to red, text color to white, opacity to 100%.
stat_line.set_text("Tasmota")
Set the text of the label.
stat_line.refr_size() # new in LVGL8
stat_line.refr_pos() # new in LVGL8
The latter is new in LVGL8 and tells the widget to update its size and position, that we will use right after to position other widgets. Please note that the actual display is asynchronous. We describe the objects, in whatever order, they will be all displayed at once.
Create a style~
#- create a style for the buttons -#
btn_style = lv.style()
btn_style.set_radius(10) # radius of rounded corners
btn_style.set_bg_opa(lv.OPA_COVER) # 100% background opacity
if f20 != nil btn_style.set_text_font(f20) end # set font to Montserrat 20
btn_style.set_bg_color(lv.color(0x1fa3ec)) # background color #1FA3EC (Tasmota Blue)
btn_style.set_border_color(lv.color(0x0000FF)) # border color #0000FF
btn_style.set_text_color(lv.color(0xFFFFFF)) # text color white #FFFFFF
We create a lv_style
object and associate some attributes. This works similarly to CSS styles. This style sets background color to Tasmota button blue, text to white, opacity to 100%, font to Montserrat 20 and corner rounding to 1 pixel (10 decipixels).
Create the buttons~
home_btn = lv.btn(scr) # center button
home_btn.set_pos(120,vres-40)
home_btn.set_size(80, 35)
home_btn.add_style(btn_style, lv.PART_MAIN | lv.STATE_DEFAULT)
home_label = lv.label(home_btn)
home_label.set_text(lv.SYMBOL_OK) # set text as Home icon
home_label.center()
Finally create a lv_btn
object with parent scr
, set its size and position, add the previously defined style and set its text.
LVGL provides some pre-defined symbols like lv.SYMBOL_OK
.
Advanced features and extensions~
Screenshot~
Tasmota includes an easy way to take screenshots.
Just use lv.screenshot()
and a BMP
file will be stored in the file system.
Example:
> lv.screenshot()
/screenshot-1642356919.bmp
Then download the file to your local computer. The file format is uncompressed BMP with 16 bits per pixel. It is highly recommended to then compress the image to PNG or JPG with the software of your choice.
Warning: due to internal implementation limitations, the image is stored upside down. Don't forget to vertically revert the image.
PNG Image support~
Support for PNG decoding depends on #define USE_LVGL_PNG_DECODER
- which is enabled by default in Tasmota32-lvgl.
You need to first store images on the file system, and simply load them through LVGL standard way. PNG identification depends on the .png
extension.
Example: store the following image as Sunrise320.png
sunrise = lv.img(scr) # create an empty image object in the current screen
sunrise.set_src("A:/Sunrise320.png") # load "Sunrise320.png", the default drive letter is 'A:'
sunrise.move_background() # move the image to the background
Freetype fonts support~
Support for Freetype fonts depends on #define USE_LVGL_FREETYPE
- which is NOT enabled by default in Tasmota32-lvgl.
Bitmat fonts typically consume significant flash size because you need to embed the font at different size. Using FreeType vector fonts can bring more flexibility and options. You need to first upload the desired fonts on the Tasmota file system.
To create the lv_font
object, use lv.load_freetype_font(name:string, size:int, type:int) -> nil or lv_font
. If the font is not found, the call returns nil
. type
can be 0
or lv.FT_FONT_STYLE_NORMAL
, or a combination of lv.FT_FONT_STYLE_ITALIC
and lv.FT_FONT_STYLE_BOLD
.
Example (after loading lvgl_demo.be
) using sketchbook.ttf
font:
sb120 = lv.load_freetype_font("sketchbook.ttf", 120, 0)
tt = lv.label(scr)
tt.set_style_bg_opa(lv.OPA_0, lv.PART_MAIN | lv.STATE_DEFAULT)
tt.set_style_text_color(lv.color(0xFFFFFF), lv.PART_MAIN | lv.STATE_DEFAULT)
tt.set_text("MQTT")
tt.set_pos(10,40)
tt.set_size(300,150)
if sb120 != nil tt.set_style_text_font(sb120, lv.PART_MAIN | lv.STATE_DEFAULT) end
What's implemented and what's not?~
What's implemented currently:
- All standard LVGL widgets are available, most of extras
- Styles
- File-system
- Fonts, currently Montserrat fonts are embedded at sizes 10, 14 (default), 20 and 28 (compressed - smaller and slower)
- External Fonts in file-system, either in LVGL's binary format of TrueType fonts via the FreeType library (requires
#defined USE_LVGL_FREETYPE
) - Images in file-system, either in LVGL's binary format or PNG
- Most of the high-level LVGL APIs via the
lv
Berry object - SPI displays with 16 bits per pixels
- Animations via Berry code
- Touch Screen support
- SPI DMA
- Callbacks on LVGL objects to react on events
- Ability to define custom widgets in Berry
What will probably not be implemented
Native LVGL animation engine- Styles garbage collection is not done, which means that creating lots of styles leads to memoly leak
- multi-screens display - I don't know of a single ESP32 based device with multi-screens
- Bidirectional fonts - unless there is strong demand
- LVGL tasks - Berry provides all the necessary tools for task scheduling
Converting C LVGL to Berry~
Simply speaking, you can convert most constants from their C equivalent to berry by just changing the LV_
prefix to lv.
.
Example: LV_SYMBOL_OK
becomes lv.SYMBOL_OK
Berry provides an object model to lv_object
and sub-classes for widhets like lv_btn
, lv_label
... To create an object, just instantiate the class: lv_btn(parent)
lv_style
is created independently.
lv_color
takes a 24 bits 0xRRGGB as parameter, or a pre-defined color like lv.BLUE
Compiling for LVLG~
In my_user_config.h
or in your config override, add:
#define USE_LVGL
#define USE_DISPLAY
#define USE_DISPLAY_LVGL_ONLY
#define USE_XPT2046
#define USE_UNIVERSAL_DISPLAY
#undef USE_DISPLAY_MODES1TO5
#undef USE_DISPLAY_LCD
#undef USE_DISPLAY_SSD1306
#undef USE_DISPLAY_MATRIX
#undef USE_DISPLAY_SEVENSEG
Be aware that it adds 440Kb to you firmware, so make sure you have a partition with enough program Flash space. Preferably use esp32_partition_app1856k_spiffs320k.csv
partition file.
Goodies~
Tasmota Logo~
Get a Tasmota logo:
# start the display
lv.start()
# set background color to blue
scr = lv.scr_act()
scr.set_style_bg_color(lv.color(lv.COLOR_BLUE), lv.PART_MAIN | lv.STATE_DEFAULT)
# create a lv_img object and set it to Tasmota logo
logo = lv.img(scr)
logo.set_tasmota_logo()
logo.center()
The logo is black, with anti-aliasing and transparency. You can now manipulate the logo: change zoom, rotate or recolor.
# recolor logo to white
logo.set_style_img_recolor_opa(255, lv.PART_MAIN | lv.STATE_DEFAULT)
logo.set_style_img_recolor(lv.color(lv.COLOR_WHITE), lv.PART_MAIN | lv.STATE_DEFAULT)
# zoom by 125% - 100% is 256, so 125% is 320
logo.set_zoom(300)
# rotate by 30 degrees - or 300 deci-degrees
logo.set_angle(300)
Example of animation:
cur_zoom = 200
cur_incr = 5
def animate_logo()
cur_zoom += cur_incr
if cur_zoom > 300 cur_incr = - cur_incr end
if cur_zoom < 200 cur_incr = - cur_incr end
logo.set_zoom(cur_zoom)
tasmota.set_timer(100, animate_logo)
end
animate_logo()
Calibrate a resitive Touch Screen~
Some touchscreens like Lolin TFT 2.4 Touch Shields use a resistive touchscreen controlled by XPT2046
. Contrary to capacitive touchscreens, resistive touchscreens needs a per-device calibration.
You can download DisplayCalibrate.tapp Tasmota Application which allows for easy calibration. In only a few steps, it will generate the universal display line :M
with calibration information.
-
First download DisplayCalibrate.tapp application and upload it in the file system, and restart.
-
Make sure you are in orientation
DisplayRotate 0
-
In the console, type the command
DisplayCalibrate
You will see the following screens. Click on all 4 crosses near corners.
Note: measures are taken every 50 ms and are averaged, and requires at least 3 measures (150ms).
If everything went well, you will see the following screen. After reboot, your touchscreen is ready and calibrate.
If the geometry is wrong, you will see the following screen and no change is done to display.ini
Cookbook~
Measuring user inactivity~
LVGL has a notion of screen inactivity, i.e. how long did the user not interact with the screen. This can be use to dim the display or turn it off after a moment of inactivity (like a screen saver). The time is in milliseconds. Full doc here: https://docs.lvgl.io/8/overview/display.html#inactivity
# time of inactivity in ms
lv.disp().get_inactive_time()
Technical Details~
The code size impact is quite significant, so you probably need partitions with code at least set to 1856KB. Doing so leaves 320KB for file system on 4MB flash. With the Safeboot partition layout, default code partition size for 4MB of flash is 2880KB.
Most of Berry code is solidified in Flash, so the initial RAM footprint is very low (a few KB).