beaumont.dev posts

Embedded Rust with LoRa and ePaper - Display

Dec. 12, 2020

In the previous post, we got our discovery board lighting up on button presses using GPIO interrupts. Next I want to introduce our first peripheral device, our ePaper display.

I chose the 2.9 inch three color ePaper module from Waveshare for this project, mostly because a color ePaper display is pretty cool and I wanted to try it out. Be aware, they are very slow.

In order to communiate with the display, we’ll use SPI, a synchronous (synchronized to a clock signal), serial (one bit at a time) communication protocol running on four wires. SPI is extremely common protocol on embedded systems for communicating with peripherals. The radio transceiver on our discovery board is connected to our microcontroller via SPI as well.

SPI designates two lines, one for each device, for transmitting data (MOSI and MISO). It uses one line for clock synchronization (SCK) and one for notifying the device we’re going to send data (CS) to it. Because we have a display with one way communication, we only use one of the data lines (MOSI) and simply set CS to active when we want to transmit data.

Waveshare provides a wiki page for each display that gives a summary of the communication protocol specific to that display. In addition to the SPI lines, the display uses three more pins:

EPD pins

Initialization

Let’s look at how we connect and initialize our device. The first thing to note is that we won’t be directly manipulating the SPI data lines ourselves. Our MCU datasheet tells us that it provides 2 SPI peripherals that handle the SPI communication for us. The gritty details are again in our reference manual, chapter 30.

Lucky for us, embedded-hal and the HAL for our board are going to handle those details for us by initializating the peripherals (setting the clock, etc.) and giving us a convenient interface for sending and receiving data.

Let’s work on getting our SPI peripheral initialized. Spoiler alert for later: the datasheet for our module, page 4 tells us that the radio transceiver is connected to SPI1. And so we’ll be using SPI2 for our display!

SPI pins

The SPI peripheral in our microcontroller is only capable of using specific pins for the SPI lines so let’s get those sorted.

If we look at our board manual, we find that chapter 9.2/table 7 tells us which pins are exposed on our board and which function they have. As part of the CN3 connector we find SPI2_SCK: PB13, SPI2_MOSI: PB15, and SPI2_NSS: PB12 (NSS is another name for the CS line).

A more complete description of the pins and their functions can be found in table 16 of our MCU datasheet.

HAL

The HAL crate and clever types also gives us some hints as to which pins we need to provide. If we look at the signature of the Spi::spi2 function we see we need to pass an argument pins: PINS with the bound PINS: Pins<SPI2>. The docs for the Pins<SPI> Trait link to the PinSck, PinMiso, and PinMosi traits. PinMosi<SPI2> for example, is only implemented by PB15. You may also have noticed the NoMiso struct, which implements PinMiso<SPI2>. We’ll pass that instead of passing in a real pin and leaving it unused.

The final arguments of our Spi::spi2 function are:

SPI Mode

SPI requires setting the clock polarity and phase, which determine timing and polarity of the clock pulses with respect to the data signal. Reading through the display wiki or by interpreting the diagrams in the datasheet, chapter MCU Serial Interface (4-wire SPI), we see that we need SPI mode 0.

Finally we have something like:

let spi = Spi::spi2(
    dp.SPI2,
    (gpiob.pb12, NoMiso, gpio.pb15),
    MODE_0,
    rcc.clocks.sys_clk(),
    &mut rcc
);

Additional pins

We’ve also got to connect BUSY, DC and RST. We can arbitrarily choose any GPIO pins for this. I chose PA2, PA10 and PA8.

EPD pin connections (compare colors with pins)

Display driver crate

We now have our SPI peripheral connected and setup and can start up the display and send data. How exactly this is done is specified, albeit sometimes vaguely and confusingly, in the datasheet. It lists the commands we can send as well as how to send the pixel data.

We’ll spare ourselves translating those commands into Rust and use the amazing epd-waveshare library. Let’s start a new module for handling our display:

src/epaper.rs
use crate::hal::{
    gpio::*,
    pac,
    prelude::*,
    rcc::Rcc,
    spi::{NoMiso, Spi, MODE_0},
};

pub type SPI = Spi<pac::SPI2, (gpiob::PB13<Analog>, NoMiso, gpiob::PB15<Analog>)>;

pub fn init(
    spi2: pac::SPI2,
    sck: gpiob::PB13<Analog>,
    mosi: gpiob::PB15<Analog>,
    cs: gpiob::PB12<Analog>,
    rcc: &mut Rcc,
) -> SPI {
    Spi::spi2(spi2, (sck, NoMiso, mosi), MODE_0, rcc.clocks.sys_clk(), rcc)
}

Most waveshare ePaper displays are supported by epd-waveshare and in the docs we find EPD2in9bc::new for our device (the protocol for the red and yellow versions of our display is identical).

We’ll need our initialized SPI struct as well as CS, which we haven’t used yet. The SPI struct doesn’t own the CS pin like it does the other SPI pins. Retaining ownership of the pin gives us more flexibility, especially because if we have multiple SPI devices, we need one CS pin per device. We also need the 3 other Waveshare specific pins, RST, BUSY and DC.

The final piece of the puzzle here is delay: &mut DELAY where DELAY: DelayMs<u8>, which we’re going to require as an argument of our init function and cover in just a second:

src/epaper.rs
use crate::hal::{
    gpio::*,
    pac,
    prelude::*,
    rcc::Rcc,
    spi::{NoMiso, Spi, MODE_0},
    spi::{NoMiso, Spi},
};
use embedded_hal::blocking::delay::*;
use epd_waveshare::{epd2in9bc::EPD2in9bc, prelude::*, SPI_MODE};

pub type SPI = Spi<pac::SPI2, (gpiob::PB13<Analog>, NoMiso, gpiob::PB15<Analog>)>;

pub fn init(
pub fn init<DELAY, BUSY, DC, RST>(
    spi2: pac::SPI2,
    sck: gpiob::PB13<Analog>,
    mosi: gpiob::PB15<Analog>,
    cs: gpiob::PB12<Analog>,
    busy: BUSY,
    dc: DC,
    rst: RST,
    rcc: &mut Rcc,
) -> SPI {
    Spi::spi2(spi2, (sck, NoMiso, mosi), MODE_0, rcc.clocks.sys_clk(), rcc)
    mut delay: DELAY,
) -> (
    SPI,
    EPD2in9bc<SPI, gpiob::PB12<Output<PushPull>>, BUSY, DC, RST>,
)
where
    DELAY: DelayMs<u8>,
    BUSY: InputPin,
    DC: OutputPin,
    RST: OutputPin,
{
    // `epd-waveshare` conveniently exports the SPI mode for the waveshare devices
    let mut spi = Spi::spi2(
        spi2,
        (sck, NoMiso, mosi),
        SPI_MODE,
        rcc.clocks.sys_clk(),
        rcc,
    );
    let epd = EPD2in9bc::new(
        &mut spi,
        cs.into_push_pull_output(),
        busy,
        dc,
        rst,
        &mut delay,
    )
    .unwrap();
    (spi, epd)
}

Calling init

Let’s call our new init function from main.

src/main.rs
    // Get access to the GPIO B port
    // Get access to the GPIO A & B ports
    let gpioa = dp.GPIOA.split(&mut rcc);
    let gpiob = dp.GPIOB.split(&mut rcc);
src/main.rs
    let (spi, epd) = epaper::init(
        dp.SPI2,
        gpiob.pb13,
        gpiob.pb15,
        gpiob.pb12,
        gpioa.pa2.into_floating_input(),
        gpioa.pa10.into_push_pull_output(),
        gpioa.pa8.into_push_pull_output(),
        &mut rcc,
        delay,
    );

Delay

Sometimes with embedded software we need to wait. In our case epd-waveshare waits some number of milliseconds after powering on our device.

One way to do this in general is to have the CPU loop:

for i in 1..10_000 {
    cortex_m::asm::noop(); // prevent the compiler from optimizing our loop away
}

The disadvantage of this strategy is that it’s very difficult to wait a specific number of milliseconds. For example, the wall execution time of one noop instruction varies based on the configured clock speed and interrupts can preempt our loop.

DelayMs<T> is an embedded-hal trait that provides a delay_ms function for waiting some number of milliseconds. Most embedded-hal-implementing HAL crates provide at least one Delay-implementing struct, typically on top of timers.

Timers

Timers are configurable counters. In general, a timer is configured by selecting some input signal as a clock and then selecting a prescaler, which can scale the frequency down by some factor. We can then start our timer, which updates a register with the elapsed clock time.

Our timers, as well as all peripherals on our microcontroller, are connected to what’s called a bus. A bus is an electrical interconnect that allows components to communicate amongst each other. The microcontroller datasheet shows us in Figure 1 the specifics of which peripherals are connected to which buses. Every timer on our STM chip is connected to the bus’ internal clock. The general purpose timers can also use external signals as clocks.

The our SPI peripheral we set up earlier is connected to a bus, too. The implementation of spi2 sets the clock frequency by finding the relationship between the given frequency and the clock of its bus (APB1, a peripheral bus) in order to set the prescaler.

Figure 2 of the MCU datasheet shows the relationships between all the clocks, prescalers and buses in the clock tree.

stm32l0xx_hal provides the Delay struct, which implements DelayMs and DelayUs with the SysTick peripheral. SysTick is provided by every Cortex-based microcontroller. Our MCU provides additional, ST-specific timers as well (TIM2, TIM3).

src/main.rs
use crate::hal::{
    delay,
src/main.rs
    // Get one-time access to our peripherals
    let cp = cortex_m::Peripherals::take().unwrap();
    let dp = pac::Peripherals::take().unwrap();
src/main.rs
    // Because SysTick is universal to Cortex-M chips it's provided by the `cortex_m` crate
    let syst_delay = delay::Delay::new(cp.SYST, rcc.clocks);
    let (spi, epd) = epaper::init(
        dp.SPI2,
        gpiob.pb13,
        gpiob.pb15,
        gpiob.pb12,
        gpioa.pa2.into_floating_input(),
        gpioa.pa10.into_push_pull_output(),
        gpioa.pa8.into_push_pull_output(),
        &mut rcc,
        delay,
        syst_delay,

Drawing

epd-waveshare uses embedded-graphics, which provides primitives like shapes and text. Let’s explore these libraries by displaying a splash on device startup. The general idea is that we maintain an abstract display to which we draw our abstract objects and then render that display to a bitmap, either for black pixels or for red/yellow pixels.

src/epaper.rs
use embedded_graphics::{
    fonts::{Font24x32, Text},
    pixelcolor::BinaryColor::On as Black,
    prelude::*,
    style,
};
use embedded_hal::blocking::delay::*;
use epd_waveshare::{epd2in9bc::EPD2in9bc, prelude::*, SPI_MODE};
use epd_waveshare::{
    epd2in9bc::{Display2in9bc, EPD2in9bc},
    prelude::*,
    SPI_MODE,
};
src/epaper.rs
    (spi, epd)
}

pub type EPD = EPD2in9bc<
    SPI,
    gpiob::PB12<Output<PushPull>>,
    gpioa::PA2<Input<Floating>>,
    gpioa::PA10<Output<PushPull>>,
    gpioa::PA8<Output<PushPull>>,
>;

pub fn display_startup(spi: &mut SPI, epd: &mut EPD) {
    let mut display = Display2in9bc::default();
    // the rotation is used when rendering our text
    // and shapes into a bitmap
    display.set_rotation(DisplayRotation::Rotate90);
    // send a uniform chromatic and achromatic frame
    epd.clear_frame(spi).expect("clear frame failed");
    let style = style::TextStyleBuilder::new(Font24x32)
        .text_color(Black)
        .build();
    let _ = Text::new("Farsign", Point::new(50, 35))
        .into_styled(style)
        .draw(&mut display);
    // render our display to a buffer and set it as
    // our chromatic frame
    epd.update_chromatic_frame(spi, display.buffer())
        .expect("send text failed");
    epd.display_frame(spi).expect("display startup failed");
}

That’s a wrap for our second post! Next I’m going to either explore translating our button presses to Morse code or using our radio.