Well, Dublin Maker is coming up on the 23rd of July this year. The unofficial electronic badge design is coming along well. I think (hope!) that the PCB design is fine and it will be sent off for manufacture soon. You can see the 3D viewer output from KiCad below. U3 is the pin header for the display, U2 is a pin header for the boost converter. U1 is an NRF52833 module. SW7 is shown as a pin header but is in fact an on/off switch with the same pin pitch. A “Simple Add-On” connector (SAO1) is also provided. This nearly conforms to the BadgeLife SAO 1.69 standard. It provides power, I2C and a single GPIO (rather than 2). I ran out of GPIO port bits in the design as I will not be using the pads underneath the NRF52833 module (can’t hand solder them). The IDC socket for the SAO interface will not be populated in the final badge but will instead be left to anyone who cares to solder one on.
All components for the build have now arrived except for the battery cases. These can hold a single AA battery and are mounted on the back of the badge. One last check and it will be off to PCBWay with the design!
All of the games I have written to date for embedded systems such as Breadboard Games and badges featured a uniform background which was usually black. This was simple to implement . If a character is to be moved, it is first overwritten with the background colour and then redrawn at a new location. More complex backgrounds that featured terrain such as grass or rock were not so easy to deal with. In a desktop programming environment I would probably tackle the problem as follows:
(1) Make a copy of the area that the character will obscure
(2) Draw the character
If a character is then to be moved, you simply write the copy of the obscured area to the screen to hide the character and then repeat the process at a new location.
Step (1) requires a readable display buffer. The ST7789 in this project is a write-only device. In theory I could make a frame buffer big enough to hold the entire screen and then repeatedly write this out over the SPI interface. The display has 240×240 pixels, each pixel encoded in 16 bits. A complete framebuffer for this requires 240x240x2 = 115200 bytes. The NRF52833 MCU driving the display has 128kB of RAM so not much would be left over for stack and variables. Dividing the display up into “tiles” can greatly reduce the RAM requirements.
Let’s divide the screen up into 30×30 pixel tiles. This may seem a little large however the ST7789 screen is very small and has a high pixel density. A 30×30 tile represents an area of 3mm x 3mm approx within which a texture or character is drawn. Let’s also use the following two tiles:
The left tile represents a character in a game (surrounded by transparency), the right one represents grass on the ground. We can completely fill the screen with 64 grass tiles. When our character moves across this background it can cover (at least partially) at most 4 grass tiles. This means that we can make use of a framebuffer in RAM that is 2 tiles x 2 tiles or 60x60x2 bytes in size (7200 bytes). We can move the character across the framebuffer in RAM and then write the framebuffer to the display. If the character moves beyond the edge of the framebuffer we can move where we write the framebuffer to the display and adjust the character’s position within the framebuffer. The image below shows how the framebuffer moves when the character moves diagonally across the screen (the background was not drawn to emphasize the movement).
The character moves across screen as shown in the following video:
As can be seen, the movement is smooth and quite fast (it is actually artificially slowed down). There is however a problem: The bounding square of the character is drawn as white which overwrites the background. It would be much better if the background bounding rectangle for the character was treated as transparent.
A slight detour for PNG files.
I’m using KolourPaint in KDE (Kubuntu) to produce the tiles. These images are saved as PNG files with 4 channels: Red, Green, Blue and Alpha. The Alpha channel represents the transparency of a pixel. In this case I’m concerned with two levels of this: completely opaque (Alpha=255) and completely transparent (Alpha = 0). These PNG files are converted to C header files which encode the RGB values into a 16 bit colour value suitable for use with the ST7789. As mentioned in a previous blog post, the ST7789 uses 565 (RGB) encoding. A value of 11111 111111 11111 represents white. A slightly different value of 11111 111110 11111 (the LSB of the green channel is set to 0) looks nearly the same on the screen. I decided that the colour value of 11111 111111 11111 (65535) would represent “transparent” while any value that was meant to be white would be re-encoded as 11111 111110 11111 (65503). The following python script was then used to convert the png file to a C header file.
# Want to deal with transparency. Need to nominate a particular colour as "transparent"
# Going to go with 0xffff as being transparent
# if a pixel is designed to be this colour it will be changed to
# 0b11111 111110 11111
# i.e. the least significant green bit will be set to 0. This is slightly off the intended white
# but not by much.
import sys
Filename=sys.argv[1]
Forename=Filename.split(".")[0]
from PIL import Image
img=Image.open(Filename)
width, height = img.size
pixels = list(img.getdata())
print("#define ",end="")
print(Forename,end="")
print("_width ",end="")
print(width)
print("#define ",end="")
print(Forename,end="")
print("_height ",end="")
print(height)
print("static const uint16_t ",end="")
print(Forename,end="")
print("[]={")
for x in range(0,width):
for y in range (0, height):
(Red,Green,Blue,Alpha) = pixels[(x*height)+y]
# Colour format : Red : 5 bits, Green 6 bits, Blue 5 bits
# Assuming all components are in range 0 - 255
if (Alpha == 255):
Red = Red >> 3 # discard 3 bits
Blue = Blue >> 3 # discard 3 bits
Green = Green >> 2 # discard 2 bits
st7789_16 = (Red << 11) + (Green << 5) + Blue
low_byte = st7789_16 & 0xff
# have to do an endian swap
high_byte = st7789_16 >> 8
st7789_16 = (low_byte << 8) + high_byte
if (st7789_16 == 0xffff):
st7789_16 = 0b1111111111011111
print(st7789_16, end="")
else:
print("65535", end="")
print(",")
print("};")
The framebuffer output functions were adapted to take this special transparent colour into account and the new motion now looks like this:
DMB2022 (Dublin Maker Badge for 2022 (not official)) is an electronic badge consisting of an NRF52833 module and a display. The display for the badge is an ST7789 module. It has an SPI interface that is operated at 32MHz. It is configured to use 16 bit RGB colour values arranged as follows:
5 most significant bits : Red
6 middle bits : Green
5 least significant bits : Blue
The NRF52833’s SPI3 is used to drive the display as SPI0 and SPI1 are limited to 8Mbs. SPI3 can operate up to 32Mbs.
The display.cpp module handles all interaction with the ST7789. Lots of LCD displays of this type have a similar pattern of operations.
At startup, the display is first configured which typically involves bringing the device out of low power mode and configuring its colour mode, orientation and optionally its colour palette. The initialization code achieves this by sending commands and data to the display. The D/C signal tells the display whether a command or data byte is being transmitted. If it is Low then a command is being sent; a High level implies data is being sent.
In order to put pixels on the display, the controlling program must first open up an aperture for drawing in. This aperture can range from the entire display to just a single pixel. Once opened, data can be written to the aperture as a continuous stream of bytes. The display performs a raster like operation on the incoming data with display lines auto-wrapping when the data stream reaches the right-hand side of the aperture.
All of the remaining graphic primitives are built on top of this mechanism (with a few performance optimizations). The display class includes the following functions (for now):
I will not do a deep dive on all of the functions, instead I will just focus on two of them : putPixel and fillRectangle.
Getting a dot on the screen : putPixel
This badge uses Zephyr OS as the underlying operating system (principally to make use of its Bluetooth Low Energy features). As a result it makes use of Zephyr’s peripheral device drivers to interface with the onboard peripherals of the NRF52833.
// Configuration for the SPI port. Note the 32MHz clock speed possible only on SPI 3
// Pin usage by SPI bus defined in app.overlay.
static const struct spi_config cfg = {
.frequency = 32000000,
.operation = SPI_WORD_SET(8) | SPI_TRANSFER_MSB | SPI_MODE_CPOL | SPI_MODE_CPHA,
.slave = 0,
};
void display::putPixel(uint16_t x, uint16_t y, uint16_t colour)
{
this->openAperture(x, y, x + 1, y + 1);
struct spi_buf tx_buf = {.buf = &colour, .len = 2};
struct spi_buf_set tx_bufs = {.buffers = &tx_buf, .count = 1};
DCHigh();
spi_write(spi_display, &cfg, &tx_bufs);
}
The SPI configuration structure cfg sets the SPI mode, bit order and role. It also specifies the transfer speed of 32Mbps. This is used in calls to spi_write.
Inside putPixel we see that a 1 pixel size aperture is opened on the display. Next, an spi_buf structure is prepared which contains a pointer to the data being written as well as it’s length. This is wrapped in an spi_buf_set structure required by the spi_write function. Finally, the D/C line is driven high indicating to the ST7789 that data is being written. The spi_write function handles the actual write operation. The first parameter for spi_write is a Zephyr device structure called spi_display which identifies the SPI device being used. This was obtained when the SPI interface was initialized and is used in a manner similar to a FILE structure in C file operations
As you can see from the above there is a bit of overhead associated with writing a pixel to the display. Functions in display.cpp that write multiple pixels don’t necessarily call putPixel as this would be too slow. Instead they interface directly with Zephyr’s I/O functions. An example of this is fillRectangle.
Filling large areas of the screen: fillRectangle
void display::fillRectangle(uint16_t x,uint16_t y,uint16_t width, uint16_t height, uint16_t colour)
{
// This routine breaks the filled area up into sections and fills these.
// This allows it to make more efficient use of the control lines and SPI bus
#define PIXEL_CACHE_SIZE 64
static uint16_t fill_cache[PIXEL_CACHE_SIZE]; // use this to speed up writes
uint32_t pixelcount = height * width;
uint32_t blockcount = pixelcount / PIXEL_CACHE_SIZE;
this->openAperture(x, y, x + width - 1, y + height - 1);
DCHigh();
struct spi_buf tx_buf = {.buf = &colour, .len = 2};
struct spi_buf_set tx_bufs = {.buffers = &tx_buf, .count = 1};
if (blockcount)
{
for (int p=0;p<PIXEL_CACHE_SIZE;p++)
{
fill_cache[p]=colour;
}
}
while(blockcount--)
{
tx_buf.buf=fill_cache;
tx_buf.len = PIXEL_CACHE_SIZE*2;
spi_write(spi_display, &cfg, &tx_bufs);
}
pixelcount = pixelcount % PIXEL_CACHE_SIZE;
while(pixelcount--)
{
tx_buf.buf = &colour;
tx_buf.len = 2;
spi_write(spi_display, &cfg, &tx_bufs);
}
}
Filling a rectangle on screen consists of two steps:
Open an aperture for the rectangle
Write pixel values to the display.
In an attempt to reduce the number of Zephyr API function calls this function divides the pixel data into chunks of size PIXEL_CACHE_SIZE. This allows that number of pixels to be written in a single call to spi_write (and maybe to leverage any optimization within the driver such as DMA). For example, suppose you want to fill an area of the screen consisting of 200 pixels. Using the value of 64 as a block size this would require the writing of three 64 pixel block and 8 individual pixel writes. So 200 calls to spi_write have been reduced to 11. If the function were to use putPixel then it would suffer from the additional overhead of opening an aperture for each individual pixel as well as controlling the D/C line for each of them. The above mechanism is a lot faster.
Code for all of this is very much a work in progress and is available over here on github. It will be changing a lot over the next few weeks
It looks like Dublin Maker will actually happen this year :). I had planned a second badge for 2020 but that never happened. I initially thought that I might simply move that plan to this year however testing of the radio link proved to be inadequate for my needs (it used an NRF24L01 module). In the meantime I have been working with Zephyr OS and the BBC Microbit V2. The Microbit is based on an NRF52833 MCU which is capable of doing BLE Mesh networking. This looks quite attractive so I have begun moving my design to this platform. Basic Mesh networking seems to be ok and the graphics routines are working. I need to do a full hardware prototype next using some NRF52833 modules before a batch of badges is produced.
This is the first in a set of posts that will follow the development of the badge.