Sunday 27 May 2018

Using an 8 x 7-segment SPI display with the BBC micro:bit


Introduction
I got sent ten (!) of these MAX7219 displays by accident when I had ordered something completely different on Aliexpress, so I thought I'd better learn how to use them. As usual, there are some Arduino libraries for these, and also for the Raspberry Pi but I could only find one example for the BBC micro:bit. That example is a mashup of two different drivers but, when I tried it, I found that quite a lot of things didn't work properly. In any case, I don't really like just plugging in someone else's driver because I prefer to understand what is going on "under the hood". Also, using modules with the micro:bit is a bit of a pain as it's a two-stage process which slows down program development and debugging.

So, armed with the MAX7219 data sheet, I decided to work things out for myself.

Connecting things up
This is very straightforward. The display has five pins. Apart from VCC and GND (it's 3.3v-friendly), there is DIN (data in), CS (chip select) and CLK (clock). As it is an SPI device, the default connections on the micro:bit are CLK=P13 and DIN=P14 (although these are just the defaults - you can use any pins). In addition, you'll need to connect CS to any micro:bit pin (I used P0).

Micropython code
The good news is that testing your connections is easy because the MAX7219 has a test mode which overrides any other settings so it should work every time. The photo above shows "test mode", where each segment of each digit is lit up at full intensity. The MAX7219 is programmed by sending 16 bits, of which the 4 "most significant bits" are ignored, the next 4 bits are the register addresses and the 8 "least significant bits" are the data to send to the registers. Referring to page 10 of the data sheet, to enable the "display test" mode, we need to send the register address 0xXf (where X is "don't care), followed by 0x01. Try this (using the Mu editor):

from microbit import * 
spi.init(baudrate=1000000,bits=8,mode=0, sclk=pin13, mosi=pin15, miso=pin14) #setup SPI
spi.write(b'\x0f\x01')#enable test mode

Whoa! That doesn't work though. The reason is that (to send anything to the display) you need to take the chip select pin (P0) low, then send the data, then take the chip select pin high again. So, this should work:

from microbit import * 
spi.init(baudrate=1000000,bits=8,mode=0, sclk=pin13, mosi=pin15, miso=pin14) # setup SPI
pin0.write_digital(0) # take CS pin low
spi.write(b'\x0f\x01')# enable test mode 
pin0.write_digital(1) # take CS high to latch data

That's the easy bit! The tricky thing I found about using this display is that (apart from "display test" mode), nothing will work as you want it to, unless you make some essential settings. The data sheet explains what each of the registers is for, but it took me a while to realise that nothing much works unless you configure the following registers as below:
  • Test mode (0x0f) set to off (0x00)
  • Decode mode (0x09) set to off (0x00)
  • Set "scan limit" (0x0b) for 8 columns (0x07)
  • Shutdown register (0x0c) enabled (0x01)
  • Intensity register (0x0a) set to something between 1 (0x01 - dim) and 15 (0x0f - very bright)

In addition to that problem, some of the settings are persistent after a power-down, so you also need to clear all the registers to start with (because there is no way to find out what the existing settings are). So, the things to remember are that you need:

  1. Before sending commands/data, to take CS low, then send your commands/data, then take CS high.
  2. To clear all the registers so you start with a "clean slate".
  3. To make the essential settings.
  4. Then to send the data you want to display.
Here's an example program showing how to put it all together. In the example, I have used a python dictionary to hold the bit patterns for all the numbers and most of the letters of the alphabet.




from microbit import *
spi.init(baudrate=1000000,bits=8,mode=0, sclk=pin13, mosi=pin15, miso=pin14) #setup SPI
# initialisation code

pin0.write_digital(0)
spi.write(b'\x0f\x00')#enable normal mode (disable test mode)
pin0.write_digital(1) #latch data
sleep(300)

# zero-out all registers
for cmd in range(16):
    pin0.write_digital(0)
    packet = cmd << 8
    # e.g., if cmd is 0101, 0101 << 8 becomes 010100000000
    spi.write(bytearray(packet))
    pin0.write_digital(1)

# set some essential parameters
pin0.write_digital(0)
spi.write(bytearray([0x09,0x00])) #enable no decode
pin0.write_digital(1)

pin0.write_digital(0)
spi.write(bytearray([0x0b,0x07])) #enable 8 cols
pin0.write_digital(1)

pin0.write_digital(0)
spi.write(bytearray([0x0c,0x01])) # enable shutdown register
pin0.write_digital(1)


# set intensity
pin0.write_digital(0)
spi.write(b'\x0a\x0f')# set intensity to 15 (max)
pin0.write_digital(1) #latch data

# dictionary of bit patterns
# n.b. decimal point can be added to any character by adding 128 to its decimal value
DIGITS = {
    ' ': 0,
    '-': 1,
    '_': 8,
    '\'': 2,
    '0': 126,
    '1': 48,
    '2': 109,
    '3': 121,
    '4': 51,
    '5': 91,
    '6': 95,
    '7': 112,
    '8': 127,
    '9': 123,
    'a': 125,
    'b': 31,
    'c': 13,
    'd': 61,
    'e': 111,
    'f': 71,
    'g': 123,
    'h': 23,
    'i': 16,
    'j' : 24,
    # 'k' Can't represent
    'l': 6,
    # 'm' Can't represent
    'n': 21,
    'o': 29,
    'p': 103,
    'q': 115,
    'r': 5,
    's': 91,
    't': 15,
    'u': 28,
    'v': 28,
    # 'w' Can't represent
    # 'x' Can't represent
    'y': 59,
    'z': 109,
    'A': 119,
    'B': 127,
    'C': 78,
    'D': 126,
    'E': 79,
    'F': 71,
    'G': 94,
    'H': 55,
    'I': 48,
    'J': 56,
    # 'K' Can't represent
    'L': 14,
    # 'M' Can't represent
    'N': 118,
    'O': 126,
    'P': 103,
    'Q': 115,
    'R': 70,
    'S': 91,
    'T': 15,
    'U': 62,
    'V': 62,
    # 'W' Can't represent
    # 'X' Can't represent
    'Y': 59,
    'Z': 109,
    ',': 128,
    '.': 128,
    '!': 176,
}

def write_segs(col,char): # turn on the relevant segments
    pin0.write_digital(0)
    spi.write(bytearray([col,char]))
    pin0.write_digital(1) #latch data

def letter(charx): # Look up character in DIGITS dictionary & return
    value = DIGITS.get(str(charx))
    return value

def blank_cols(): # blank out all columns
for col in range(8):
pin0.write_digital(0)
spi.write(bytearray([col+1,0x00])) # range is 0-7, cols are 1-8!
pin0.write_digital(1)

def write_str(disp_str): #
len_str=len(disp_str) # find length of string
if len_str>8:
len_str=8 #truncate if too long for display
c=len_str #start column
for x in range(len_str):
n=disp_str[x]
write_segs(c,letter(n))
c-=1 #Next (i.e. previous) COLUMN

################################################################ now send some stuff to the display!
while True:
    blank_cols() # blank all columns
    write_str('Hello  ') # write another string
    sleep(2000)
    write_str('there  ') #write a string
    sleep(2000)
    for displ_count in range(1,500): #count up from 0 to 500
        write_str("{0:0=8d}".format(displ_count)) #turn into 8-digit str with leading spaces# 

Tuesday 8 May 2018

MCP9808 digital temperature sensor, BBC micro:bit and LCD screen


Introduction
In my previous post, I described how to connect and code the Microchip MCP9808 digital temperature sensor. That allowed you to send accurate temperature data via the REPL to a connected PC.

In this post, I shall describe how to send the temperature readings to an LCD screen. This could be the basis of a BBC micro:bit weather station or data logging project, or maybe for environmental control. N.B. I wasn't going to risk leaving my micro:bit in the freezer long enough to see how cold it really is - apart from anything else, I doubt whether the LCD would be too happy at -18 °C!


I came across a post on the excellent MultiWingSpan website showing how the micro:bit could be used with a Sparkfun Serial Enabled 3.3V 16x2 LCD Character Display. Unfortunately, those displays are quite expensive (around £24), whereas you can pick up a parallel (5v) 16x2 LCD screen for as little as £2. If you already have one of the Sparkfun ones or don't mind buying one, you can skip the next section.

So how can we make our own (cheaper) equivalent to the Sparkfun serial LCD?
The microbit is a 3.3v logic level device, so first we need an LCD screen that will work at that voltage. You can either pay a bit more and get a 3.3v screen (but they are hard to come by in the UK), or convert a 5v one, like I described in an earlier post.

Next, we need a serial UART LCD backpack like the Sparkfun one. I happened to have bought one from Proto-Pic in a sale last year for the princely sum of £2.40. They have discontinued that now but Hobbytronics in the UK sell a similar one (and, as a bonus, it also works over I2C) for £6. All of these backpacks have their own microprocessor that converts serial UART data into the correct parallel format for any display based on the Hitachi HD44780 compatible interface.

Adding up the cost of components (using the Hobbytronics backpack), it comes to about £9.

Connecting things up
Please refer to my previous post for connecting and programming the MCP9808 digital temperature sensor. Also have a look at the MultiWingSpan post for how to use the serial backpack. Depending on which serial backpack you are using, you may need the datasheet. My Proto-Pic one was very similar to the Sparkfun one but not identical so, if you find characters not appearing on the right row or column, that could be the reason. For convenience, I connected the data connection on my backpack to micro:bit pin 12 - the same as in the MultiWingSpan post.

Micropython code
I borrowed MultiWingSpan's code for the function "move_cursor(row,col)" for sending data to the serial backpack as it is very neat! I have extended it to work with 20x4 displays as well as 16x2. As mentioned above, the Proto-Pic backpack uses a slightly different addressing scheme from the Sparkfun display so I had to adjust the numbers a little as characters weren't coming out in the right place. I also had to look up in the HD44780 datasheet how to send special characters such as the degree symbol ° (decimal 223).

from microbit import *
# set up screen
# command set 1
bl_full = [254,02,255] # backlight on full
bl_half = [254,02,80] # half
bl_off = [254,02,0] # off
clr_scr = [254,01]

# command set 2
col_wide = [124,03] # 20 cols
col_narr = [124,04] # 16 cols
rows_four = [124,05] # 4 rows
rows_two = [124,06] # 2 rows

# choose cursor position - row 0,1,2,3 col, 0-15
def move_cursor(row,col):
    cmd = [254,128]
    if row==1:
        cmd[1]+=16 # adds 16 to cmd and stores it back
    if row==2:
        cmd[1]+=40 # adds 40 to cmd and stores it back
    if row==3:
        cmd[1]+=60 # adds 60 to cmd and stores it back
    cmd[1]+=col    
    uart.write(bytes(cmd))
    sleep(100)
  
# define character code for degree symbol
deg_symb = [223] #from HD44780 datasheet

# wait half a second for the splash screen
sleep(500)

# initialise uart
uart.init(baudrate=9600, bits=8, parity=None, stop=1, tx=pin12)
uart.write(bytes(rows_two)) # set 16x2
uart.write(bytes(col_narr)) # set 16x2
uart.write(bytes(bl_full))  # set backlight to full brightness

# clear display
move_cursor(0,0)
uart.write(bytes(clr_scr))
sleep(200)


# wait half a second for the splash screen
sleep(500)

# n.b. cursor is still at 0,0
uart.write("Temp.: ")
# sleep(50)
move_cursor(0,11)
uart.write(bytes(deg_symb))
uart.write("C")
move_cursor(1,0)
uart.write("This is line two") # change this to something useful!
move_cursor(0,7) #move cursor back again for temp. data
##########################################################
# Measure temperature and send to LCD
while True:
    i2c.write(0x18,bytearray([0x05])) # select ambient temp. register
    sleep(200) #  small pause needed to allow the device to update
    t_amb=i2c.read(0x18,2) # read two bytes from the ambient temp. register
    t_hi = (t_amb[0] & 0x0f) << 4 # mask upper 4 bits (alarm settings)
    t_lo = t_amb[1] / 16          # lower 8 bits (LSB)
    if t_amb[0] & 0x10 == 0x10: # take twos complement for -ve readings
        temp = (t_hi + t_lo) - 256.0
    else:
        temp = t_hi + t_lo
    uart.write('%.1f' % temp)  # display to 1 dec place
    move_cursor(0,7) #move cursor back again ready for next reading
    sleep(500) 
uart.init(115200) # reset uart

Monday 7 May 2018

Using an MCP9808 digital temperature sensor with the BBC micro:bit

Introduction
MakeCode for the BBC micro:bit has a Temperature block that provides a temperature reading in °C. There are two problems with this:
  1. The block infers the temperature from the temperature of the silicon die on the main CPU. This tends to overestimate the temperature due to self-heating of the CPU and the degree of self-heating depends on the load on the processor. Typically, the reading will be 3 degrees Celsius higher than the ambient temperature.
  2. You might, in any case, want to monitor the temperature somewhere other than where the micro:bit is situated (e.g. outside).
The way around this is to connect a separate temperature sensor to the micro:bit. Several tutorials exist for doing this, but the ones I have seen are all for analogue devices such as the TMP36 where the voltage output by the sensor is proportional to the temperature. These are relatively low-precision devices (accurate to ±1°C at +25°C).

I wanted to use a more accurate, digital temperature sensor and found the Microchip MCP9808, which has a typical accuracy ±0.25°C from -40°C to +125°C. This uses the I2C bus and works over a 2.7V ~ 5.5V logic level voltage range. Another possibility would be the Maxim/Dallas DS18B20, which uses a "one-wire protocol" but it takes at least 0.75 secs for each conversion, plus I haven't found out an easy way of using this with the micro:bit. In contrast, the MCP9808 is very easy to use, has a conversion time of only 250ms at maximum resolution and doesn't interfere with any other devices. The easiest way to use it with the micro:bit is to buy a breakout board that exposes its connections via breadboard-friendly pins. Adafruit makes a popular MCP9808 breakout board but I just went for a cheap Chinese board (costing around £2.50) as they do the same job.


The I2C protocol is a serial protocol that allows different data to be sent to different devices along a "bus". Each device must have its own address on the bus. The sensor has a default I2C address of 0x18 but this can be changed to one of seven other addresses by connecting one of more of the additional connections A0, A1, A2 to VCC, via the inbuilt resistors. This would allow you to have up to eight temperature sensors running from the same micro:bit, and controlled separately. See my previous blog posts on using I2C devices with the micro:bit.

Connecting the sensor to the micro:bit
Although there are eight pins, we need just four connections to get going. 

VCC to 3v on the micro:bit, GND to GND, SCL to pin 19 on the micro:bit and SDA to pin 20. To access pins 19 and 20, we need an edge connector breakout board for the micro:bit such as the bread:bit edge connector board from Proto-Pic.

The other four pins on the MCP9808 are for changing its I2C address and a pin that can be used as a trigger if, for example, certain temperature thresholds are reached. 

The micro:bit already has internal pullup resistors on the I2C lines so no other components are needed if you just want to measure temperature and are happy with the default I2C address.

Configuration
By default, the sensor's I2C address is 0x18, its resolution is +0.0625°C and its conversion time is 250ms. For the purposes of this guide, we will leave these defaults as they are.

Micropython code
You'll need the excellent Mu editor for this, and you must also have REPL set up because we will be sending the temperature readings from the micro:bit via the programming cable to the REPL window in Mu. If you have  Windows PC, you'll need to install the driver for this (download it from the Mu website).

The micro:bit implementation of I2C in micropython is slightly different from other boards. The I2C bus does not need to be separately initialised unless the default parameters are unsuitable for the device you wish to connect. As with some other I2C devices, the required configuration registers are written to, then data is read from the bus. In our case, we tell the MCP9808 what function we require by an "i2c.write" to the relevant register address (in this case 0x05 - the ambient temperature register). Then we "i2c.read" a specified number of bytes.

The datasheet for the MCP9808 explains that the temperature is stored as two bytes (one word). The digital word is loaded to a 16-bit read-only ambient temperature register that contains 13-bit temperature data in two’s complement format. The other 3 bits (i.e. bits 7, 6 and 5 of the first byte) contain the alert temperature settings. Bit 4 of the first byte is the sign (i.e. positive or negative Celsius value). If bit 4 is 1, the temperature is negative. This is equivalent to saying that negative temperatures will be represented as 256 plus the numeric value of bits 3 to 0 of the first byte. The second byte of the word contains the 8 least significant bits of the ambient temperature.

Before you flash the code to the micro:bit, remember to click the REPL button so that you can see the data coming back from the micro:bit.


from microbit import *
sleep(1000)
while True:
    i2c.write(0x18,bytearray([0x05])) # select ambient temp. register
    sleep(100) #  small pause needed to allow the device to update
    t_amb=i2c.read(0x18,2) # read two bytes from the ambient temp. register
    t_hi = (t_amb[0] & 0x0f) << 4 # mask upper 4 bits (alarm settings)
    t_lo = t_amb[1] / 16          # lower 8 bits (LSB)
    if t_amb[0] & 0x10 == 0x10: # take twos complement for -ve readings
        temp = (t_hi + t_lo) - 256.0
    else:
        temp = t_hi + t_lo
    print('%.2f' % round(temp,2))  # round and print to 2 places
    sleep(500)

In my next post, I will describe how to connect a 3V LCD screen instead of sending the temperature readings to the REPL. Then you will have a stand-alone digital thermometer which can be used, for example, as part of a weather station project, data logging, or for environmental control.