A lightweight and minimal MicroPython GUI library for display drivers based on the FrameBuffer class
Go to file
Peter Hinch d3908dd1bc Add plot images. 2018-09-23 13:53:02 +01:00
drivers Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
images Add plot images. 2018-09-23 13:32:51 +01:00
plot Add plot images. 2018-09-23 13:53:02 +01:00
.gitignore Initial commit 2018-08-29 18:07:08 +01:00
LICENSE Initial commit 2018-08-29 18:07:08 +01:00
README.md Add plot images. 2018-09-23 13:32:51 +01:00
aclock.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
alevel.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
arial10.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
color15.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
color96.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
courier20.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
font6.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
freesans20.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
mono_test.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00
nanogui.py Plot module added. Release 0.1 2018-09-22 17:19:52 +01:00

README.md

A lightweight and minimal MicroPython GUI library for display drivers based on the framebuf class. With the exception of the Nokia 5110, such drivers are currently for color and monochrome OLED displays. This is coincidental.

These images don't do justice to the OLED displays which are visually impressive with bright colors and extreme contrast. For some reason they are quite hard to photograph.
Image The aclock.py demo.

Image Label objects in two fonts.

Image
One of the demos running on an Adafruit 1.27 inch OLED. The colors change dynamically with low values showing green, intermediate yellow and high red.

Image
The alevel.py demo. The Pyboard was mounted vertically: the length and angle of the vector arrow varies as the Pyboard is moved.

There is an optional graph plotting module for basic Cartesian and polar plots, also realtime plotting including time series.

Notes on Adafruit and other OLED displays including wiring details, pin names and hardware issues.

NOTE 20 Sep 2018 Under development. I am now reasonably happy with the API but I can't yet promise no changes. There may, of course, be bugs...

Contents

  1. Introduction
  2. Files and Dependencies
    2.1 Dependencies
    2.2.1 Monochrome use
    2.2.2 Color use
  3. The nanogui module
    3.1 Initialisation Initial setup and refresh method.
    3.2 Label class Dynamic text at any screen location.
    3.3 Meter class A vertical panel meter.
    3.4 LED class Virtual LED of any color.
    3.5 Dial and Pointer classes Clock or compass style display of one or more pointers.
  4. Device drivers Device driver compatibility requirements (these are minimal).

1. Introduction

This library provides a limited set of GUI objects (widgets) for displays whose display driver is subclassed from the framebuf class. Examples are:

Widgets are intended for the display of data from physical devices such as sensors. The GUI is display-only: there is no provision for user input. This is because there are no frmebuf- based display drivers for screens with a touch overlay. Authors of applications requiring input should consider my touch GUI's for the official lcd160cr or for SSD1963 based displays.

Widgets are drawn using graphics primitives rather than icons to minimise RAM usage. It also enables them to be effciently rendered at arbitrary scale on devices with restricted processing power.

Owing to RAM requirements and limitations on communication speed, framebuf based display drivers are intended for physically small displays with limited numbers of pixels. The widgets are designed for displays as small as 0.96 inches: this involves some compromises. They aim to maximise the information on screen by offering the option of dynamically changing colors.

Copying the contents of the frame buffer to the display is relatively slow. The time depends on the size of the frame buffer and the interface speed, but the latency may be too high for applications such as games. For example the time to update a 1281288 color ssd1351 display on a Pyboard 1.0 is 41ms.

Drivers based on framebuf must allocate contiguous RAM for the buffer. To avoid 'out of memory' errors it is best to instantiate the display early, possibly before importing many other modules. The aclock.py and alevel.py demos illustrate this.

2. Files and Dependencies

2.1 Files

  • nanogui.py The library.
  • mono_test.py Tests/demos using the official SSD1306 library for a monochrome 128*64 OLED display.
  • color96.py Tests/demos for the Adafruit 0.96 inch color OLED.
  • color15.py Similar for Adafruit 1.27 inch and 1.5 inch color OLEDs. Edit the height = 96 line as per the comment for the larger display.

Demos for Adafruit 1.27 inch and 1.5 inch color OLEDs. Edit the height = 96 line as per the code comment for the larger display.

  • aclock.py Analog clock demo.
  • alevel.py Spirit level using Pyboard accelerometer.

Sample fonts created by font_to_py.py:

  • arial10.py
  • courier20.py
  • font6.py
  • freesans20.py

2.2 Dependencies

All applicatons require a device driver for the display in use plus any Python font files in use. The following is required by all applications:

2.2.1 Monochrome use

OLED displays using the SSD1306 chip require:

Displays based on the PCD8544 chip require:

2.2.2 Color use

Supported displays amd their drivers are listed below:

Test script for Adafruit 1.5 and 1.27 inch color OLED displays. It's a good idea to paste this at the REPL to ensure the display is working before progressing to the GUI. Remember to change height if using the 1.5 inch display.

import machine
from ssd1351 import SSD1351 as SSD

pdc = machine.Pin('X1', machine.Pin.OUT_PP, value=0)
pcs = machine.Pin('X2', machine.Pin.OUT_PP, value=1)
prst = machine.Pin('X3', machine.Pin.OUT_PP, value=1)
spi = machine.SPI(1)
ssd = SSD(spi, pcs, pdc, prst, height=96)  # Ensure height is correct (96/128)
ssd.fill(0)
ssd.line(0, 0, 127, 95, ssd.rgb(0, 255, 0))  # Green diagonal corner-to-corner
ssd.rect(0, 0, 15, 15, ssd.rgb(255, 0, 0))  # Red square at top left
ssd.show()
Contents

3. The nanogui module

This supports widgets whose text components are drawn using the Writer (monochrome) or CWriter (colour) classes. Upside down rendering is not supported: attempts to specify it will produce unexpected results.

Widgets are drawn at specific locations on screen and are incompatible with the display of scrolling text: they are therefore not intended for use with the Writer.printstring method. The coordinates of a widget are those of its top left corner. If a border is specified, this is drawn outside of the limits of the widgets with a margin of 2 pixels. If the widget is placed at [row, col] the top left hand corner of the border is at [row-2, col-2].

When a widget is drawn or updated (typically with its value method) it is not immediately displayed. To update the display nanogui.refresh is called: this ensures that the framebuf contents are updated before copying the contents to the display. This postponement is for performance reasons and to provide the appearance of a rapid update.

3.1 Initialisation

The GUI is initialised in the following stages. The aim is to allocate the framebuf before importing other modules. This is intended to reduce the risk of memory failures when instantiating a large framebuf in an application which imports multiple modules.

Firstly set the display height and import the driver:

height = 96  # 1.27 inch 96*128 (rows*cols) display. Set to 128 for 1.5 inch
import machine
import gc
from ssd1351 import SSD1351 as SSD  # Import the display driver

Then set up the bus (SPI or I2C) and instantiate the display. At this point the framebuffer is created:

pdc = machine.Pin('X1', machine.Pin.OUT_PP, value=0)
pcs = machine.Pin('X2', machine.Pin.OUT_PP, value=1)
prst = machine.Pin('X3', machine.Pin.OUT_PP, value=1)
spi = machine.SPI(1)
gc.collect()  # Precaution before instantiating framebuf
ssd = SSD(spi, pcs, pdc, prst, height)  # Create a display instance

Finally import nanogui modules and initialise the display. Import any other modules required by the application. For each font to be used import the Python font and create a CWriter instance (for monochrome displays a Writer is used):

from nanogui import Label, Dial, Pointer, refresh  # Whatever you need
refresh(ssd)  # Initialise and clear display.

from writer import CWriter  # Import other modules
import arial10  # Font
GREEN = SSD.rgb(0, 255, 0)  # Define colors
RED = SSD.rgb(255, 0, 0)
BLUE = SSD.rgb(0, 0, 255)
YELLOW = SSD.rgb(255, 255, 0)
BLACK = 0

CWriter.set_textpos(ssd, 0, 0)  # In case previous tests have altered it
 # Instantiate any CWriters to be used (one for each font)
wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False)
wri.set_clip(True, True, False)

The nanogui.refresh method takes two args:

  1. device The display instance (supports multiple displays).
  2. clear=False If set True the display will be blanked; it is also blanked when a device is refreshed for the first time.

It should be called after instantiating the display, and again whenever the physical display is to be updated.

Contents

3.2 Label class

This supports applications where text is to be rendered at specific screen locations.

Text can be static or dynamic. In the case of dynamic text the background is cleared to ensure that short strings cleanly replace longer ones.

Labels can be displayed with an optional single pixel border.

Colors are handled flexibly. By default the colors used are those of the Writer instance, however they can be changed dynamically; this might be used to warn of overrange or underrange values.

Constructor args:

  1. writer The Writer instance (font and screen) to use.
  2. row Location on screen.
  3. col
  4. text If a string is passed it is displayed: typically used for static text. If an integer is passed it is interpreted as the maximum text length in pixels; typically obtained from writer.stringlen('-99.99'). Nothing is dsplayed until .value() is called. Intended for dynamic text fields.
  5. invert=False Display in inverted or normal style.
  6. fgcolor=None Optionally override the Writer colors.
  7. bgcolor=None
  8. bdcolor=False If False no border is displayed. If None a border is shown in the Writer forgeround color. If a color is passed, it is used.

The constructor displays the string at the required location.

Methods:

  1. value Redraws the label. This takes the following args:
    • text=None The text to display. If None displays last value.
    • invert=False If true, show inverse text.
    • fgcolor=None Foreground color: if None the Writer default is used.
    • bgcolor=None Background color, as per foreground.
    • bdcolor=None Border color. As per above except that if False is passed, no border is displayed. This clears a previously drawn border.
      Returns the current text string.
  2. show No args. (Re)draws the label. Primarily for internal use by GUI.

If populating a label would cause it to extend beyond the screen boundary a warning is printed at the console. The label may appear at an unexpected place. The following is a complete "Hello world" script.

height = 96  # 1.27 inch 96*128 (rows*cols) display. Set to 128 for 1.5 inch
import machine
import gc
from ssd1351 import SSD1351 as SSD  # Import the display driver
pdc = machine.Pin('X1', machine.Pin.OUT_PP, value=0)
pcs = machine.Pin('X2', machine.Pin.OUT_PP, value=1)
prst = machine.Pin('X3', machine.Pin.OUT_PP, value=1)
spi = machine.SPI(1)
gc.collect()  # Precaution before instantiating framebuf
ssd = SSD(spi, pcs, pdc, prst, height)  # Create a display instance
from nanogui import Label, refresh
refresh(ssd)  # Initialise and clear display.
from writer import CWriter  # Import other modules
import freesans20  # Font
GREEN = SSD.rgb(0, 255, 0)  # Define colors
BLACK = 0
CWriter.set_textpos(ssd, 0, 0)  # In case previous tests have altered it
wri = CWriter(ssd, freesans20, GREEN, BLACK, verbose=False)
wri.set_clip(True, True, False)
 # End of boilerplate code. This is our application:
Label(wri, 2, 2, 'Hello world!')
refresh(ssd)
Contents

3.3 Meter class

This provides a vertical linear meter display of values scaled between 0.0 and 1.0.

Constructor positional args:

  1. writer The Writer instance (font and screen) to use.
  2. row Location on screen.
  3. col
    Keyword only args:
  4. height=50 Height of meter.
  5. width=10 Width.
  6. fgcolor=None Foreground color: if None the Writer default is used.
  7. bgcolor=None Background color, as per foreground.
  8. ptcolor=None Color of meter pointer or bar. Default is foreground color.
  9. bdcolor=False If False no border is displayed. If None a border is shown in the Writer forgeround color. If a color is passed, it is used.
  10. divisions=5 No. of gradutions to show.
  11. label=None A text string will cause a Label to be drawn below the meter. An integer will create a Label of that width for later use.
  12. style=Meter.LINE The pointer is a horizontal line. Meter.BAR causes a vertical bar to be displayed.
  13. legends=None If a tuple of strings is passed, Label instances will be displayed to the right hand side of the meter, starting at the bottom. E.G. ('0.0', '0.5', '1.0')
  14. value=None Initial value. If None the meter will not be drawn until its value() method is called.

Methods:

  1. value Args: n=None, color=None.
    • n should be a float in range 0 to 1.0. Causes the meter to be updated. Out of range values are constrained. If None is passed the meter is not updated.
    • color Updates the color of the bar or line if a value is also passed. None causes no change.
      Returns the current value.
  2. text Updates the label if present (otherwise throws a ValueError). Args:
    • text=None The text to display. If None displays last value.
    • invert=False If true, show inverse text.
    • fgcolor=None Foreground color: if None the Writer default is used.
    • bgcolor=None Background color, as per foreground.
    • bdcolor=None Border color. As per above except that if False is passed, no border is displayed. This clears a previously drawn border.
  3. show No args. (Re)draws the meter. Primarily for internal use by GUI.
Contents

3.4 LED class

This is a virtual LED whose color may be altered dynamically.

Constructor positional args:

  1. writer The Writer instance (font and screen) to use.
  2. row Location on screen.
  3. col
    Keyword only args:
  4. height=12 Height of LED.
  5. fgcolor=None Foreground color: if None the Writer default is used.
  6. bgcolor=None Background color, as per foreground.
  7. bdcolor=False If False no border is displayed. If None a border is shown in the Writer forgeround color. If a color is passed, it is used.
  8. label=None A text string will cause a Label to be drawn below the LED. An integer will create a Label of that width for later use.

Methods:

  1. color arg c=None Change the LED color to c. If c is None the LED is turned off (rendered in the background color).
  2. text Updates the label if present (otherwise throws a ValueError). Args:
    • text=None The text to display. If None displays last value.
    • invert=False If true, show inverse text.
    • fgcolor=None Foreground color: if None the Writer default is used.
    • bgcolor=None Background color, as per foreground.
    • bdcolor=None Border color. As per above except that if False is passed, no border is displayed. This clears a previously drawn border.
  3. show No args. (Re)draws the LED. Primarily for internal use by GUI.
Contents

3.5 Dial and Pointer classes

A dial is a circular analogue clock style display showing a set of pointers. To use, the Dial is instantiated then one or more Pointer objects are instantiated and assigned to it. The Pointer.value method enables the Dial to be updated, with the length, angle and color being dynamically variable. Pointer values are complex numbers.

Constructor positional args:

  1. writer The Writer instance (font and screen) to use.
  2. row Location on screen.
  3. col
    Keyword only args:
  4. height=50 Height and width of dial.
  5. fgcolor=None Foreground color: if None the Writer default is used.
  6. bgcolor=None Background color, as per foreground.
  7. bdcolor=False If False no border is displayed. If None a border is shown in the Writer forgeround color. If a color is passed, it is used.
  8. ticks=4 No. of gradutions to show.
  9. label=None A text string will cause a Label to be drawn below the meter. An integer will create a Label of that width for later use.
  10. style=Dial.CLOCK Pointers are drawn from the centre of the circle as per the hands of a clock. Dial.COMPASS causes pointers to be drawn as arrows centred on the control's centre. Arrow tail chevrons are suppressed for very short pointers.
  11. pip=None Draws a central dot. A color may be passed, otherwise the foreground color will be used. If False is passed, no pip will be drawn. The pip is suppressed if the shortest pointer would be hard to see.

When a Pointer is instantiated it is assigned to the Dial by the Pointer constructor.

The Pointer class:

Constructor arg:

  1. dial The Dial instance on which it is to be dsplayed.

Methods:

  1. value Args:
    • v=None The value is a complex number. If its magnitude exceeds unity it is reduced (preserving phase) to constrain it to the boundary of the unit circle.
    • color=None By default the pointer is rendered in the foreground color of the parent Dial. Otherwise the passed color is used.
  2. text Updates the label if present (otherwise throws a ValueError). Args:
    • text=None The text to display. If None displays last value.
    • invert=False If true, show inverse text.
    • fgcolor=None Foreground color: if None the Writer default is used.
    • bgcolor=None Background color, as per foreground.
    • bdcolor=None Border color. As per above except that if False is passed, no border is displayed. This clears a previously drawn border.
  3. show No args. (Re)draws the control. Primarily for internal use by GUI.

Typical usage (ssd is the device and wri is the current Writer):

def clock(ssd, wri):
    # Border in Writer foreground color:
    dial = Dial(wri, 5, 5, ticks = 12, bdcolor=None)
    hrs = Pointer(dial)
    mins = Pointer(dial)
    hrs.value(0 + 0.7j, RED)
    mins.value(0 + 0.9j, YELLOW)
    dm = cmath.exp(-1j * cmath.pi / 30)  # Rotate by 1 minute
    dh = cmath.exp(-1j * cmath.pi / 1800)  # Rotate hours by 1 minute
    # Twiddle the hands: see clock.py for an actual clock
    for _ in range(80):
        utime.sleep_ms(200)
        mins.value(mins.value() * dm, RED)
        hrs.value(hrs.value() * dh, YELLOW)
        refresh(ssd)

4. Device drivers

For a driver to support nanogui it must be subclassed from framebuf and provide height and width bound variables defining the display size in pixels. This is all that is required for monochrome drivers.

For color drivers, to conserve RAM it is suggested that 8-bit color is used for the framebuf. If the hardware does not support this, conversion to the supported color space needs to be done "on the fly" as per the SSD1351 driver. Since this is likely to be slow, consider using native, viper or assembler.

Color drivers should have a static method converting rgb(255, 255, 255) to a form acceptable to the driver. For 8-bit rrrgggbb this can be:

    @staticmethod
    def rgb(r, g, b):
        return (r & 0xe0) | ((g >> 3) & 0x1c) | (b >> 6)

This should be amended if the hardware uses a different 8-bit format.

The Writer (monochrome) or CWriter (color) classes and the nanogui module should then work automatically.

If a display uses I2C note that owing to this issue soft I2C may be required, depending on the detailed specification of the chip.

Contents