RkBlog

Scripting LCD on Wio Terminal with ArduPy

2021-12-06

One of Wio Terminal key features is the LCD display. It can be used to provide a graphical interface for your device. In this tutorial I'll go over using the LCD in MicroPython with ArduPy.

ArduPy on Wio Terminal

To get ArduPy running on Wio Terminal we have to flash it with latest ArduPy build. It's described on wiki and in short it's like so:

Optionally you can install aip on your local machine:

pip install ardupy-aip

This tool can help managing Wio Terminal, especially for ArduPy.

When we are in ArduPy mode we have typical for MicroPython main.py file - code there will be executed on board boot.

Wio Terminal in Arduino mode
Wio Terminal in Arduino mode
Wio Terminal after flashing ArduPy firmware
Wio Terminal after flashing ArduPy firmware

Scripting LCD via ArduPy

There is a API reference for the LCD and a simple Hello World looks like so:

from machine import LCD 
lcd = LCD()                            # Initialize LCD and turn the backlight
lcd.fillScreen(lcd.color.BLACK)        # Fill the LCD screen with color black
lcd.setTextSize(2)
lcd.setTextColor(lcd.color.WHITE)
lcd.drawString("Hello World!", 0, 0)

To use the LCD you just initialize it and then you can draw at specified coordinates (LCD has 320x240 pixels).

Wio Terminal LCD Hello World
Wio Terminal LCD Hello World
Hello World works just that the cover cuts a bit of the screen off

You can also draw various geometrical shapes like circles, triangles, rectangles or simple lines:

import time

from machine import LCD
 
lcd = LCD()                            # Initialize LCD and turn the backlight
lcd.fillScreen(lcd.color.BLACK)        # Fill the LCD screen with color black
lcd.setTextSize(2)
lcd.setTextColor(lcd.color.WHITE)

x = 320
y = 240

x_start = 10
y_start = 5

for i in range(1, 10):
    lcd.drawString("Hello World! %s" % i, x_start, y_start)
    lcd.drawCircle(int(x/2), int(y/2), int(10*i), lcd.color.GREEN)
    lcd.drawRect(x_start, y_start, int(10*i), int(10*i), lcd.color.RED)
    lcd.drawTriangle(x, y_start, int(x-20*i), y_start, x, y, lcd.color.BLUE)
    time.sleep(1)

Here in a for-loop we draw text and some geometrical shapes. Notice how text overwrites previous one as it's in the same spot while shapes draw bigger and bigger versions of themselves. To clear screen you would have to use fillScreen but that is quite expensive operation and for a short while the screen will be completely blank. In such cases the best way is to draw the static UI once and make the dynamic part in a way that it just over-draws itself without the need of full screen redraw.

Drawing geometrical shapes
Drawing geometrical shapes
Drawing geometrical shapes

Here is more advanced example with static and dynamic part. For static we want to draw borders around the screen and labels and for dynamic we want to display current radius and draw a circle for said radius:

import time

from machine import LCD

lcd = LCD()
lcd.fillScreen(lcd.color.BLACK)

x = 320
y = 240

x_start = 5
y_start = 5

x_max = x - int(x_start * 2)
y_max = y - int(y_start * 2)


class CircleApplication:
    LABEL_PADDING = 5
    SECOND_LINE = 50
    SECOND_COLUMN = 90

    def __init__(self, lcd):
        self.lcd = lcd

    def draw_ui(self):
        self._draw_borders()
        self._draw_labels()

    def _draw_borders(self):
        self.lcd.drawFastHLine(x_start, y_start, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastHLine(x_start, y_start + self.SECOND_LINE, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastHLine(x_start, y_max, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(x_start, y_start, y_max - y_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(x_max, y_start, y_max - y_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(
            x_max - self.LABEL_PADDING - self.SECOND_COLUMN - self.LABEL_PADDING,
            y_start, self.SECOND_LINE, lcd.color.GREEN)

    def _draw_labels(self):
        self.lcd.setTextSize(1)
        self.lcd.setTextColor(lcd.color.WHITE)
        lcd.drawString('Loop index:', x_start + self.LABEL_PADDING, y_start + self.LABEL_PADDING)
        lcd.drawString(
            'Current radius:',
            x_max - self.LABEL_PADDING - self.SECOND_COLUMN,
            y_start + self.LABEL_PADDING,
        )

    def draw_circle(self, index):
        self._draw_loop_index(index)
        radius = self._get_radius(index)
        self._draw_radius(radius)
        self._draw_circle(radius)

    def _draw_loop_index(self, index):
        self.lcd.setTextSize(3)
        self.lcd.setTextColor(self.lcd.color.BLUE)
        self.lcd.drawString(
            str(index),
            x_start + 120,
            y_start + 15,
        )

    def _get_radius(self, index):
        return index * 15

    def _draw_radius(self, radius):
        self.lcd.drawString(
            str(radius),
            x_max - int(self.SECOND_COLUMN / 2) - 25,
            27,
        )

    def _draw_circle(self, radius):
        self.lcd.drawCircle(int((x - + self.SECOND_LINE)/2), int(y/2), radius, self.lcd.color.GREEN)


c = CircleApplication(lcd)
c.draw_ui()
c.draw_circle(2)

draw_ui method draws the static part while draw_circle draws the dynamic part for given index (like in the for-loop example before). A lot of this code is just getting X/Y coordinates. It could potentially be replace by drawing a bitmap (BMP graphics file) which would be easier to draw and update in the future.

In this example we get subsequent circles. But we can also clear the previous circle by drawing it in black when next circle is being drawn:

def _draw_current_circle(self, radius, index):
        self._draw_circle(radius, self.lcd.color.GREEN)
        if index > 1:
            self._draw_circle(self._get_radius(index - 1), self.lcd.color.BLACK)

    def _draw_circle(self, radius, color):
        self.lcd.drawCircle(int(x/2), int(y/2) + int(self.SECOND_LINE / 2), radius, color)
Wio Terminal: Drawing geometric shapes
Wio Terminal: UI example

Using built-in buttons

Wio Terminal has 3 top buttons. Their IDs are available in machine.Map module:

from machine import Pin
from machine import Map

a = Pin(Map.WIO_KEY_A, Pin.IN)
b = Pin(Map.WIO_KEY_B, Pin.IN)

This is typical MicroPython code and API. However ArduPy lacks pyb module and the Pin class doesn't have irq method - no support for interrupts on buttons. So to handle actions on button press we have to use a while loop.

Let start with the CircleApplication class (with few modifications to circle drawing):

import time

from machine import LCD
from machine import Pin
from machine import Map

lcd = LCD()                            # Initialize LCD and turn the backlight
lcd.fillScreen(lcd.color.BLACK)        # Fill the LCD screen with color black

x = 320
y = 240

x_start = 5
y_start = 5

x_max = x - int(x_start * 2)
y_max = y - int(y_start * 2)


class CircleApplication:
    LABEL_PADDING = 5
    SECOND_LINE = 50
    SECOND_COLUMN = 90

    def __init__(self, lcd):
        self.lcd = lcd

    def draw_ui(self):
        self._draw_borders()
        self._draw_labels()

    def _draw_borders(self):
        self.lcd.drawFastHLine(x_start, y_start, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastHLine(x_start, y_start + self.SECOND_LINE, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastHLine(x_start, y_max, x_max - x_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(x_start, y_start, y_max - y_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(x_max, y_start, y_max - y_start, lcd.color.GREEN)
        self.lcd.drawFastVLine(
            x_max - self.LABEL_PADDING - self.SECOND_COLUMN - self.LABEL_PADDING,
            y_start, self.SECOND_LINE, lcd.color.GREEN)

    def _draw_labels(self):
        self.lcd.setTextSize(1)
        self.lcd.setTextColor(lcd.color.WHITE)
        lcd.drawString('index value:', x_start + self.LABEL_PADDING, y_start + self.LABEL_PADDING)
        lcd.drawString(
            'Current radius:',
            x_max - self.LABEL_PADDING - self.SECOND_COLUMN,
            y_start + self.LABEL_PADDING,
        )

    def draw_circle(self, index, previous_index):
        self._draw_loop_index(index)
        radius = self._get_radius(index)
        self._draw_radius(radius)
        self._draw_current_circle(radius, previous_index)

    def _draw_loop_index(self, index):
        self.lcd.setTextSize(3)
        self.lcd.setTextColor(self.lcd.color.BLUE)
        self.lcd.drawString(
            str(index),
            x_start + 120,
            y_start + 15,
        )

    def _get_radius(self, index):
        return index * 15

    def _draw_radius(self, radius):
        self.lcd.drawString(
            str(radius),
            x_max - int(self.SECOND_COLUMN / 2) - 25,
            27,
        )

    def _draw_current_circle(self, radius, previous_index):
        self._draw_circle(radius, self.lcd.color.GREEN)
        if previous_index:
            self._draw_circle(self._get_radius(previous_index), self.lcd.color.BLACK)

    def _draw_circle(self, radius, color):
        self.lcd.drawCircle(int(x/2), int(y/2) + int(self.SECOND_LINE / 2), radius, color)

And now the usage. We want to increase the circle on button press for button a and decrease it on button b press:

c = CircleApplication(lcd)
c.draw_ui()


a = Pin(Map.WIO_KEY_A, Pin.IN)
b = Pin(Map.WIO_KEY_B, Pin.IN)
index = 1
old_index = None
c.draw_circle(index, old_index)

while True:
    time.sleep(0.1)
    if a.value() == 0:
        if index < 5:
            old_index = index
            index += 1
            c.draw_circle(index, old_index)
    elif b.value() == 0:
        if index > 1:
            old_index = index
            index -= 1
            c.draw_circle(index, old_index)
    print('ping')
Wio Terminal: LCD + Buttons example

Displaying bitmap on LCD

ArduPy lacks Arduino GFX libraries (Adafruit) but still we can draw an image on the display. It's quite tricky. We can set any RGB color to a pixel so we need to convert a 320x240 BMP image to a list of lists containing R,G,B values for each pixel. Pillow on your PC can get such data:

from PIL import Image
import csv

im = Image.open('image.bmp')
pixels = list(im.getdata())

The problem is on microcontrollers there is very little RAM so we can't just read the whole image data at once. I had to make a CSV file and read line by line to fit within the memory limits. This generates 100 lines of pixel data:

import csv

from PIL import Image


im = Image.open('image.bmp')
pixels = list(im.getdata())

res = []

for pixel in pixels:
    pixel = f'{pixel[0]}.{pixel[1]}.{pixel[2]}'
    res.append(pixel)


block_size = 768

with open('pixels.csv', 'w') as csvfile:
    writer = csv.writer(csvfile)

    for i in range(100):
        start = i * block_size
        end = (i + 1) * block_size
        writer.writerow(res[start:end])

I copyed pixels.csv to Wio Terminal and updated main.py to:

from machine import LCD

lcd = LCD()
lcd.fillScreen(lcd.color.WHITE)

x = 0
y = 0

file_handle = open('names.csv', 'r')

for i in range(0, 100):
    line = file_handle.readline()
    image = line.split(',')
    for pixel in image:
        pixel = pixel.strip()
        if pixel:
            r, g, b = pixel.split('.')
            lcd.drawPixel(x, y, lcd.color565(int(r), int(g), int(b)))
        x += 1
        if x == 320:
            y += 1
            x = 0

readline reads a line from the file and will read next line on next call. This allows processing large amount of data due to small chunks.

Wio LCD - displaying an image
Wio LCD - displaying an image
Wio Terminal - displaying an image
Comment article