I recently acquired an M5Stack Tab5 board for an upcoming project and wanted to share my initial experiences, especially for anyone interested in building attractive graphical user interfaces (GUIs) using the LVGL library on MicroPython. My motivation for writing this post stems from my experience on GUI programming. Most examples of LVGL with ESP32 are written in C/C++ using the Arduino IDE. For those unfamiliar with event-driven programming, this can feel complex and difficult to start with. MicroPython offers a compelling alternative for beginners.
This article will guide you through developing example code for the M5Stack Tab5, connecting it to an M5Stack Unit Weight-I2C module. This module combines an STM32 with an HX711, used with a load cell for weight measurement. It connects directly to the M5Stack Tab5 via I2C on Port A. The load cell used is a 0–20 kg model, purchased from ThaiEasyElec and available in our lab. The design will focus on two features: measuring and displaying weight, and a section for calibrating the weight measurement. Weight data will be reported via WiFi using the MQTT protocol to a public broker like EMQX.
Why M5Stack Tab5The M5Stack Tab5 board features an ESP32-P4 processor (Dual-core RISC-V), which is unique from other ESP32 families as it lacks built-in wireless connectivity. This requires adding an ESP32-C6 for communication. However, the ESP32-P4 on the Tab5 compensates by offering generous resources (16 MB Flash and 32 MB PSRAM) compared to typical ESP32 boards. This is more than sufficient for creating complex UIs with LVGL.
A major highlight of this board is its comprehensive HMI (Human-Machine Interface) integration. It includes a 5-inch 1280 × 720 IPS TFT display with GT911 touch, a 2MP camera, audio capabilities (microphone, speaker, 3.5mm jack), and an IMU sensor (BMI270 accel + gyro). This makes it an incredibly affordable board at $55 (under 2, 000 Thai Baht). The design is sleek, lightweight, and offers various connectors. M5Stack's quality is also reliable, as I've used several of their boards previously.
M5Stack offers a web-based GUI development tool called UIFlow, which uses a drag-and-drop Blockly-style interface, similar to tools like App Inventor, Microsoft MakeCode, and KidBright IDE. I chose not to use this method because my planned code logic is somewhat complex, and converting it to a block flow would become messy. However, the UIFlow 2.0 firmware on the Tab5 board is developed with MicroPython and LVGL 9.3 to connect with a server. Therefore, installing UIFlow 2.0 firmware on the Tab5 allows us to write code using MicroPython + LVGL directly.
First step: firmwareTo install UIFlow firmware, use the M5Burner software. Select and download the UIFlow 2.0 firmware. Then, press and hold the Reset button while plugging in the USB cable to enter bootloader mode (you'll see a green LED flashing). Programming the UIFlow 2.0 firmware requires registration and entering your WiFi credentials so the firmware can connect to the UIFlow server.
After installation, press the Reset button. You'll see a UI display for testing the board's devices. However, the REPL (Read-Eval-Print Loop) won't work because it gets stuck in the UI code. Therefore, it's recommended to select the "Boot Option" as "Run main.py directly" to allow the Tab5 to drop into the REPL after booting. Connecting to MicroPython on the Tab5 board is done via the serial port REPL, with various program options available, such as Thonny.
2nd step: UI layoutCreating MicroPython code can be done using M5Stack's UIFlow 2.0 web IDE, which is designed for rapid prototyping. I recommend this method because it allows you to visualize the shape, color, size, and font of various UI components beforehand. This helps in pre-assessing the look-and-feel of your GUI. Additionally, the UIFlow 2.0 web IDE supports creating LVGL pages, enabling you to design multi-screen GUI layers, simplifying the design of complex operational GUIs.
The GUI design section of UIFlow 2.0 offers two library modules:
- M5GFX: This is M5Stack's older-style API with limited UI components, such as label, rect, circle, and image.
- M5UI: This API is an evolution based on LVGL 9.3 and supports a wider range of UI components like label, button, switch, checkbox, etc., though not all LVGL components are fully implemented yet.
This article will use the M5UI library. To enter the UI design section, click on the PEN symbol. From there, you can drag UI components from the left panel onto the screen area and then adjust each UI component by entering property values on the right side.
If you need a multi-screen UI, you can add pages by clicking the "Page: page0" button and then adding a new page. This new page will be blank, allowing you to drag and drop UI components onto it. Switching between pages can be achieved by calling LVGL commands within your code.
The demonstration system's UI will be divided into two pages. The first page will be the main screen, which reads data from the Unit Weight-I2C module, converts the digital value to weight, and displays the result. It will also include two buttons for resetting and calibrating. Property settings for each UI component are limited to basic options like position, color, and font. The fonts included in the firmware are Montserrat, ranging from 14 to 48 pixels. This can be quite small for a screen with 1280 × 720 pixel resolution.
The second page will be the calibration screen, featuring a display for the raw digital readings, a button for setting the offset, and a button for updating the slope value. It is crucial that the names assigned to each UI component on this page are unique and do not overlap with those on the first page, as variables will be created based on these assigned names for each UI component.
Once the GUI's look-and-feel is defined, we have two software development options: using Blockly-style graphical programming or exporting as MicroPython code. This article will focus on the MicroPython format because it allows for enhancing the GUI by calling LVGL APIs to customize various properties of each UI component, as well as utilizing various functions from libraries integrated into MicroPython.
The MicroPython code generated from the UIFlow 2.0 web IDE will be in a structured programming style, without the creation of classes typical of OOP. The code structure mimics Arduino, consisting of two main functions:
- setup( ) for hardware/software initialization
- loop( ) for cyclical operations.
Groups of UI component variables rely on global declarations to be accessible within functions. For those with OOP coding experience, encapsulating UI components as properties of classes, separated by pages, would likely be a much better option.
import os, sys, io
import M5
from M5 import *
import m5ui
import lvgl as lv
page0 = None
page1 = None
weight_label = None
weight0_label = None
reset_btn = None
cal_btn = None
weight_value = None
weight0_value = None
digital_label = None
offset_label = None
digital_value = None
cal_weight_label = None
cal_weight_value = None
offset_value = None
offset_button = None
cal_button = None
def setup():
global page0, page1, weight_label, weight0_label, reset_btn, cal_btn, weight_value, weight0_value, digital_label, offset_label, digital_value, cal_weight_label, cal_weight_value, offset_value, offset_button, cal_button
M5.begin()
Widgets.setRotation(3)
m5ui.init()
page0 = m5ui.M5Page(bg_c=0xeeeeee)
page1 = m5ui.M5Page(bg_c=0xcccccc)
weight_label = m5ui.M5Label("Weight", x=105, y=100, text_c=0x000000, bg_c=0xffffff, bg_opa=0, font=lv.font_montserrat_48, parent=page0)
weight0_label = m5ui.M5Label("Start weight", x=100, y=250, text_c=0x000000, bg_c=0xffffff, bg_opa=0, font=lv.font_montserrat_48, parent=page0)
reset_btn = m5ui.M5Button(text="RESET", x=300, y=500, bg_c=0x2196f3, text_c=0xffffff, font=lv.font_montserrat_48, parent=page0)
cal_btn = m5ui.M5Button(text="CALIBRATE", x=700, y=500, bg_c=0x2196f3, text_c=0xffffff, font=lv.font_montserrat_48, parent=page0)
weight_value = m5ui.M5Label("0 g", x=700, y=100, text_c=0x00ffff, bg_c=0xaaaaaa, bg_opa=255, font=lv.font_montserrat_48, parent=page0)
weight0_value = m5ui.M5Label("0 g", x=700, y=250, text_c=0x00ffff, bg_c=0xaaaaaa, bg_opa=255, font=lv.font_montserrat_48, parent=page0)
digital_label = m5ui.M5Label("Digital value:", x=100, y=100, text_c=0x000000, bg_c=0xffffff, bg_opa=0, font=lv.font_montserrat_48, parent=page1)
offset_label = m5ui.M5Label("Offset value:", x=100, y=250, text_c=0x000000, bg_c=0xffffff, bg_opa=0, font=lv.font_montserrat_48, parent=page1)
digital_value = m5ui.M5Label("0", x=700, y=100, text_c=0xffff00, bg_c=0x999999, bg_opa=255, font=lv.font_montserrat_48, parent=page1)
cal_weight_label = m5ui.M5Label("Calibrated value:", x=100, y=400, text_c=0x000000, bg_c=0xffffff, bg_opa=0, font=lv.font_montserrat_48, parent=page1)
cal_weight_value = m5ui.M5Label("0 g", x=700, y=400, text_c=0xffff00, bg_c=0x999999, bg_opa=255, font=lv.font_montserrat_48, parent=page1)
offset_value = m5ui.M5Label("0", x=700, y=250, text_c=0xffff00, bg_c=0x999999, bg_opa=255, font=lv.font_montserrat_48, parent=page1)
offset_button = m5ui.M5Button(text="OFFSET", x=250, y=550, bg_c=0x2196f3, text_c=0xffffff, font=lv.font_montserrat_48, parent=page1)
cal_button = m5ui.M5Button(text="CALIBRATE", x=700, y=552, bg_c=0x2196f3, text_c=0xffffff, font=lv.font_montserrat_48, parent=page1)
page0.screen_load()
def loop():
global page0, page1, weight_label, weight0_label, reset_btn, cal_btn, weight_value, weight0_value, digital_label, offset_label, digital_value, cal_weight_label, cal_weight_value, offset_value, offset_button, cal_button
M5.update()
if __name__ == '__main__':
try:
setup()
while True:
loop()
except (Exception, KeyboardInterrupt) as e:
try:
m5ui.deinit()
from utility import print_error_msg
print_error_msg(e)
except ImportError:
print("please update to latest firmware")
The MicroPython + LVGL code generated from the UIFlow 2.0 web IDE offers the advantage that the code for LVGL initialization, memory management, and rendering to the display is handled by a single command: m5ui.init( ). This allows our coding to focus directly on the application-level behavior of UI components. However, this also means the code won't be portable to other microcontroller boards. While vendor lock-in might seem like a long-term disadvantage, compared to writing LVGL code in C/C++, writing MicroPython code with the UIFlow 2.0 web IDE offers a significant advantage in terms of rapid prototyping.
Writing LVGL code with MicroPython is significantly easier than with C/C++ due to how commands are bound to UI component objects. However, a major challenge is that the LVGL 9.x documentation lacks content explaining the API or providing code examples for MicroPython, forcing users to guess commands (LVGL 8.x documentation has MicroPython examples, but many APIs have changed names) or study M5UI example code on GitHub. Therefore, I'll use the task of developing an IoT weight-measuring device to provide example code for different firmware functions.
The first example code I'll explain covers WiFi usage. The ESP32-P4 processor needs to connect to an ESP32-C6 communication module. The UIFlow 2.0 firmware already encapsulates the functionality of the ESP32-C6 module, allowing you to write MicroPython-like code by calling the network module's API
import network
def setup():
...
sta_if = network.WLAN(network.WLAN.IF_STA)
sta_if.active(True)
sta_if.connect('WIFI_SSID', 'WIFI_PASSWD')
while not sta_if.isconnected():
time.sleep_ms(100)
print('.')
print(sta_if.ipconfig('addr4'))
The next code example is connecting to an MQTT broker using the umqtt module, an auxiliary MicroPython library. You can learn how to write this code by studying existing MicroPython examples for MQTT connections.
from umqtt.simple import MQTTClient
import ujson
def mqtt_msg_cb(topic, msg):
print(topic, msg)
def setup():
...
global mqtt_client
mqtt_client = MQTTClient('MQTT_CLIENT_ID', 'broker.emqx.io', 1883)
mqtt_client.set_callback(mqtt_msg_cb)
mqtt_client.connect()
mqtt_client.subscribe('MQTT_SUB_TOPIC')
def loop():
...
global mqtt_client
msg = {'value': 0}
mqtt_client.publish('MQTT_PUB_TOPIC', ujson.dumps(msg))
mqtt_client.check_msg()
time.sleep(1)
The UIFlow 2.0 firmware includes libraries for connecting to M5Stack hardware, making it easy to write code to read values from the Weight-I2C unit. You can study the example code on GitHub. A point to be aware of is that the I2C pins connected to Port A vary depending on the hardware. For instance, the M5Stack Tab5 uses pin 54 for SCL and pin 53 for SDA, so you must configure the I2C pins before using the hardware library.
from machine import I2C, Pin
from unit import WeightI2CUnit
def setup():
...
global weight_sensor
i2c = I2C(0, scl=Pin(54), sda=Pin(53), freq=100000)
weight_sensor = WeightI2CUnit(i2c, 0x26)
def loop():
...
global weight_sensor
print(weight_sensor.get_adc_raw)
After the WiFi/MQTT and sensor sections are operational, UI coding begins by binding the screen logic. This involves associating callback code with the button UI components on both screens.
def main_reset_btn_cb(event_struct):
event = event_struct.code
if event == lv.EVENT.PRESSED:
print('Main: RESET button')
def setup():
...
reset_btn.add_event_cb(main_reset_btn_cb, lv.EVENT.ALL, None)
Code for managing pages and UI components will call relevant object methods. For example, to switch to page1, you'd use page1.screen_load( ), and to update text, you'd use digital_value.set_text( ).
def main_cal_btn_cb(event_struct):
global page1
event = event_struct.code
if event == lv.EVENT.PRESSED:
page1.screen_load()
def loop():
...
digital_value.set_text(str(weight_sensor.get_adc_raw))
The final code section is a simple state machine for weight calibration and remembering which page is currently active, allowing for updates to the corresponding UI components. The weight can be easily calibrated by subtracting the ADC value reported by the Weight-I2C unit and calculating the slope from a known weight (during code testing, a 600cc water bottle was used).
It's worth noting that rapid prototyping for GUI development with LVGL on the M5Stack Tab5 is remarkably straightforward if you understand and choose the right tools. However, this code was written quickly within a day, so it's not entirely stable yet and has several minor issues. For instance, recalibration is required every time the device is reset or powered off. This problem could be solved by using the API to save the adc_offset and adc_slope values to the ESP32's NVS (Non-Volatile Storage) memory.
Comments