Sunday, March 14, 2021

Easy Multitasking on the Arduino - Finite State Machines

Unlike a PC, microcontrollers like the Arduino or ESP32 are not able to run multiple programs in parallel. But that doesn't mean that you wouldn't be able to run multiple tasks in parallel. This article shows how to achieve this in a simple way.

The Concept

The main idea is that most code pieces do not fully require the full processor power. Therefore we can alternate and let the processor work on each piece of code for a limited amount of time (typically less than a millisecond). In this case, each piece of code needs to remember where it stopped, so it knows where to continue the next time. This requires to define (and to number) all the possible "states" of the code, so it can memorize this "state number".

This approach is called a Finite State Machine (FSM). If this sounds complex to you, let me assure you: it isn't! Probably, you have used the "Blink" example from the Arduino library before (I always use this to test any new Arduino board). Under "02.Digital" is another example "BlinkWithoutDelay". Please have a look at that one. The difference between these two examples explains basically all the ingredients that we need to understand a FSM - and to do multitasking.

A Simple Example

Before we build a slightly more complex example below, let's study the main features by comparing slightly modified versions of the two blink sketches.

Here is the main loop (which is the same for both):

void loop() {
  checkButton();
  blink();
}

Let us assume that "checkButton()" is a subroutine that checks if a button has been pressed - it does not matter for our purposes how this is done in detail, and what the code will do else. It only matters here, that we want to ensure that the code will be able recognize if a user pushes a button, which requires it to be called in sufficiently small time intervals. For the moment, we just create an empty subroutine.

void checkButton() {
}

The first (simple and inefficient) version of the blink sketch is this:

void blink() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on
  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off
  delay(1000);                       // wait for a second
}

In this case, the main loop is doing the following: it enters "checkButton()" to quickly check if the button was pressed (this is extremely fast - much faster than 1 millisecond), and then it enters "blink()". In "blink()", it turns on the LED, waits one second, turns off the LED, and waits another second - so two seconds are spent in each call of "blink()". Then the loop repeats and it checks the button again.

The obvious problem is that two seconds pass in between subsequent readings of the button. In many cases, when a user presses the button for a typical time of 1/2 second, the code will not recognize this, since it may just be waiting in "blink()" inside one of the two "delay(1000)" statements. 

So, the culprit is the "delay()" statement - which essentially prevents our Arduino sketch from doing anything during this time. 

A better, more efficient code would memorize whether the LED is on or off, and also the time when it was set into this state. Then it would exit the blink() subroutine, so that the main loop() could go back and call checkButton() again after a much shorter time. Whenever blink() is called again, it checks if one second has already expired. If not, it exits - and if yes, it turns the LED on or off (depending on its previous state) and, again, memorizes the new on/off state and the time when this happened. Together, the information whether the LED is on or off plus the time information define the state of the updated blink() subroutine. This is what a Finite State Machine (FSM) is about.

A FSM can be in a certain (finite) number of possible states. The state changes whenever it receives input (examples are signals from other code pieces or here, from a timer). The new state is defined based on the type of input and on the current state. 

Let's implement everything that we stated above in a new, FSM version of the blink() code. It is almost equal to the "BlinkWithoutDelay" example - just slightly re-organized, so that the FSM feature becomes more clear. 

void blink() {
  static byte state = 0;               // state  0:LED-off  1:LED-on
  static unsigned
long previousTime = 0;  // time of the last change
  const unsigned long delayTime = 1000;   // blink-time in millisec
                                         
  
if ((millis()-previousTime) > delayTime) { // one sec expired
    previousTime = millis();            // store current time 
    if (state == 0) {                   // if LED was off
      state = 1;                        //   -> new state is "on" 
      
digitalWrite(LED_BUILTIN, HIGH);  //   -> turn on LED
    } else {                            // if LED was off
      state = 0;                        //   -> new state is "off" 
      digitalWrite(LED_BUILTIN, LOW);   //   -> turn off LED
  }
}

Let's understand this, line by line:

First we define the variable "state" in which we store, well, the current state of the LED - using the type "byte". Then we define the variable "previousTime" to store the last time that the LED was turned on/off - using the type "unsigned long ". Both of these variables are declared as "static", which means that the Arduino will remember their values between subsequent calls of the blink() code (otherwise they would be initialized as zero in every new call).

In the third line we define a constant "delayTime" which we set to 1000. This is the time (in milliseconds) between subsequent on/off changes as used in the first simple and inefficient blink() version. By declaring this as a constant, it will be easier to change this in the future, if you ever decide to change the blink frequency. 

Then we check if one second has passed: This is the case when the difference of the current time and the previous time when the state changed is larger than 1000 ms (the "delayTime"). If not, the code is finished and returns back to the "loop()".
If the code finds that 1000 ms have passed, it will change the state of the LED. Therefore, first, it stores the time when this happened, by updating the variable "previousTime" and setting it to the current time. Then, if the current state of the LED is "off", we set the state variable to "on", and turn on the LED. If the current state of the LED is "on", we set the state variable to "off", and turn off the LED. 

In either case, whether the state of the LED was changed or not, the time spent in the blink routine is very small (much less than one millisecond). Therefore, the loop() code can quickly go back and check the status of the button, so that no push of the button will go unnoticed. Which is exactly what we wanted!

The essential lesson is, not to use any "blocking" code This includes "delay()" statements and also "while" loops that wait for events to occur. If it is necessary for your code to wait for something to happen, then remember (i.e. store) the current state, exit the subroutine, and check again the next time the subroutine is called.

A General Rule: Keep Your Code Local

This means that all operations of a given component (like an LED) are done by a single subroutine which has a state variable to store the current state. If the component can affected by external code, the subroutine also needs an input variable that specifies the request to change the state.

Let's say we have an LED that can be turned on and off. When it's asked to turn on, the LED should fade from zero to the maximum brightness and stay there until requested to turn off. For this case we provide a subroutine "operateLed" with an input variable "input" and a state variable "state".

The "input" variable can have the following values:

  • 0 (means: continue to operate in the current state)
  • 1 (means: turn on the LED)
  • 2 (Means: turn off the LED)

The "state" can either be:

  • 0 (off)
  • 1 (currently ramping up from zero up to max brightness)
  • 2 sitting at max. brightness

The detailed code for "operateLed()" is listed further below as part of ...

A More Interesting and Complex Example

Let's operate a device that has a switch (or a momentary push button), one LED, two servos, and a sound player. The device is supposed to do the following. At startup (when the switch/button is open), the LED is off, the servos sit at their initial angles "minAngle", and no sound is playing.

  • When the switch/button changes from off to on:
    • the servos start to rotate from "minAngle" to "maxAngle" (and stop when they reached the latter)
    • the LED code waits a little, then slowly fades on
    • the sound code waits a little, then starts the sound player
  • The switch/button is ignored for 100-200 ms to avoid bouncing
  • When the switch/button changes from on to off:
    • the servos start to rotate from "maxAngle" back to "minAngle"
    • the LED code turns the LED off
    • the sound code checks if the sound is still playing, and, if yes, it stops.
  • The switch/button is ignored for 100-200 ms to avoid bouncing.
This is a precise, concise, and complete description of the task, based on which we can write the code. The four bullets above correspond to the four different states in which the "checkButton()" can possibly be.

The complete code is provided in seven pieces,

  • a list of constants in which parameters are stored, plus include statements of libraries used,
  • the setup() code in which the Arduino I/O pins are declared,
  • the loop() which is the main program that is executed continuously,
  • the subroutine checkButton() which reads the button status and initiates the requested responses,
  • the subroutine operateLed() which operates the LED,
  • the subroutine operateServos() which operates the servos,
  • the subroutine operateSound() which (you guessed it!) operates the sound output.
Tu run the code, just copy, paste, and append the "code" pieces which follow.

Parameters and Libraries

In the header of the code we define constants that may have to be tuned later plus the pin numbers where the devices are connected.

/*
 * Arduino code for a device with 
 *     push button, LED, 2 servos, sound player
 * - for Arduino Uno, Nano, ProMini
 */

// -----------------------------------------------------------------
// --- constants
//
// --- The angles where the servos start and stop
//     These could be different for both servos
const byte minAngle[2] = {0,0};       // servo start angles
const byte maxAngle[2] = {80, 80};    // servo stop angles
//
// --- delay times for the LED to turn on and the sound to start
//     after the button was pushed
const int ledDelay = 500;  // delay time (in ms) for the LED turn on
const int soundDelay = 450;   // delay time (in ms) for the sound
//
// -----------------------------------------------------------------
// --- pins and libraries
//
// --- button (connected to this pin and GND
const byte pinButton = 2; 
//
// --- LED - (connected to this pin and, via a resistor) to GND
const byte pinLed = 3;     // must be a PWM pin like 3,5,6,9
//
// --- servos
#include <Servo.h>
Servo myServo[2];
const byte pinServo[2] = {9, 10}; 
//
// --- plus variables/pins for the sound player
//     [these are omitted here for brevity]


In the setup() code, the Arduino pins are enabled as required. The button is connected between the Arduino pin and GND. To ensure that the logic level of the pin is well-defined while the pin is open, we use the built-in pull-up resistor.

void setup() {
  Serial.begin(9600);
  pinMode(pinButton, INPUT_PULLUP);     // push button 
  pinMode(pinLed, OUTPUT);              // LED pin
  digitalWrite(pinLed, LOW);            // turn off LED
  for (byte i = 0; i < 2; i++) {     
    myServo[i].attach(pinServo[i]);     // two servos
    myServo[i].write(minAngle[i]);         // rotate to minimum angle
  }
  // --- plus initialization for sound player
}


The loop() code is executed and repeated continuously. It is very simple and consists of four calls. The checkButton() routine has no input - it always does the same: checking for changes of the button status. The other routines each have an input argument. In the loop() code, this input argument is always zero, meaning: "just continue doing what you did".

void loop() {
  checkButton();
  operateLed(0);
  operateServos(0);
  operateSound(0);
}


Now we look at the individual device-specific subroutines, starting with the code to check changes of the button status and to initiate actions. 

// --- check changes of the status of the button
//
//  possible states
//       0:  the button is open - check if the button is closed
//       1:  the button was closed - wait a few milliseconds
//           to ignore possible bounces - then go to state 2
//       2:  the button is closed - wait for button to open
//       3:  button was opened - wait few ms to debounce
// 
void 
checkButton() {
  static byte state = 0;
  static unsigned long previousTime = 0;

  if (state == 0) {
    if (digitalRead(pinButton) == LOW) {    // button was pressed
      operateLed(1);                 // turn on the LED
      operateServos(1);              // rotate servos
      operateSound(1);               // start sound 
      state = 1;                     // change the state
      previousTime = millis();       // store current time
    }
  } else if (state == 1) {
    if ( (millis() - previousTime) > 100) {    // wait 100ms
      state = 2;                               // change state 
    }
  } else if (state == 2) {
    if (digitalRead(pinButton) == HIGH) {   // button was released
      operateLed(2);
      operateServos(2);
      operateSound(2);
      state = 3;                     // change state 
      previousTime = millis();       // store current time
    }

  } else if (state == 3) {
    if ( (millis() - previousTime) > 100) {    // wait 100ms
      state = 0;                               // change state 
    }
  }
}

The LED is operated by the subroutine "operateLed()". It has an input argument for which the meaning of the values are described in the header of the routine.

// --- operate the LED
//     input   0 continue
//             1 turn LED on
//             2 turn LED off
//   
// 
  possible states
//       0:  LED is off
//       1:  LED has received the "turn-on" signal but waits
//       2:  LED is fading on
//       3:  LED is on
//
void operateLed(byte input) {
  static byte state = 0;                   // store the state
  static unsigned long previousTime = 0;   // store the time
  static byte step = 0;                    // steps when fading LED 

  // --- deal with external inputs
  if (input == 1 && state == 0) {     // turn on signal - if LED:off
    state = 1;                        // start turn-on procedure 
    previousTime = millis();          // store current time 
  } else if (input == 2) {            // turn off signal

    digitalWrite(pinLed, LOW);        // turn off LED
    state = 0;                        // set state to 0:off
  }

  // --- timer-based state changes
  if (state == 1) {                   // turn-on was requested
    if ( (millis() - previousTime) > ledDelay) {   // wait
      state = 2;                      // set state to 2:fade-on 
      step = 0;                       // reset step counter
      previousTime = millis();        // store current time 
    }
  } else 
if (state == 2) {            // ramp-up
    if (step == 255) {
      state = 3;
    } else if 
( (millis()-previousTime) > 2){   //
      previousTime = millis();
      step++;
      analogWrite(pinLed,step); 
    }
  }
}

The servos are operated by the subroutine "operateServos()". It has an input argument as described in the header.

// --- operate the servos
//     input   0 continue
//             1 rotate to max angle
//             2 rotate back to min angle
//   
//   possible states
//       0:  servos sit at min angle
//       1:  servos rotate from min to max 
//       2:  servos sit at max angle
//       3:  servos rotate from max to min
//
//
void operateServos(byte input) {
  static byte state = 0;                   // store the state
  static unsigned long previousTime = 0;   // store the time 
  //     store current servo angles
  static byte currentAngle[2] = {minAngle[0],minAngle[1]}; 


  // --- deal with external inputs
  if (input==1 && (state==0 || state==3)) { // go to max angle
    state = 1;               // set state to 1:start rotate to max
    previousTime = millis();          // store current time
  } else if (input==2 && (state==1 || state==2)) { // go to min angle
    state = 3;               // set state to 3:start rotate to min
    previousTime = millis();          // store current time 
  }

  // --- timer-based state changes (rotation of servos)
  if (state == 1) {                   // rotate from min to max
    if ( (millis() - previousTime) > 2) {      // wait
      previousTime = millis();                 // store current time 
      for (byte i = 0; i < 2; i++) {           // do for both servos
        if (currentAngle[i] < maxAngle[i]) {   // if not at max angle
          currentAngle[i]++;                   // increase angle
          myServo[i].write(currentAngle[i]);   // rotate to new angle
        }
      }
      // if both servos reached their max angles -> change to state 2
      if (currentAngle[0]==maxAngle[0] &&
        currentAngle[1]==maxAngle[1]) {  
        state = 2;                    // set state to 2:at max angle
      }
    }
  } else if (state == 3) {                //  rotate from max to min
     if ( (millis() - previousTime) > 2) {     // wait
      previousTime = millis();                 // store current time 
      for (byte i = 0; i < 2; i++) {           // do for both servos
        if (currentAngle[i] > minAngle[i]) {   // if not at min angle
          currentAngle[i]--;                   // decrease angle
          myServo[i].write(currentAngle[i]);   // rotate to new angle
        }
      }
      // if both servos reached their min angles -> change to state 0
      if (currentAngle[0]==minAngle[0] &&
          currentAngle[1]==minAngle[1]) {  
        state = 2;                    // set state to 0:at min angle
      }
    }
  }
}

Finally, the routine to operate the sound player. I am just giving the skeleton - the user needs to add the specific commands for their sound player. Code for the DFPlayerMini (plus a detailed description of the setup) can be found in one of my older blog posts.

// --- operate the sound player
//     input   0 continue
//             1 start sound
//             2 stop sound
//   
//   possible states
//       0:  sound is off
//       1:  waiting for sound to start after a short delay
//       2:  sound has started
//
//
void operateSound(byte input) {
  static byte state = 0;                        // store the state
  static unsigned long previousTime = 0;        // store the time

  // --- deal with external inputs
  if (input == 1 && state == 0) {     // start signal - if:off
    state = 1;                        // go into waiting state 
    previousTime = millis();          // store current time 
  } else if (input == 2) {            // stop signal - if:playing
    // your-code-to-stop-playing();   // stop playing
    state = 0;                        // set state to 0:off
  }

  // --- timer-based state changes
  if (state == 1) {                     // waiting for sound to start
    if ( (millis()-previousTime) > soundDelay) {   // wait
      state = 2;                        // set state to 2:playing
      // your-code-to-start-playing();  // play sound
    }
  }
}

This is it: A complete and detailed example of how to do multitasking with the Arduino using the concept of Finite State Machines. 
It works really well in cases like this, where most code pieces are idle most of the time, only waiting for external inputs (button presses, signals from a motion sensor, timers, etc.). 
If a subroutine is doing extended, complex calculations which each last long (i.e. longer than the time interval at which one e.g. wants to check a button status), then one needs a to look for other solutions (like breaking the calculations into smaller units, or using timer interrupts to execute the button code).

Outlook: Possible Improvements

Most of the above could be coded slightly more efficient, if one understands the concept of a matrix. In the FSM, the new state is uniquely defined from the current state and the input. This can be expressed with a (2-dimensional) matrix, where e.g. the rows (i.e. the first index) correspond to the inputs, the columns (i.e. the second index) to the current state, and the element of the matrix at this position corresponds to the new state.
Here is a rough code sketch using this idea, for a device with four possible states (0-3), and three possible inputs (0-2). The matrix for the FSM is called "fsmMatrix".

void operateMyDevice(byte input) {
  static byte currentState = 0;      // store the current state

  byte newState = 0;                 // store the new state

  // --- response matrix for 3 inputs (i.e. 3 rows 0-2)
  //     and 4 states (i.e. 4 columns 0-3)
  const byte fsmMatrix[3][4] = {   
         {0,1,2,3},            // responses for input==0
         {3,3,1,1},            // responses for input==1
         {0,0,0,0}             // responses for input==2
  };
  newState = fsmMatrix[input][currentState];

  // possible actions, depending on the current state,
  //         the input, and/or the new state
  // further actions (including state changes) may also
  //               be based on timers

  currentState = newState; 
}

Some different cases:

  • If the code is called with "input" zero, the first row of the matrix is evaluated to compute the new state. The first row contains {0, 1, 2, 3}. Since the n-th element is equal to "n", the new state will always be equal to the current state - so the state does not change. (This is the same as what we did in the routines above, where a zero input meant to continue operating in the same state).
  • If the code is called with "input" of one
    • if the current state was zero or one, the new state will become "3"
    • if the current state was two or three, the new state will become "1"
  • if the code is called with input "2", the new state will always be "0" (as the turn-off in the operateLed() code above)

I've been using the concept of FSMs (somehow) for quite some time. But, honestly, never as consequent as described in the example codes presented here. That means, I will the first one to benefit from writing this tutorial for my future code projects. And I hope that one or the other "out there" finds this helpful too!

No comments: