Arduino: Switch GPIOs by time

Sometimes you want to switch on / off pins on the Arduino based on time. Or you want to start an task with a button press and let it end by time. As you probably now - delay() has it limitations and millis() is your way to go. On this page we want to learn about the usage of millis() and how to design processing flows.

The Use Case

Assume following use case: you have a device which warns you about low water levels with blinking LEDs. The LEDs are blinking about 1 Hz if the water level is to low. You are able to read read the voltage of these LEDs. If a LED starts blinking you want to switch on a relay which activates a water pump. If the LED stops blinking the relay should switch off. As the relay should not switch on/off in parallel of the blinking LED you define to start the relay after a blinking period of 15 seconds (delayOn). If the LED has stopped blinking for 30 seconds - the relay should switch off (delayOff). The device has 4 different LEDs and each single LED should activate/deactivate one  relay.

The Challenge

Basically you have to solve these challenges

  • How can you make this blinking signal to be used as "start signal"?
  • How do you identify the end of blinking?
  • How can you handle the delays without using delay, as you want to react on more than one LED.

How to Start

Before you open the Arduino IDE, think about the necessary steps which have to be done in the program. There is a phase with the LED permanent off. And a phase when the blink LED is active. Let's call it IDLE and ACTIVE. As this blinking has ON/OFF phases and we need the start of the blinking, we add an phase STARTED. In programming terms we call these phases "states" and we can draw a state diagram:

What tells this diagram:

  • If the LED starts blinking, switch to status to STARTED.
  • If we were 10 seconds in the status STARTED switch to status ACTIVE
  • As long as we see a blinking stay in ACTIVE
  • If there was no blinking for 30 seconds, go back to IDLE

What is currently not shown but obviously: when you leave STARTED and enter Active you have to switch on the relay. When you leave the ACTIVE state and go back to IDLE - you have to switch off the relay.

Well that is all. You have designed your first "finite state machine" and you can start the IDE now.

Programming a Finite State Machine on the Arduino

This is a complete sketch to copy-paste in the IDE:

/*
  4 input from blinking LED
  4 output controling 4 different relay

  When the LED starts blinking, start timer
  If the LED blinks for certain time, then output  == HIGH
  When the LED stops blinking, wait for a certain time, then output == LOW

  by noiasca
*/

#define DEBUG                                   // deactivate if you don't need debug print
#ifdef DEBUG
#define DEBUG_PRINT(x)    Serial.print (x)
#define DEBUG_PRINTLN(x)  Serial.println (x)
#else
#define DEBUG_PRINT(x)
#define DEBUG_PRINTLN(x)
#endif

enum class State {IDLE,    // LED is OFF
                  STARTED, // after first blink but relais is still kept off
                  ACTIVE   // LED was blinking long enough, Relais is on
                 };

struct Checker {
  const byte readPin;        // GPIO to read the external LED
  const byte relayPin;       // GPIO with a connected relay module
  unsigned long firstBlink;  // "time" of first blink
  unsigned long lastBlink;   // "time" of last blink
  State state;               // actual state 
};

Checker checker[] {                            // now we need 4 instances of the LED - relay combination
  {A1, 5, 0, 0, State::IDLE},                  // pin 1 is occupied by Serial!
  {A2, 2, 0, 0, State::IDLE},
  {A3, 3, 0, 0, State::IDLE},
  {A4, 4, 0, 0, State::IDLE}
};

const unsigned long delayFirst = 10 * 1000UL;  // delay after the first recognized blink
const unsigned long delayLast = 30 * 1000UL;   // delay after the last recognized blink

void runCheck()
{
  unsigned long currentMillis = millis();                    // helper variable to store the actual millis ("time");
  for (auto & i : checker)                                   // "for each" element of the struct
  {
    switch (i.state)                                         // do according the current state of the struct
    {
      case State::IDLE :                                     // when you are in IDLE state, then 
        if (digitalRead(i.readPin) == HIGH)                  // 1: check for blinking
        {
          DEBUG_PRINT(F("in firstBlink ")); DEBUG_PRINTLN(i.readPin);
          i.firstBlink = currentMillis;
          i.state =  State::STARTED;                         // next state
        }
        break;
      case State::STARTED :                                  // when the blinking has already started, then
        if (currentMillis - i.firstBlink > delayFirst)       // 2: check if blinking is old enough
        {
          digitalWrite(i.relayPin, HIGH);
          i.lastBlink = currentMillis;
          i.state = State::ACTIVE;                           // next state
        }
        break;
      case State::ACTIVE :                                   // when the you are in ACTIVE state, then
        if (digitalRead(i.readPin) == HIGH)
        {
          i.lastBlink = currentMillis;                       // 3: update timer
        }
        if (currentMillis - i.lastBlink > delayLast)         // 4: check if blinking is over long enough
        {
          DEBUG_PRINT(F("in timeout ")); DEBUG_PRINTLN(i.readPin);
          digitalWrite(i.relayPin, LOW);
          i.state = State::IDLE;                             // next state = start from new
        }
    }
  }
}

void setup() {
  Serial.begin(115200);               // we have 2020 - most of serials can scope with 115200 nowadays
  for (auto & i : checker)            // "for each" element of the struct checker
  {
    pinMode (i.readPin, INPUT);
    pinMode (i.relayPin, OUTPUT);
  }
}

void loop() {
  runCheck();
}

Now let's do some analyzes.

We start with some defines for the Serial monitor. During development of the sketch debug print comes very handy to follow the program behaviour. When the sketch is final and ready - we just comment the first #define and all debug prints in the Skech are gone.

#define DEBUG                                   // deactivate if you don't need debug print
#ifdef DEBUG
#define DEBUG_PRINT(x)    Serial.print (x)
#define DEBUG_PRINTLN(x)  Serial.println (x)
#else
#define DEBUG_PRINT(x)
#define DEBUG_PRINTLN(x)
#endif

As you are writing a finite state machine, you want to keep track of the states. Don't use magic numbers. C++/11 comes with a so called enumeration class, which allows you to just name your state:

enum class State {IDLE,    // LED is OFF
                  STARTED, // after first blink but relais is still kept off
                  ACTIVE   // LED was blinking long enough, Relais is on
                 };

The compiler will give each state a number (0, 1, 2) - but you don't care! You will just use the IDLE, STARTED, ACTIVE in your sketch and you can forget about the number behind.

Next step is to define variables. First you need a pin to connect the external LED. Then you obviously you need a pin for the relay. As you want to identify the first blink you need an unsigned long to store the time (millis). The same applies for the end of blinking. And finally - you have to keep track of the status. Keep your variables tidy! Put together what belongs together. To "bundle" variables - to give them a structure, you use the C++ keyword struct:

struct Checker {
  const byte readPin;        // GPIO to read the external LED
  const byte relayPin;       // GPIO with a connected relay module
  unsigned long firstBlink;  // "time" of first blink
  unsigned long lastBlink;   // "time" of last blink
  State state;               // actual state 
};

This defines a struct Checker with 5 member variables.

As described in the use case, you need these variables 4 times. Better: you have to define 4 instances of this structure. To make the handling easier, but them in an array:

Checker checker[] {                            // now we need 4 instances of the LED - relay combination
  {A1, 5, 0, 0, State::IDLE},                  // pin 1 is occupied by Serial!
  {A2, 2, 0, 0, State::IDLE},
  {A3, 3, 0, 0, State::IDLE},
  {A4, 4, 0, 0, State::IDLE}
};

The next part is easy: here are some variables you can use as parameter for the state machine:

const unsigned long delayFirst = 10 * 1000UL;  // delay after the first recognized blink
const unsigned long delayLast = 30 * 1000UL;   // delay after the last recognized blink

Now the heavy part: The finite state machine. First you "remember" the current time in a variable

void runCheck()
{
  unsigned long currentMillis = millis();                    // helper variable to store the actual millis ("time");

You want to handle 4 LEDs - but you don't want to write code 4 times. As you have the variables in an struct array, you can use a for loop. Better - you can use something new:

for (auto & i : checker)                                   // "for each" element of the struct

C++/11 comes with the so called ranged based for loop. You have defined a structure,  auto inherits the type of the checker. The actual element will get a reference on &i. In other words: you can use i like the actual instance with all member variables. Instead to write checker[0].readPin for the first pin, you just write i.readPin - nice, isn't it?

Now we can handle the state machine. To use a switch-case is straight forward. To define each case, you simple use

what happens in State::IDLE

 {
    switch (i.state)                                         // do according the current state of the struct
    {
      case State::IDLE :                                     // when you are in IDLE state, then 
        if (digitalRead(i.readPin) == HIGH)                  // 1: check for blinking
        {
          DEBUG_PRINT(F("in firstBlink ")); DEBUG_PRINTLN(i.readPin);
          i.firstBlink = currentMillis;
          i.state =  State::STARTED;                         // next state
        }
        break;

If the Arduino detects a HIGH, the current timestamp will be copied to the member variable firstBlink AND the next state is "STARTED".

      case State::STARTED :                                  // when the blinking has already started, then
        if (currentMillis - i.firstBlink > delayFirst)       // 2: check if blinking is old enough
        {
          digitalWrite(i.relayPin, HIGH);
          i.lastBlink = currentMillis;
          i.state = State::ACTIVE;                           // next state
        }
        break;
 

In STARTED you check if enough time is passed and after the defined time you switch on the relay and hand over to the next State::ACTIVE.

In ACTIVE you have to do two steps: every time you see a blink you update the timestamp for lastBlink:

     case State::ACTIVE :                                   // when the you are in ACTIVE state, then
        if (digitalRead(i.readPin) == HIGH)
        {
          i.lastBlink = currentMillis;                       // 3: update timer
        }
 

Additionally you check, if the time of the lastBlink has expired - and if so: you switch off the relay and go back to State::IDLE

       if (currentMillis - i.lastBlink > delayLast)         // 4: check if blinking is over long enough
        {
          DEBUG_PRINT(F("in timeout ")); DEBUG_PRINTLN(i.readPin);
          digitalWrite(i.relayPin, LOW);
          i.state = State::IDLE;                             // next state = start from new
        }
    }
  }
}

The setup is only a 3 liner with a Serial.begin and the setting of the pinmodes. Again you use the "range based for" and set all 8 pinmodes in just 2 lines:

void setup() {
  Serial.begin(115200);               // we have 2020 - most of serials can scope with 115200 nowadays
  for (auto & i : checker)            // "for each" element of the struct checker
  {
    pinMode (i.readPin, INPUT);
    pinMode (i.relayPin, OUTPUT);
  }
}

Finally you just call the finite state machine in your loop as often as possible.

void loop() {
  runCheck();
}

As long as you write non-blocking code - you can add new functionalities to the loop. What's about a heartbeat LED for your Arduino to see everything is fine with your Arduino?

Links

(*) Disclosure: Some of the links above are affiliate links, meaning, at no additional cost to you I will earn a (little) comission if you click through and make a purchase. I only recommend products I own myself and I'm convinced they are useful for other makers.

History

First upload: 2020-10-10 | Version: 2024-03-22