beaumont.dev posts

Embedded Rust with LoRa and ePaper - State machines

Jan. 21, 2021

In the previous post, we worked on getting the display powered up and displaying a splash on startup.

Today we’ll start translating into morse code using timers and state machines. We talked a little about timers in the last post where they were an implementation detail of Delay. Today we’ll use them again, except this time we’ll control them directly.

State machines

Devices that interact with the real world can often be modelled through an abstraction called a state machine. We can usually think of devices as being in a state at any specific moment. They remain in this state until some event occurs, like a button press or a change in input signal, at which point they transition to another state. The behavior of our device can be expressed as a set of states, each of which has a set of possible outgoing transitions to some state. A specific “use” of our device can be modeled as a sequence of transitions.

Let’s model the behavior of our device this way:

Morse code state machine

Enumerating the components of our state and our transitions like this is a great way to make sure we’ve covered everything, including edge cases.

The idea is that as we press and hold the button, we move from a short press to a long press. When we release, we push a new dot/dash onto the list of dots/dashes and when we haven’t held the button in a longer time, the current sequence of dots/dashes (𝑣) is transformed into a letter which is appended to the sequence of letters. When we hold a very long press, we transmit the sequence of letters (𝑤), but only if we don’t have any dots/dashes yet.

The state of our device can be described by the LED state, the sequence of letters (𝑤) as well as the current accumulated sequence of dots/dashes (𝑣).

The transitions in our case are time passing and presses/releases of the button.

Creating a MorseMachine

We already have a type that holds our status lights. Let’s create a new struct that we’ll use in src/main.rs to hold and manipulate the state of the current morse code, i.e. the sequence of dots/dashes.

src/machine.rs
pub struct MorseMachine {}

pub enum Transition {}

impl MorseMachine {
    fn new() -> Self;
    fn press(&mut self);
    fn release(&mut self);
    fn tick(&mut self) -> Option<Transition>;
}

The idea will be to tell the struct when an external event occurs. We’ll call tick every time our timer interrupt fires and call press/release when our GPIO interrupt fires.

Calling tick can cause more than one kind of transition which we’re going to handle in main so let’s have it return a Transition. Notice press always leads to the “short press” state and although release modifies the current sequence, i.e. the internal state of MorseMachine, it always leads to a “waiting” state.

Looking again at our state machine we can see that a tick of the clock can lead to the following state transitions:

src/machine.rs
pub struct MorseMachine {}
#[derive(PartialEq, Eq)]
pub enum Transition {
    Long,
    VeryLong,
    Character(u8),
    Transmit,
}

pub enum Transition {}
pub struct MorseMachine {}

impl MorseMachine {
    fn new() -> Self;

Hooking interrupts to MorseMachine

We’ll put a MorseMachine into a Mutex<RefCell<_>> to share between our interrupts:

src/main.rs
static STATUS: Mutex<RefCell<Option<status::StatusLights<PBOut, PBOut, PBOut>>>> =
    Mutex::new(RefCell::new(None));
static BUTTON: Mutex<RefCell<Option<gpiob::PB2<Input<PullUp>>>>> = Mutex::new(RefCell::new(None));
static MORSE: Mutex<RefCell<Option<machine::MorseMachine>>> = Mutex::new(RefCell::new(None));

#[entry]
fn main() -> ! {
src/main.rs
    cortex_m::interrupt::free(|cs| {
        *STATUS.borrow(cs).borrow_mut() = Some(status);
        *BUTTON.borrow(cs).borrow_mut() = Some(button);
        *MORSE.borrow(cs).borrow_mut() = Some(machine::MorseMachine::new());
    });

    unsafe {

Let’s hook it up to the EXTI2_3 interrupt handler and get rid of our dummy code from the last chapter:

src/main.rs
            .map(|p| (p.pin_number(), p.is_low().unwrap()))
            .unwrap();
        Exti::unpend(GpioLine::from_raw_line(pin_number).unwrap());
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
        if is_low {
            status.as_mut().unwrap().off();
            mm.as_mut().unwrap().press();
        } else {
            status.as_mut().unwrap().on_short();
            mm.as_mut().unwrap().release();
        }
    })
}

Our microcontroller reference describes a general-purpose timer peripheral called TIM2 whose interrupt handler we can use to call tick:

src/main.rs
    loop {}
}

#[allow(non_snake_case)]
#[interrupt]
fn TIM2() {
    cortex_m::interrupt::free(|cs| {
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
        if let Some(state_change) = mm.as_mut().unwrap().tick() {
            // handle state_change
        }
    })
}

#[allow(non_snake_case)]
#[interrupt]
fn EXTI2_3() {

Our HAL crate provides a Timer struct to encapsulate the timer which we’ll access through a Mutex<RefCell<_>>.

src/main.rs
    delay,
    exti::{Exti, ExtiLine, GpioLine, TriggerEdge},
    gpio::*,
    pac::{self, interrupt},
    pac::{self, interrupt, TIM2},
    prelude::*,
    syscfg,
    timer::Timer,
};
use core::cell::RefCell;
use cortex_m::interrupt::Mutex;
src/main.rs
    Mutex::new(RefCell::new(None));
static BUTTON: Mutex<RefCell<Option<gpiob::PB2<Input<PullUp>>>>> = Mutex::new(RefCell::new(None));
static MORSE: Mutex<RefCell<Option<machine::MorseMachine>>> = Mutex::new(RefCell::new(None));
static TIMER: Mutex<RefCell<Option<Timer<TIM2>>>> = Mutex::new(RefCell::new(None));

#[entry]
fn main() -> ! {

Let’s now hook up the state changes from press/release as well as tick to our status LEDs and timer. We start the timer with listen whenever we get a button press.

src/main.rs
        Exti::unpend(GpioLine::from_raw_line(pin_number).unwrap());
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
        let mut timer = TIMER.borrow(cs).borrow_mut();
        if is_low {
            mm.as_mut().unwrap().press();
            timer.as_mut().unwrap().listen();
            status.as_mut().unwrap().on_short();
        } else {
            mm.as_mut().unwrap().release();
            status.as_mut().unwrap().off();
        }
    })
}

We always clear the interrupt request with clear_irq and deactivate the timer with unlisten when we get a new letter in tick:

src/main.rs
    cortex_m::interrupt::free(|cs| {
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
        let mut timer = TIMER.borrow(cs).borrow_mut();
        timer.as_mut().unwrap().clear_irq();
        if let Some(state_change) = mm.as_mut().unwrap().tick() {
            // handle state_change
            match state_change {
                machine::Transition::Long => status.as_mut().unwrap().on_long(),
                machine::Transition::VeryLong => status.as_mut().unwrap().busy(),
                machine::Transition::Transmit => {
                    // send letters
                },
                machine::Transition::Character(ch) => {
                    timer.as_mut().unwrap().unlisten();
                    // handle letter
                }
            }
        }
    })
}

Flash on timeout

One UX optimization we can make is to flash our LED when the timeout is triggered, so the user knows they can start the next letter. It’s not really a part of our morse code state machine so we’ll track it in the TIM2 interrupt handler. The LED turns on for a few ticks then off again and unlistening the timer instead of directly at timeout:

src/main.rs
#[allow(non_snake_case)]
#[interrupt]
fn TIM2() {
    static mut FLASH: Option<u8> = None;
    const FLASH_TICKS: u8 = 2;
    cortex_m::interrupt::free(|cs| {
        let mut mm = MORSE.borrow(cs).borrow_mut();
        let mut status = STATUS.borrow(cs).borrow_mut();
src/main.rs
                    // send letters
                },
                machine::Transition::Character(ch) => {
                    timer.as_mut().unwrap().unlisten();
                    *FLASH = Some(FLASH_TICKS);
                    status.as_mut().unwrap().busy();
                    // handle letter
                }
            }
        } else if let Some(flash_count) = *FLASH {
            if flash_count == 0 {
                *FLASH = None;
                timer.as_mut().unwrap().unlisten();
                status.as_mut().unwrap().off();
            } else {
                *FLASH = Some(flash_count - 1);
            }
        }
    })
}

Finishing up the machine

Let’s implement the state tracking and transitions in MorseMachine.

Button state

We’re going to maintain the state of our button inside the MorseMachine as well, so let’s create an enum with the different possible states:

src/machine.rs
#[derive(Clone, Copy, PartialEq, Eq)]
enum PressType {
    Short,
    Long,
    VeryLong,
}

#[derive(PartialEq, Eq)]
enum State {
    Press(PressType),
    WaitingOnPress,
    Idle,
}

Internally, we really only need a count of how many ticks have passed as well as whether the button is being held. We offer a state method to return a State as well as tick to advance the tick count.

src/machine.rs
struct Button {
    long_press: u32,
    very_long_press: u32,
    timeout: u32,
    count: u32,
    pressed: bool,
}

impl Button {
    fn new(long_press: u32, very_long_press: u32, timeout: u32) -> Self {
        Self {
            long_press,
            very_long_press,
            timeout,
            count: 0,
            pressed: false,
        }
    }
    fn release(&mut self) {
        self.count = 0;
        self.pressed = false;
    }

    fn press(&mut self) {
        self.count = 0;
        self.pressed = true;
    }

    fn state(&self) -> State {
        if !self.pressed {
            if self.count > self.timeout {
                State::Idle
            } else {
                State::WaitingOnPress
            }
        } else {
            State::Press(if self.count > self.very_long_press {
                PressType::VeryLong
            } else if self.count > self.long_press {
                PressType::Long
            } else {
                PressType::Short
            })
        }
    }

    fn tick(&mut self) {
        self.count += 1;
    }
}

#[derive(PartialEq, Eq)]
pub enum Transition {
    Long,

Standard morse code uses a 1:3 ratio of dot:letter-spacing duration so we set that in new and leave the transmit press duration the same as the letter-spacing:

src/machine.rs
    Transmit,
}

pub struct MorseMachine {}
pub struct MorseMachine {
    button: Button,
}

impl MorseMachine {
    fn new() -> Self;
    fn press(&mut self);
    fn release(&mut self);
    fn tick(&mut self) -> Option<Transition>;
    pub fn new(dot_ticks: u32) -> Self {
        Self {
            button: Button::new(dot_ticks, 3 * dot_ticks, 3 * dot_ticks),
        }
    }

    pub fn press(&mut self) {
        self.button.press();
    }

    pub fn release(&mut self) {
        self.button.release();
    }

    pub fn tick(&mut self) -> Option<Transition> {
        self.button.tick();
        None
    }
}

press

Looking again at our state machine we can see that press is complete because a press always leads to a short press state and doesn’t affect our morse code.

release

When the button is released, our ouput state definitely depends on whether we were holding a short, long or very long press and we update the current morse code. Let’s sketch out an interface for a morse code value, i.e. a dot/dash accumulator. I’m going to leave the implementation out for now but we’ll briefly go over it at the end of this post.

src/morse.rs
#[derive(PartialEq)]
pub struct MorseCode {}

pub const TRANSMIT: MorseCode;

impl MorseCode {
    pub fn empty() -> Self;
    pub fn is_empty(&self) -> bool;
    pub fn append_dot(&mut self) -> Self;
    pub fn append_dash(&mut self) -> Self;
}

Import to note here is that we’ve expanded the alphabet with a sentinel value to mean “ready to transmit”.

In fact, our Button::state function from earlier wasn’t correct. We only want to enter a very long press if we haven’t started accumulating dots and dashes, so let’s fix that up:

src/machine.rs
        self.pressed = true;
    }

    fn state(&self) -> State {
    fn state(&self, code_empty: bool) -> State {
        if !self.pressed {
            if self.count > self.timeout {
                State::Idle
src/machine.rs
                State::WaitingOnPress
            }
        } else {
            State::Press(if self.count > self.very_long_press {
            State::Press(if self.count > self.very_long_press && code_empty {
                PressType::VeryLong
            } else if self.count > self.long_press {
                PressType::Long

We’ll track a MorseCode in our MorseMachine:

src/machine.rs

pub struct MorseMachine {
    button: Button,
    current: morse::MorseCode,
}

impl MorseMachine {
    pub fn new(dot_ticks: u32) -> Self {
        Self {
            button: Button::new(dot_ticks, 3 * dot_ticks, 3 * dot_ticks),
            current: morse::MorseCode::empty(),
        }
    }

Now we can complete release and update our MorseCode according to our State:

src/machine.rs
    }

    pub fn release(&mut self) {
        if let State::Press(p) = self.button.state(self.current.is_empty()) {
            self.current = match p {
                PressType::Short => self.current.append_dot(),
                PressType::Long => self.current.append_dash(),
                PressType::VeryLong => morse::TRANSMIT,
            }
        }
        self.button.release();
        if self.current == morse::TRANSMIT {
            self.button.timeout();
        }
    }

    pub fn tick(&mut self) -> Option<Transition> {

We make a little optimization to immediately timeout our button when the user enters a TRANSMIT character.

src/machine.rs
    fn timeout(&mut self) {
        self.count = self.timeout;
    }

tick

For tick we’re going to have to come up with an Option<Transition>. The idea is to tick our button and see if there’s been a change in State, these are all the transitions which are triggered by time passing.

The interesting part is the “waiting on dot/dash timeout” transition where we lookup our accumulated dots/dashes into a u8 (an ASCII character).

src/machine.rs
    }

    pub fn tick(&mut self) -> Option<Transition> {
        let is_empty = self.current.is_empty();
        let previous_state = self.button.state(is_empty);
        self.button.tick();
        None
        let current_state = self.button.state(is_empty);
        match (previous_state, current_state) {
            (p, n) if p == n => None,
            (_, State::Idle) => {
                let character = self.current;
                self.current = morse::MorseCode::empty();
                Some(if character == morse::TRANSMIT {
                    Transition::Transmit
                } else {
                    Transition::Character(character.lookup())
                })
            }
            (_, State::Press(PressType::VeryLong)) => Some(Transition::VeryLong),
            (_, State::Press(PressType::Long)) => Some(Transition::Long),
            _ => None,
        }
    }
}

MorseMachine initialization

Finally we can setup MorseMachine from main and pick a tick of 10ms with a dot to dash transition of 20*10ms, which feels pretty comfortable.

src/main.rs
    let line = GpioLine::from_raw_line(button.pin_number()).unwrap();
    exti.listen_gpio(&mut syscfg, button.port(), line, TriggerEdge::Both);

    let timer = dp.TIM2.timer(10.ms(), &mut rcc);
    cortex_m::interrupt::free(|cs| {
        *STATUS.borrow(cs).borrow_mut() = Some(status);
        *BUTTON.borrow(cs).borrow_mut() = Some(button);
        *MORSE.borrow(cs).borrow_mut() = Some(machine::MorseMachine::new());
        *TIMER.borrow(cs).borrow_mut() = Some(timer);
        *MORSE.borrow(cs).borrow_mut() = Some(machine::MorseMachine::new(20));
    });

    unsafe {
        NVIC::unmask(line.interrupt());
        NVIC::unmask(Interrupt::TIM2);
    }

    loop {}

That concludes everything we need to start speaking morse code, check it out!

Hopefully this third post served as an introductory but not completely trivial example of translating the behavior of an embedded device into a state machine and then into code.

The next post covers moving over to RTIC and some refactors.


MorseCode implementation

There are multiple ways to implement the accumulation and lookup of morse code. I chose to do it by essentially encoding a binary tree of possible morse code values into an array and keeping a u8 pointer into the tree, moving left or right down the branches as new dots/dashes come in. Check it out on Github.

Edits