Geile Grafik, mit wenig Aufwand, für den ESP32
In letzter Zeit benutze ich für den ESP und ESP32 immer mehr Micropython (wie z.B. hier: CLM, Nobb-E, MessageInAbottle)
weils einfach verdammt schnell geht.
Um mit dem ESP32 noch umwerfende Grafik zu machen gibt es lv_micropython.
Leider musst du dir das für deinen Controller selbst bauen und kannst nich sofort loslegen.
Ich werd in diesem Betrag am Ende ein fertiges Binary von lv_micropython anhängen,
damit du ohne großen Aufwand in diese geniale Grafikbibliothek einsteigen kannst.
Eine Übersicht was LVGL alles kann findest du hier LVGL-Api
Für Micropython mit LVGL gibt es einen online Simulator, mit dem du ohne Hardware alles an Grafik schon mal ausprobieren kannst.
Ein cooles Beispiel ist z.B. das hier
Ich nutze den online Simulator für das komplette UI-Design – erst dann pack ichs auf den Controller.
Und wenns etwas größer wird einfach den Linux/SDL-Port
Das ist einfach und unglaublich effizient.
Aber am Ende muss es doch auf den Controller und dafür müssen wir ein wenig was tun.
Als erstes muss der Micropython-Interpreter mit LVGL-Bindings auf deinen Controller.
Hier gibt es zwei unterschiedliche Varianten.
- ESP32 WROOM mit nur 320kB-RAM
- ESP32 WROVER mit gigantischen 4MB-RAM (man bekommt auch mehr – können wir aber nicht wirklich nutzen)
Einfache Programme laufen auf beiden, aber preislich unterscheiden se sich fast ned – Daher fällt die Auswahl leicht 🙂
Im ZIP-File am Ende findest du trotzdem einen Ordner für beide Varianten.
Was wir jetzt brauchen ist noch ein Display.
Ich hab dieses hier schon ne Weile in Verwendung: AZ-Touch
Gleich mit Gehäuse für faule Leute wie mich 🙂
Ich benutze für Micropython auf dem ESP32 meistens Thonny
Der dient als IDE und kann auch Dateien auf das Flash-Dateisystem des Controllers schieben.
Eine Datei, die wir dorthin schieben müssen ist display_driver.py mit folgendem Inhalt:
import ili9XXX
from xpt2046 import xpt2046
import lvgl as lv
disp = ili9XXX.ili9341(miso=19, mosi=23, clk=18, dc=4, cs=5, rst=22, power=-1, backlight=15, mhz=20, width=320, height=240, rot=ili9XXX.LANDSCAPE, factor=16)
touch = xpt2046(cs=14, transpose=False, cal_x0 = 3783, cal_y0 = 3600, cal_x1 = 242, cal_y1 = 123)
Diese kannst du bei Bedarf an andere Displays anpassen
Jetzt kannst du dir noch eines der Simulator-Beispiele von hier in den Mu-Editor kopieren und einfach (auf dem Gerät) ausführen.
Bist du zufrieden, dann schiebst du deinen Quellcode als main.py auf den Controller und beim nächsten Reset ist deine Grafik da und tut Dinge 🙂
Ich hab das mal mit dem Meter-Beispiel von oben gemacht:
Man beachte den schnellen Start (ca. 1 Sekunde). Das mit einem embedded Linux zu übertreffen wird schwierig 🙂
Downloads:
lv_micropython-WROVER.zip
(hier hab ich auch noch die deutschen Umlaute mit in die Systemfont gepackt)
Neues Board
Bei Amazon gibt es ein schönes neues Board: https://www.amazon.de/dp/B0CSYPG716
Leider war das Touch-Screen etwas zickig und ich musste den Treiber dafür etwas anpassen (Brauchte eine SoftSPI weil die Pins so komisch konfiguriert waren) Mit der richtigen display_driver.py klappts aber damit auch:
import sys
import espidf as esp
from machine import *
import ili9XXX
import lvgl as lv
disp = ili9XXX.ili9341(factor=32, double_buffer=False, clk=14, cs=15, dc=2, rst=12, power=23, backlight=21, miso=12, mosi=13,width=320, height=240, backlight_on=1, rot=ili9XXX.LANDSCAPE)
spi2=SoftSPI(baudrate=2500000,sck=Pin(25),mosi=Pin(32),miso=Pin(39))
class xpt2046:
CMD_X_READ = const(0x90)
CMD_Y_READ = const(0xd0)
CMD_Z1_READ = const(0xb8)
CMD_Z2_READ = const(0xc8)
MAX_RAW_COORD = const((1<<12) - 1)
def __init__(self, spi, cs, max_cmds=16, cal_x0 = 3783, cal_y0 = 3948, cal_x1 = 242, cal_y1 = 423,
transpose = True, samples = 3):
# Initializations
if not lv.is_initialized():
lv.init()
disp = lv.disp_t.__cast__(None)
self.screen_width = 320
self.screen_height = 240
self.spi = spi
self.cs = cs
self.recv = bytearray(3)
self.xmit = bytearray(3)
self.max_cmds = max_cmds
self.cal_x0 = cal_x0
self.cal_y0 = cal_y0
self.cal_x1 = cal_x1
self.cal_y1 = cal_y1
self.transpose = transpose
self.samples = samples
self.touch_count = 0
self.touch_cycles = 0
indev_drv = lv.indev_drv_t()
indev_drv.init()
indev_drv.type = lv.INDEV_TYPE.POINTER
indev_drv.read_cb = self.read
indev_drv.register()
def calibrate(self, x0, y0, x1, y1):
self.cal_x0 = x0
self.cal_y0 = y0
self.cal_x1 = x1
self.cal_y1 = y1
def deinit(self):
print('Deinitializing XPT2046...')
def touch_talk(self, cmd, bits):
if self.cs is not None:
self.cs(0)
self.xmit[0] = cmd
self.spi.write_readinto(self.xmit, self.recv)
if self.cs is not None:
self.cs(1)
return (self.recv[1] * 256 + self.recv[2]) >> (15 - bits)
# @micropython.viper
def xpt_cmds(self, cmds):
result = []
for cmd in cmds:
value = self.touch_talk(cmd, 12)
if value == int(self.MAX_RAW_COORD):
value = 0
result.append(value)
return tuple(result)
# @micropython.viper
def get_med_coords(self, count : int):
mid = count//2
values = []
for i in range(0, count):
values.append(self.xpt_cmds([self.CMD_X_READ, self.CMD_Y_READ]))
x_values = sorted([x for x,y in values])
y_values = sorted([y for x,y in values])
if int(x_values[0]) == 0 or int(y_values[0]) == 0 : return None
#print(x_values[mid], y_values[mid])
return x_values[mid], y_values[mid]
# @micropython.viper
def get_coords(self):
med_coords = self.get_med_coords(int(self.samples))
if not med_coords: return None
if self.transpose:
raw_y, raw_x = med_coords
else:
raw_x, raw_y = med_coords
if int(raw_x) != 0 and int(raw_y) != 0:
x = ((int(raw_x) - int(self.cal_x0)) * int(self.screen_width)) // (int(self.cal_x1) - int(self.cal_x0))
y = ((int(raw_y) - int(self.cal_y0)) * int(self.screen_height)) // (int(self.cal_y1) - int(self.cal_y0))
return x,y
else: return None
# @micropython.native
def get_pressure(self, factor : int) -> int:
z1, z2, x = self.xpt_cmds([self.CMD_Z1_READ, self.CMD_Z2_READ, self.CMD_X_READ])
if int(z1) == 0: return -1
return ( (int(x)*factor) / 4096)*( int(z2)/int(z1) - 1)
start_time_ptr = esp.C_Pointer()
end_time_ptr = esp.C_Pointer()
cycles_in_ms = esp.esp_clk_cpu_freq() // 1000
# @micropython.native
def read(self, indev_drv, data) -> int:
esp.get_ccount(self.start_time_ptr)
coords = self.get_coords()
#print(coords)
esp.get_ccount(self.end_time_ptr)
if self.end_time_ptr.int_val > self.start_time_ptr.int_val:
self.touch_cycles += self.end_time_ptr.int_val - self.start_time_ptr.int_val
self.touch_count += 1
if coords:
data.point.x ,data.point.y = coords
data.state = lv.INDEV_STATE.PRESSED
return False
data.state = lv.INDEV_STATE.RELEASED
return False
def stat(self):
return self.touch_cycles / (self.touch_count * self.cycles_in_ms)
touch = xpt2046(spi=spi2, cs=Pin(33), cal_x1 = 3700, cal_y1 = 3820, cal_x0 = 180, cal_y0 = 250, transpose=False)
lv.init()
ESP-NOW
Ich hab mich riesig gefreut, dass Harald mich letzte Woche über das Kontaktformular angeschrieben und mir von EspNow erzählt hat. EspNow ist ein sehr einfaches Protokoll, mit dem sich ESPs untereinander kurze Nachrichten senden können ohne sich vorher zu verbinden (verwenden dazu einfach die WiFi-MAC-Addresse). Unter https://docs.espressif.com/projects/esp-faq/en/latest/application-solution/esp-now.html findet man erste Informationen zu diesem coolen Protokoll. Und natürlich in der Micropython-Doku: https://docs.micropython.org/en/latest/library/espnow.html
Ich kannte EspNow garnicht, finds aber superklasse und hab Harald versprochen, dass ichs natürlich einbaue, damit man auch grafisch rumfunken kann 🙂
So erstmal die Firmware, jetzt mit ESP-NOW:
Harald hat gemeint er benutzt gerne asyncio und braucht noch aioespnow:
Und natürlich ein kleines Beispiel, was man damit machen kann:
Sender (mit Grafik)
import network
import espnow
import lvgl as lv
import display_driver
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.disconnect()
en = espnow.ESPNow()
en.active(True)
peer = b'\xff\xff\xff\xff\xff\xff' # MAC broadcast-address
en.add_peer(peer)
color = "red"
count = 1
def send():
en.send(peer, color + " " + str(count))
def event_handler(e):
global color
code = e.get_code()
obj = e.get_target()
if code == lv.EVENT.VALUE_CHANGED:
option = " "*10
obj.get_selected_str(option, len(option))
color = option.strip()
send()
print("Color: " + color)
roller1 = lv.roller(lv.scr_act())
roller1.set_options("\n".join([
"red",
"green",
"blue"]),lv.roller.MODE.INFINITE)
roller1.set_width(300)
roller1.set_visible_row_count(3)
roller1.align(lv.ALIGN.CENTER,0,-50)
roller1.add_event_cb(event_handler, lv.EVENT.ALL, None)
def slider_event_cb(e):
global count
slider = e.get_target()
slider_label.set_text("{:d}%".format(slider.get_value()))
slider_label.align_to(slider, lv.ALIGN.OUT_BOTTOM_MID, 0, 10)
count = int(slider.get_value() / 6)
send()
# Create a slider in the center of the display
slider = lv.slider(lv.scr_act())
slider.align(lv.ALIGN.CENTER,0,50)
slider.add_event_cb(slider_event_cb, lv.EVENT.VALUE_CHANGED, None)
# Create a label below the slider
slider_label = lv.label(lv.scr_act())
slider_label.set_text("0%")
slider_label.align_to(slider, lv.ALIGN.OUT_BOTTOM_MID, 0, 10)
Empfänger (mit Neopixel-Ring)
import network
import espnow
import time
import machine, neopixel
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.disconnect()
e = espnow.ESPNow()
e.active(True)
peer = b'\xff\xff\xff\xff\xff\xff' # MAC broadcast-address
e.add_peer(peer)
np = neopixel.NeoPixel(machine.Pin(5), 16)
def disp(msg):
part = msg.split(" ")
color = (255, 0, 0)
if "green" in part[0]:
color = (0, 255, 0)
if "blue" in part[0]:
color = (0, 0, 255)
print(part[1])
cnt = int(part[1].strip("'"))
for i in range(0, 15):
if i < cnt:
np[i] = color
else:
np[i] = (0,0,0)
np.write()
disp("green 3")
def messageReceived(obj):
host, msg = e.recv(100)
print(host, msg)
disp(str(msg))
e.irq(messageReceived)
while True:
time.sleep_ms(100)
print(".")
Hinweis: wenn man, wie ich, zu faul ist eine MAC-Adresse einzugeben, kann man mit EspNow auch einfach an die Broadcast-Addresse FF:FF:FF:FF:FF:FF senden. Dann geht’s einfach an alle ESPs in Reichweite 🙂
Vereinfachtes Arbeiten, ohne Gerät, mit Thonny
Beim Erstellen von UIs stört noch das ständige Übertragen des Programms auf den ESP und das dortige Testen – das geht auf dem PC viel schneller und komfortabler.
Alles was du brauchst ist das AppImage hier:
Den Rest kann Thonny:
Viel Spaß damit 🙂
API-Doku etwas mehr Python-Like
Was mich noch gestört hat, ist dass die API-Doku im Netz leider nur die C-API beschreibt und ich immer etwas umdenken muss. Hab mal versucht aus einer JSON-Datei die beim Build entsteht etwas HTML zu generieren. Taugt zwar bei den Datentypen immer noch C auf – aber ist nach Objekten und Methoden aufgeteilt. Weiß ned obs so schon brauchbar ist. Möchte ich euch aber nicht vorenthalten:
https://stefan.box2code.de/static/Lvgl8.3_API_ForMicropython.html
Viel Spaß damit. Wenn ihr Verbesserungsideen habt – einfach her damit 🙂
Micropython mit LVGL für Windows
Nach dem AppImage für Linux hab ich mich noch mal hingesetzt und auch eine EXE für Windows generiert. Leider hat Thonny für Windows kein [Micropython (local)] als Einstellung aber damit kannst du deine Programme jetzt auch unter Windows laufen lassen. Was ich sehr cool finde ist dass der Interpreter nur ca. 5 MB groß ist.
Hier der Download:
Cheap Yellow Displays mit gedrehten Farben
Daniel hat mich vor einiger Zeit kontaktiert und mir erzählt, dass es das schöne günstige Board jetzt bei AliExpress für unter 7 € gibt. Diese Boards haben aber scheinbar Rot und Grün anders gedreht. Abhilfe schafft eine kleine Änderung in der display_driver.py:
disp = ili9XXX.ili9341(..., colormode=ili9XXX.COLOR_MODE_RGB)
Ich muss mir dort unbedingt auch ein paar holen 🙂
Image mit SquareLine Studio nutzen
Gestern hat mich Thomas über das Kontaktformular kontaktiert und mir erzählt, dass er versucht das Image mit SquareLine Studio zu verwenden. SquareLine Studio ist ein GUI-Designer, mit dem du deine GUI grafisch entwerfen kannst.
Wir haben rausgefunden, dass es geht wenn man ein paar Kleinigkeiten beachtet:
Beim Erzeugen des Projekts solltest du folgende Einstellungen verwenden:
Wichtig ist die LVGL-Version v8.3.6, da das Image auf v8.3.7 basiert
Die generierten Dateien deines Projekts schiebst du dann einfach mit Thonny auf deinen ESP.
Zusätzlich brauchst du noch folgenden einfachen Inhalt in deiner main.py, da der Treiber vom generierten Skript sonst nicht vor LVGL geladen wird
import display_driver
import ui # das ist die Datei mit deinem generierten code
SquareLine Studio ist ein sehr schönes Tool geworden und scheint gut zu funktionieren. Thomas hat gemeint, dass er eher aus der Hardwareecke kommt und das es genau das richtige Tool für ihn ist. Vielleicht gilt das ja auch für dich 🙂
Viele Screens mit wenig RAM-Verbrauch
Für viele scheint das wenige RAM z.B. bei Verwendung des CYD (Cheap Yellow Display) ein großes Problem zu sein. Ich hab heute mal versucht eine Blaupause zu erstellen, mit der man Programme mit unzähligen Screens machen kann ohne dass das RAM ausgeht. Der Trick dabei ist es, ausschließlich den angezeigten Screen ins RAM zu laden. Das geht eigentlich sehr sehr einfach. Das Programm unten simuliert 100 Screens und braucht nur wenige Kilobyte RAM (nämlich immer nur für den angezeigten Screen). Hoffe euch gefällts:
import lvgl as lv
import display_driver
import gc
class ScreenWithButtonOnly:
def __init__(self, text, nextScreenNum):
self.text = text
self.nextScreen = nextScreenNum
def load(self):
self.screen = lv.obj()
self.btn = lv.btn(self.screen)
self.btn.add_event_cb(self.eventhandler, lv.EVENT.ALL, None)
self.btn.align(lv.ALIGN.CENTER, 0, 0)
self.btn.set_size(200,100)
self.label = lv.label(self.btn)
self.label.set_text(self.text)
def eventhandler(self, e):
if e.code == lv.EVENT.CLICKED:
changeScreen(self.nextScreen)
class ScreenWithChartAndButton:
def __init__(self, text, nextScreenNum):
self.text = text
self.nextScreen = nextScreenNum
def load(self):
self.screen = lv.obj()
self.chart = lv.chart(self.screen)
self.chart.set_size(200, 150)
self.chart.align(lv.ALIGN.TOP_MID, 0, 10)
self.chart.set_type(lv.chart.TYPE.LINE)
self.ser1 = self.chart.add_series(lv.palette_main(lv.PALETTE.RED), lv.chart.AXIS.PRIMARY_Y)
self.ser1.y_points = [0, 20, 40, 60, 80, 100, 80, 60, 40, 20, 0]
self.chart.refresh()
self.btn = lv.btn(self.screen)
self.btn.add_event_cb(self.eventhandler, lv.EVENT.ALL, None)
self.btn.align(lv.ALIGN.BOTTOM_MID, 0, -10)
self.btn.set_size(300,50)
self.label = lv.label(self.btn)
self.label.set_text(self.text)
def eventhandler(self, e):
if e.code == lv.EVENT.CLICKED:
changeScreen(self.nextScreen)
class ScreenWithCalendarAndButton:
def __init__(self, text, nextScreenNum):
self.text = text
self.nextScreen = nextScreenNum
def load(self):
self.screen = lv.obj()
self.calendar = lv.calendar(self.screen)
self.calendar.set_size(200, 160)
self.calendar.align(lv.ALIGN.TOP_MID, 0, 10)
self.btn = lv.btn(self.screen)
self.btn.add_event_cb(self.eventhandler, lv.EVENT.ALL, None)
self.btn.align(lv.ALIGN.BOTTOM_MID, 0, -10)
self.btn.set_size(300,50)
self.label = lv.label(self.btn)
self.label.set_text(self.text)
def eventhandler(self, e):
if e.code == lv.EVENT.CLICKED:
changeScreen(self.nextScreen)
def changeScreen(screenNum):
screens[screenNum].load() # build the new screen
# load the new screen (animated) and delete the old one
lv.scr_load_anim(screens[screenNum].screen, lv.SCR_LOAD_ANIM.FADE_ON, 100, 0, True)
gc.collect() # call the garbage collector
print("MEM free: " + str(gc.mem_free())) # show free memory
screens = []
for i in range(0, 100):
if i % 2 == 0:
screens.append(ScreenWithChartAndButton("Screen " + str(i), i + 1))
elif i % 3 == 0:
screens.append(ScreenWithCalendarAndButton("Screen " + str(i), i + 1))
else:
screens.append(ScreenWithButtonOnly("Screen " + str(i), i + 1))
screens.append(ScreenWithButtonOnly("Back to begin", 0))
changeScreen(0) # load initial screen