Tutorial Task Scheduler for Arduino

Designing a Modular Non-Blocking Task Scheduler for Arduino With One-Shot and Passive Timer Support

Many Arduino projects outgrow delay().
This tutorial shows how to build a scalable, cooperative scheduler that runs multiple timed tasks safely and efficiently — without blocking.

A Lightweight, Modular Cooperative Scheduler for Arduino

Many Arduino users eventually run into this problem:

“How do I run multiple timed tasks without using delay()?”

This scheduler:

  • Runs multiple independent timed tasks
  • Uses no delay() anywhere
  • Supports both millis() and micros() per task
  • Supports one-shot timers
  • Supports passive (non-callback) timers
  • Allows runtime enable/disable/restart
  • Is rollover safe

This is intended for users who already understand basic millis() timing and want a scalable structure.


What Is a Cooperative Scheduler?

A cooperative scheduler repeatedly checks tasks and runs them only when their timer expires.

Each task:

  • Executes quickly
  • Returns control immediately
  • Never blocks

Unlike:

  • delay() timing (blocking)
  • Interrupt-driven scheduling
  • Preemptive RTOS systems

It works because tasks cooperate — they must not hog the CPU.


Demonstrated Features

This example includes:

  • Periodic heartbeat LED
  • Self-disabling LED task (stops after 5 flashes)
  • Per-task millis() / micros() selection
  • One-shot timer (pushbutton controlled)
  • Passive timer with no callback
  • Runtime task control

Scheduler Architecture Overview

loop()
├── runScheduler()
│ ├── timer[0].checkTimer()
│ ├── timer[1].checkTimer()
│ ├── timer[2].checkTimer()
│ └── ...
└── other non-blocking code

The scheduler engine simply checks all timers every loop iteration.


The Core Timer Structure

Each timer maintains:

  • Callback function (or nullptr)
  • Interval
  • Time base
  • Enabled/disabled state
  • One-shot mode
  • Expired flag (for passive timers)

How to Add a New Timer

  1. Write a new callback function

  2. Add its prototype

  3. Add it to the timer table:
    {myNewTask, 1000, TIME_MILLIS, ENABLED, DISABLED}
    Or for a passive timer:
    {nullptr, 5000, TIME_MILLIS, DISABLED, DISABLED}

  4. Add its index in the enum.
    Note: The order of timer[] must match the TimerIndex enum.


Analogy

Intro to Cooperative Scheduling: The "Kitchen Manager" Pattern

If you’ve ever tried to blink an LED while simultaneously reading a button and moving a servo, you’ve likely realized that the delay() function is your worst enemy. When you use delay(1000), your Arduino essentially sits on its hands for 16 million clock cycles doing absolutely nothing.

To build complex projects, we need to stop thinking about "Waiting" and start thinking about "Scheduling."

1. The Analogy: The Busy Chef

Imagine a chef in a kitchen.

  • Blocking (The delay() way): The chef puts toast in the toaster and stares at it for 2 minutes. They don't prep the salad, don't check the soup, and don't take orders until the toast pops up. The kitchen is inefficient.
  • Cooperative (The Scheduler way): The chef starts the toast, sets a timer, and immediately moves to chop onions. Every few seconds, they glance at the timer. If it hasn't gone off, they keep chopping. When it rings, they stop, deal with the toast, and go back to work.

2. What makes it "Cooperative"?

In this sketch, we use a Cooperative Scheduler. It’s called "cooperative" because the scheduler doesn't have the power to "force" a task to stop. Instead:

  1. The loop() acts as the Manager, constantly checking a list of timers.
  2. If a timer has expired, the Manager calls that task’s function.
  3. The Golden Rule: The task must be "polite." It must do its job quickly and "return" control to the Manager. If a task contains its own delay(), it ruins the timing for everyone else.

3. Key Concepts in this Sketch

The Task Table

Instead of scattering variables all over your code, we organize everything into a struct (a custom data container). Each task in the table knows its own:

  • Interval: How often it should run (in milliseconds or microseconds).
  • State: Is it currently running (ENABLED) or paused (DISABLED)?
  • Behavior: Is it a heartbeat that repeats forever, or a "One-Shot" that runs once and stops?

The "Passive" Timer

Sometimes you don't want a function to run automatically; you just want to know when a certain amount of time has passed. We call this a Passive Timer. By setting the callback to nullptr, the scheduler simply sets an expiredFlag for you to check whenever you're ready, like a kitchen timer that stays beeping until you walk over and press the button.


4. How to Use This Template

  1. Define your Task: Write a small, fast function (e.g., void checkSensors()).
  2. Register it: Add it to the TimerIndex enum and the timer[] table.
  3. Let it Run: Call runScheduler() in your loop().

By using this modular approach, you can add dozens of tasks to your project without your code becoming a "spaghetti" mess of nested if statements and millis() calculations.


Final Notes

This scheduler is:

  • Cooperative
  • Non-blocking
  • Deterministic
  • Rollover safe
  • Easy to scale

“It is not an RTOS” — but for many projects, it removes the need for one.



Troubleshooting: "My Tasks are Lagging!"

If your LEDs aren't blinking smoothly or your buttons feel "mushy" and unresponsive, a task is likely taking too long to return control to the scheduler.

1. The "Heartbeat" Diagnostic

The heartbeat() task in this sketch is your best friend.

  • The Test: Watch the heartbeat LED (Pin 13).
  • The Symptom: If the LED flickers unevenly or "freezes" momentarily, you have a blocking task.
  • The Fix: Disable your other tasks one by one in the timer[] table. When the heartbeat becomes smooth again, you’ve found the culprit.

2. Common "Time-Hog" Culprits

Check your task functions for these three common mistakes:

Culprit Why it's bad The Fix
delay() Stops everything. Replace with a new Timer entry in the table.
Serial.print() Slow, especially at low Baud rates (e.g., 9600). Increase Baud rate to 115200 or print less often.
while() loops Loops that wait for sensor data or user input. Use an if statement and a State Machine to check the sensor next time.

3. Solving the "Long Task" Problem

If you have a task that must do something slow (like reading a DHT22 temperature sensor, which takes ~20ms), don't do it all at once. Break it into a State Machine.

Instead of this (Blocking):

C++

void readSensor() {

digitalWrite(trigger, HIGH);

delay(10); // <--- BAD: The whole kitchen stops!

digitalWrite(trigger, LOW);

}

Do this (Non-Blocking):

  1. Use a one-shot timer to trigger the start.
  2. Use a second timer (or a state flag) to go back and read the result later.
  3. This allows the runScheduler() to service other tasks during that 10ms gap.

4. Precision vs. Reality

In a cooperative scheduler, the interval is the minimum time between runs — not an exact deadline.

  • If Task A is scheduled for every 100ms, but Task B takes 10ms to run, Task A might actually run every 110ms.
  • The Rule: If you need microsecond precision that is immune to other code, you must use Hardware Interrupts. For almost everything else (LEDs, buttons, menus, sensors), this scheduler is more than enough.

5. Using the "Expired" Flag correctly

If isExpired() isn’t behaving as expected, check:

  1. Is it enabled? A timer won't count down or set its flag if enabled == DISABLED.
  2. Did you clear it? If you don't call clearExpired(), the code inside your if block might run every single time loop() repeats, which can cause its own performance issues.


Schematic for this tutorial:



//
//================================================^================================================
//     Cooperative Non-Blocking Task Scheduler for Arduino (One-Shot & Passive Timer Support)
//================================================^================================================
//
//  URL
//
//  LarryD
//
//  Version    YY/MM/DD    Comments
//  =======    ========    ========================================================================
//  1.00       26/02/21    Running code.
//  1.01       26/02/25    Edited some comments.
//  1.02       26/02/26    Heartbeat LED changed to, long flash (normal) short flash (blocking code).
//  1.03       26/02/26    Added .stop() and .resume() to the structure.
//
//
//
//  The example herein, uses a struct. This struct is one step away from creating a CLASS/library.
//  The struct fully exposes all the TIMER code, as opposed to including a library.
//
//  This is an "Intermediate tutorial".
//  Not for "Absolute beginners" or "First-week Arduino learners".
//
//  What Is a Cooperative Scheduler?
//  ----------------------------------------------------------------------------------------------
//  A cooperative scheduler repeatedly checks small tasks and runs them
//  only when their timer expires. Each task must execute quickly and
//  return control to loop().
//  This differs from:
//   - delay() based timing (blocking)
//   - interrupt driven scheduling
//   - preemptive RTOS scheduling
//
//  Description:
//  ----------------------------------------------------------------------------------------------
//  This sketch implements a lightweight, fully modular cooperative task scheduler for the Arduino.
//  It helps users understand a "Task" isn't just a single action, but can be a mini-program
//  with its own memory (using static variables).
//
//  Multiple independent tasks are executed without using delay(). Each task maintains its own:
//    • Optional callback function (nullptr supported)
//    • Execution interval
//    • Timing base (millis() or micros())
//    • ENABLED / DISABLED state
//    • Optional one-shot execution mode
//    • Expired flag (for passive, non-callback timers)
//
//  The scheduler engine runs continuously inside loop() and executes tasks when their
//  individual timers expire. All timing is non-blocking and rollover safe.
//
//  loop()
//    |-----runScheduler()
//    |     |----- timer[0].checkTimer()
//    |     |----- timer[1].checkTimer()
//    |     |----- timer[2].checkTimer()
//    |     +----- ...
//    |
//    +-----Other non-blocking code
//
//  Features:
//  ----------------------------------------------------------------------------------------------
//  • Fully non-blocking (no delay())
//  • Independent modular timer objects
//  • Optional callback (nullptr enables passive mode)
//  • Per-timer interval control
//  • Selectable time base (millis() or micros())
//  • ENABLED / DISABLED runtime control
//  • Optional one-shot mode
//  • Periodic tasks (heartbeat and LED blink examples)
//  • Self-disabling task (TIMER 5ToggleLED stops after 5 flashes of its LED)
//  • Edge-triggered event handling (pushbutton input)
//  • Built-in expired flag for polling timers
//  • Passive, non-callback TIMER polling (watchdogLED turns OFF after 5 seconds)
//  • Runtime task control and reconfiguration
//
//  One-Shot Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the oneShotSwitch:
//     1. Immediately turns oneShotLED ON
//     2. Arms the _oneShot TIMER as one-shot
//     3. After the configured delay, oneShotLED goes OFF
//     4. The task automatically disables itself
//
//  The one-shot behavior is implemented inside the scheduler engine,
//  keeping callback functions clean and application-focused.
//
//  Watchdog Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the watchdogSwitch:
//     1. The watchdogLED is turned ON
//     2. The _watchdog TIMER is enabled and restarted
//     3. When the _watchdog TIMER expires, the TIMER is disabled
//     4. The watchdogLED is turned OFF
//
//  Timer expire Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the expireSwitch:
//     1. The _watchdog TIMER is set to expire
//     2. When the next checkTimer() is run, the TIMER expires
//     3. The watchdogLED is turned OFF
//
//  Task Control API (Encapsulated in struct):
//  ----------------------------------------------------------------------------------------------
//     enableRestart()     - Enables and restarts the TIMER
//     stop()              - Pauses a timer (state preserved)
//     resume()            - Resume a previously stopped TIMER (timing position preserved)
//     disable()           - Disables the TIMER
//     restart()           - Reset timing and run immediately (clears stopped state), good for debugging
//     expire()            - Forces the TIMER to appear expired on the next scheduler check
//     setInterval(value)  - Changes the TIMER interval
//     setOneShot(mode)    - Enable or disable the one-shot behavior
//     isExpired()         - Returns the expiredFlag
//     clearExpired()      - Clears the expiredFlag
//     checkTimer()        - Checks the TIMER, callback function runs when the TIMER expires
//
//  Design Philosophy:
//  ----------------------------------------------------------------------------------------------
//  This scheduler is cooperative. Tasks must execute quickly and return control
//  to loop(). It is intended for small to medium embedded applications where a full RTOS is
//  unnecessary.
//
//  This example serves as a reusable foundation for building:
//
//     • State machines
//     • Event-driven systems
//     • Soft real-time control loops
//     • Modular embedded frameworks
//     • Timed automation sequences
//
//  No delay() is used anywhere in this sketch.
//
//  Adding a new TIMER:
//  ----------------------------------------------------------------------------------------------
//    1. Write a new "callback function".
//       • Must return void
//       • Must not have parameters
//       • Must execute quickly
//       • Never use delay()
//       ex:
//         void myNewTask()
//         {
//           //Your task code here
//         }
//
//    2. In the "Callback area for Function Prototype", add your new function prototype.
//       ex: void myNewTask();
//       Note: non-callback TIMERs use "nullptr" as its callback element, a Function Prototype is not need.
//
//    3. In the "timer Table", define your new TIMER.
//       ex:{callback, interval, timeBase, enabled, oneShot}
//          {myNewTask, 1000, TIME_MILLIS, ENABLED, DISABLED}  //_myNewTask
//          OR
//          {nullptr,   5000, TIME_MILLIS, DISABLED, DISABLED} //_myNewTask
//
//    4. Note: The timer[] array order must match the TimerIndex enum order.
//       In the "TIMER index names area", define a new TIMER name.
//       For standardization, this name should be the same as the callback function's name,
//       however, place an under score infront of the index name, ex: _shortHeartbeat.
//       Non-callback TIMERs can be given the name you desire.
//       Add this TIMER name to enumeration, "enum TimerIndex {...}".
//       Use this name when accessing the TIMER's functions and variables.
//       example:
//       enum TimerIndex
//       {
//         ...
//         _checkSwitches,
//         _watchdog,
//         _shortHeartbeat,
//
//         TIMER_COUNT   //Leave this as the last entry.
//       };
//
//       MAKEtimer timer[] =
//       {
//         //{callback,  interval,    timeBase, enabled,   oneShot}      Indexes
//         ...
//         {checkSwitches,     50, TIME_MILLIS, ENABLED,  DISABLED},   //_checkSwitches
//         {nullptr,         5000, TIME_MILLIS, DISABLED, DISABLED},   //_watchdog
//         {shortHeartbeat,  1000, TIME_MICROS, DISABLED, DISABLED}    //_shortHeartbeat
//         //
//         //Next TIMER element goes above. Add a comma at the end of the previous element.
//       };
//
//
//================================================^================================================
//
#define ENABLED   true
#define DISABLED  false

#define LEDon     HIGH       //Pin---[220R]---A[LED]K---GND
#define LEDoff    LOW

#define PUSHED    LOW        //5V---[Internal Pullup]---[Pin]---[Switch]---GND
#define RELEASED  HIGH


//                                        M A K E t i m e r
//================================================^================================================
//
enum TimeBase
{
  TIME_MILLIS,
  TIME_MICROS
};

//============================================================  Maketimer structure definition.
struct MAKEtimer
{
  //========================
  void (*callback)();
  unsigned long interval;
  TimeBase timeBase;
  bool enabled;
  bool oneShot;
  bool expiredFlag;
  unsigned long lastRun;

  bool stopped;

  //========================  Constructor
  MAKEtimer(void (*_callback)(), unsigned long _interval, TimeBase _timeBase, bool _enabled, bool _oneShot)
  {
    callback     = _callback;
    interval     = _interval;
    timeBase     = _timeBase;
    enabled      = _enabled;
    oneShot      = _oneShot;

    expiredFlag  = false;
    lastRun      = 0;
    stopped      = false;
  }

  //========================
  unsigned long now()
  {
    return (timeBase == TIME_MICROS) ? micros() : millis();
  }

  //========================
  void enableRestart()
  {
    expiredFlag = false;
    enabled = true;
    stopped = false;
    lastRun = now();
  }

  //========================
  void stop()
  {
    //Pause the timer, preserve timing position and expired state
    enabled = false;
    stopped = true;
  }

  //========================
  void resume()
  {
    if (stopped)
    {
      enabled = true;
      stopped = false;
    }
  }

  //========================
  void disable()
  {
    enabled = false;
    expiredFlag = false;
    stopped = false;
  }

  //========================
  void restart()
  {
    expiredFlag = false;
    stopped = false;
    enabled = true;
    lastRun = now();

    //We could use:
    //lastRun += interval;
  }


  //========================
  void expire()
  {
    //Force TIMER to appear expired on the next scheduler check.
    lastRun = now() - interval;
  }

  //========================
  void setInterval(unsigned long value)
  {
    interval = value;
  }

  //========================
  void setOneShot(bool mode)
  {
    oneShot = mode;
  }

  //========================
  bool isExpired()
  {
    return expiredFlag;
  }

  //========================
  void clearExpired()
  {
    expiredFlag = false;
  }

  //========================
  void checkTimer()
  {
    if (enabled == false)
    {
      return;
    }

    unsigned long currentTime = now();

    if (now() - lastRun >= interval)
    {
      lastRun = currentTime;

      if (callback != nullptr)
      {
        callback();
      }

      else
      {
        expiredFlag = true;
      }

      if (oneShot == true)
      {
        disable();
      }
    }
  }

}; //END of   struct MAKEtimer

//============================================================  Callback area for Function Prototype.
//
void heartbeat();
void fiveToggle();
void slowToggle();
void oneShot();
void checkSwitches();
void shortHeartbeat();
//Next prototype goes here.

//============================================================  TIMER index names area.
//
//Give a name to each index in tasks table.
//Use the same names as callback but add a under score in the front.
//Non-callback TIMERs can have whatever name you desire.
//►► Ensure the order of "this enum" matches the "timer table". ◄◄
//
enum TimerIndex
{
  _heartbeat,
  _fiveToggle,
  _slowToggle,
  _oneShot,
  _checkSwitches,
  _watchdog,
  _shortHeartbeat,

  TIMER_COUNT   //Leave this as the last entry.
};

//============================================================  timer Table
//Define all our scheduled TIMERs.
//
//Interval constants.
const unsigned long hbInterval = 500UL;

MAKEtimer timer[] =
{
  //{callback,  interval,    timeBase, enabled,   oneShot}      Indexes
  {heartbeat, hbInterval, TIME_MILLIS, ENABLED,  DISABLED},   //_heartbeat
  {fiveToggle,       400, TIME_MILLIS, ENABLED,  DISABLED},   //_fiveToggle
  {slowToggle,    800000, TIME_MICROS, ENABLED,  DISABLED},   //_slowToggle
  {oneShot,      1600000, TIME_MICROS, DISABLED, DISABLED},   //_oneShot
  {checkSwitches,     50, TIME_MILLIS, ENABLED,  DISABLED},   //_checkSwitches
  {nullptr,         5000, TIME_MILLIS, DISABLED, DISABLED},   //_watchdog
  {shortHeartbeat,  1000, TIME_MICROS, DISABLED, DISABLED}    //_shortHeartbeat
  //
  //Next TIMER element goes above. Add a comma at the end of the previous element.
};

//========================
//Single TIMER definition.
//i.e. Passive/no_callback TIMER.
//
//Make a TIMER called "shortHeartbeat".
//MAKEtimer shortHeartbeat =
//{
//  //{callback, interval, timeBase, enabled, oneShot}
//  nullptr, 1000, TIME_MICROS, DISABLED,  DISABLED
//};


//============================================================  GPIOs and Variables
//
const byte oneShotSwitch                   = 2;
const byte watchdogSwitch                  = 3;
const byte expireSwitch                    = 4;

const byte fiveToggleLED                   = 5;
const byte slowToggleLED                   = 6;
const byte oneShotLED                      = 7;
const byte watchdogLED                     = 8;
const byte heartbeatLED                    = 13;

//Heartbeat blocking control.
const unsigned long triggerPoint           = 1000UL;   //1000us, 1ms
bool blockingFlag                          = false;


//============================================================  Scheduler Engine
//
void runScheduler()
{
  for (byte i = 0; i < TIMER_COUNT; i++)
  {
    timer[i].checkTimer();
  }
}

//============================================================  TIMER Tasks
//
void heartbeat()
{
  //Gives a rough indication of sustained long loop execution times
  //(often caused by blocking code).
  //Did we observe code blocking ?
  if (blockingFlag)
  {
    //The Fag has done its job.
    blockingFlag = false;

    digitalWrite(heartbeatLED, LEDon);

    //There was blocking code.
    //Set TIMER _shortHeartbeat to run once only.
    timer[_shortHeartbeat].setOneShot(ENABLED);

    //Enable and restart TIMER _shortHeartbeat.
    timer[_shortHeartbeat].enableRestart();
  }

  else
  {
    //Normal heartbeat toggle.
    digitalWrite(heartbeatLED, !digitalRead(heartbeatLED));
  }
}

//========================
void fiveToggle()
{
  static bool state = LOW;
  static byte toggleCount = 0;
  static byte flashCount  = 0;

  state = !state;
  digitalWrite(fiveToggleLED, state);

  toggleCount++;

  //Have we done one flash cycle ?
  if (toggleCount >= 2)
  {
    toggleCount = 0;
    flashCount++;

    if (flashCount >= 5)
    {
      timer[_fiveToggle].disable();
      digitalWrite(fiveToggleLED, LEDoff);
    }
  }
}

//========================
void slowToggle()
{
  static bool state = LOW;
  state = !state;
  digitalWrite(slowToggleLED, state);
}

//========================
//This is a classic example of a "Timed Pulse" pattern.
//It’s a great way to handle things like solenoid valves or backlight timeouts!
void oneShot()
{
  //Turn OFF the LED.
  digitalWrite(oneShotLED, LEDoff);
}

//========================
//
void shortHeartbeat()
{
  digitalWrite(heartbeatLED, LEDoff);
}


//                                           s e t u p ( )
//================================================^================================================
//
void setup()
{
  Serial.begin(115200);

  pinMode(oneShotSwitch, INPUT_PULLUP);
  pinMode(watchdogSwitch, INPUT_PULLUP);
  pinMode(expireSwitch, INPUT_PULLUP);

  pinMode(heartbeatLED, OUTPUT);
  pinMode(fiveToggleLED, OUTPUT);
  pinMode(slowToggleLED, OUTPUT);
  pinMode(oneShotLED, OUTPUT);
  pinMode(watchdogLED, OUTPUT);

} //END of   setup()


//                                            l o o p ( )
//================================================^================================================
//
void loop()
{
  //============================================================  Measure loop execution time
  //
  static unsigned long loopStartMicros = micros();
  static unsigned int blockCount = 0;

  unsigned long nowMicros = micros();
  unsigned long loopTime  = nowMicros - loopStartMicros;

  loopStartMicros = nowMicros;

  //Avoids false positives due to Serial prints etc.
  //We have blocking code when when loop() execution time is 1ms or more.
  if (loopTime >= triggerPoint)
  {
    //Do we have 100 or more consecutive detections of blocking code.
    if (++blockCount >= 100)
    {
      //We have detected blocking code.
      blockingFlag = true;
    }
  }

  //Not enough blocking events detected.
  else
  {
    //loop() execution time is normal now.
    blockCount = 0;
  }


  //============================================================  Blocking test switch.
  // We could do this for testing:
  // A momentary switch connected to pin 2 (INPUT_PULLUP) is used to intentionally
  // introduce a delay into the main loop. When the switch is pressed, a
  // delayMicroseconds(triggerPoint) call increases loop execution time beyond the blocking
  // threshold, allowing verification of the blocking-detection logic.
  //  if (digitalRead(oneShotSwitch) == LOW)
  //  {
  //    //Intentionally block this code.
  //    delayMicroseconds(triggerPoint);
  //  }


  //============================================================  Blocking test switch
  //Used for testing the blocking code.
  //
  static bool Flag = false;
  static byte lastStatus = HIGH;
  static unsigned int counter = 0;

  //Because its there, we will the oneShotSwitch.
  byte status =  digitalRead(oneShotSwitch);

  //Did the switch change state ?
  if (lastStatus != status)
  {
    lastStatus = status;

    if (status == LOW)
    {
      //Enable consecutive blocking events.
      Flag = true;
      counter = 0;
    }
  }

  //Was the switch pressed ?
  if (Flag == true)
  {
    //The test switch generates 1000 blocking loops, while detection triggers after 100.
    //Let's try 1000 so we can observe the heartbeat flash changes.
    if (counter++ < 1000)
    {
      //Intentionally block this code.
      delayMicroseconds(triggerPoint);
    }

    else
    {
      //We are finshed testing.
      Flag = false;
    }
  }


  //============================================================  Service all TIMERs.
  //
  runScheduler();


  //============================================================  _watchdog
  //This is an example of a "Passive/no_callback" TIMER.
  //When the expired flag is set, the TIMER has expired.
  //Is it time to turn OFF the watchdogLED ?
  //
  if (timer[_watchdog].isExpired())
  {
    //Get ready for the next use of this Flag.
    timer[_watchdog].clearExpired();

    //Remove this TIMER from being checked by the scheduler. i.e. disable the TIMER "_watchdog".
    timer[_watchdog].disable();

    //Do other things here.
    digitalWrite(watchdogLED, LEDoff);
  }


  //========================================
  //Other non blocking code goes here
  //========================================


} //END of   loop()


//                                   c h e c k S w i t c h e s ( )
//================================================^================================================
//
void checkSwitches()
{
  static bool lastOneShotSwitch = RELEASED;
  static bool lastWatchdogSwitch = RELEASED;
  static bool lastExpireSwitch = RELEASED;

  bool currentState;

  //============================================================  oneShotSwitch
  //Example of using a one shot control.
  //
  currentState = digitalRead(oneShotSwitch);

  //Was there a change in the switches state ?
  if (lastOneShotSwitch != currentState)
  {
    //Update to the new state
    lastOneShotSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      digitalWrite(oneShotLED, LEDon);

      //Set TIMER _oneShot to run once only.
      timer[_oneShot].setOneShot(ENABLED);

      //Enable and restart TIMER _oneShot.
      timer[_oneShot].enableRestart();
    }

  } //END of   oneShotSwitch

  //============================================================  watchdogSwitch
  //This is a classic example of a "Watchdog" or "Timed Pulse" pattern.
  //
  currentState = digitalRead(watchdogSwitch);

  //Was there a change in the switches state ?
  if (lastWatchdogSwitch != currentState)
  {
    //Update to the new state.
    lastWatchdogSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      digitalWrite(watchdogLED, LEDon);

      //Enable and restart the TIMER "_watchdog".
      timer[_watchdog].enableRestart();
    }

  } //END of   watchdogSwitch

  //============================================================  expireSwitch
  //Example of making a TIMER expire prematurely.
  //
  currentState = digitalRead(expireSwitch);

  //Was there a change in the switches state ?
  if (lastExpireSwitch != currentState)
  {
    //Update to the new state
    lastExpireSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      //Make this TIMER expire.
      timer[_watchdog].expire();
    }

  } //END of   expireSwitch

} //END of   checkSwitches()

//                                              E N D
//================================================^================================================
//

EDIT:

  • Edited some sketch comments.
3 Likes

Class

  • For those that are interested, attached is the Class Library you can install for the above post.

CooperativeScheduler.zip (8.5 KB)

MD5 is:

FE086A0047F462DB0D88B4B25CF8D2AE

  • This is one of the 2 examples in the ZIP file:
//
//================================================^================================================
//     Cooperative Non-Blocking Task Scheduler for Arduino (One-Shot & Passive Timer Support)
//================================================^================================================
//
//  URL
//
//  LarryD
//
//  Version    YY/MM/DD    Comments
//  =======    ========    ========================================================================
//  1.00       26/02/28    Running code using the "CooperativeScheduler Class".
//
//
//
//
//
//  What Is a Cooperative Scheduler?
//  ----------------------------------------------------------------------------------------------
//  A cooperative scheduler repeatedly checks small tasks and runs them
//  only when their timer expires. Each task must execute quickly and
//  return control to loop().
//  This differs from:
//   - delay() based timing (blocking)
//   - interrupt driven scheduling
//   - preemptive RTOS scheduling
//
//  Description:
//  ----------------------------------------------------------------------------------------------
//  This sketch implements a lightweight, fully modular cooperative task scheduler for the Arduino.
//  It helps users understand a "Task" isn't just a single action, but can be a mini-program
//  with its own memory (using static variables).
//
//  Multiple independent tasks are executed without using delay(). Each task maintains its own:
//    • Optional callback function (nullptr supported)
//    • Execution interval
//    • Timing base (millis() or micros())
//    • ENABLED / DISABLED state
//    • Optional one-shot execution mode
//    • Expired flag (for passive, non-callback timers)
//
//  The scheduler engine runs continuously inside loop() and executes tasks when their
//  individual timers expire. All timing is non-blocking and rollover safe.
//
//  loop()
//    |-----runScheduler()
//    |     |----- timer[0].checkTimer()
//    |     |----- timer[1].checkTimer()
//    |     |----- timer[2].checkTimer()
//    |     +----- ...
//    |
//    +-----Other non-blocking code
//
//  Features:
//  ----------------------------------------------------------------------------------------------
//  • Fully non-blocking (no delay())
//  • Independent modular timer objects
//  • Optional callback (nullptr enables passive mode)
//  • Per-timer interval control
//  • Selectable time base (millis() or micros())
//  • ENABLED / DISABLED runtime control
//  • Optional one-shot mode
//  • Periodic tasks (heartbeat and LED blink examples)
//  • Self-disabling task (TIMER 5ToggleLED stops after 5 flashes of its LED)
//  • Edge-triggered event handling (pushbutton input)
//  • Built-in expired flag for polling timers
//  • Passive, non-callback TIMER polling (watchdogLED turns OFF after 5 seconds)
//  • Runtime task control and reconfiguration
//
//  One-Shot Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the oneShotSwitch:
//     1. Immediately turns oneShotLED ON
//     2. Arms the _oneShot TIMER as one-shot
//     3. After the configured delay, oneShotLED goes OFF
//     4. The task automatically disables itself
//
//  The one-shot behavior is implemented inside the scheduler engine,
//  keeping callback functions clean and application-focused.
//
//  Watchdog Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the watchdogSwitch:
//     1. The watchdogLED is turned ON
//     2. The _watchdog TIMER is enabled and restarted
//     3. When the _watchdog TIMER expires, the TIMER is disabled
//     4. The watchdogLED is turned OFF
//
//  Timer expire Demonstration:
//  ----------------------------------------------------------------------------------------------
//  Pressing the expireSwitch:
//     1. The _watchdog TIMER is set to expire
//     2. When the next checkTimer() is run, the TIMER expires
//     3. The watchdogLED is turned OFF
//
//
//  Task Control API (Encapsulated in the Class):
//  ----------------------------------------------------------------------------------------------
//   .enableRestart()
//      Enables the TIMER and restarts its timing from the current moment. This clears the expiredFlag, 
//      removes any stopped state, and resets the internal timing reference (lastRun). 
//      Use this when you want a TIMER to begin running immediately and cleanly, regardless of its previous state.
//      Typical use: starting or re-arming a task after user input or an external event.
//   ________________________________________
//   .stop()
//      Temporarily pauses a running TIMER while preserving its internal timing position and expired state. 
//      The TIMER is removed from scheduler checks but can be resumed later without restarting the interval.
//      Typical use: pausing a task during a mode change, user interaction, or temporary fault condition.
//   ________________________________________
//   .resume()
//      Resumes a TIMER that was previously stopped using stop(). The TIMER continues counting from where it was paused,
//      rather than restarting the interval. If the TIMER was not stopped, this function has no effect.
//      Typical use: continuing a paused task without disturbing its timing accuracy.
//   ________________________________________
//   .disable()
//      Completely disables the TIMER and clears both the stopped state and the expiredFlag. Once disabled, 
//      the TIMER cannot be resumed and must be restarted using enableRestart() or restart().
//      Typical use: permanently shutting down a task, such as after a one-shot completion or watchdog timeout.
//   ________________________________________
//   .restart()
//      Resets the TIMER’s timing reference and immediately enables it. This clears the stopped state and expiredFlag, 
//      and restarts the interval from “now,” without forcing immediate expiration.
//      Typical use: debugging, re-synchronizing timing, or restarting a task in a known clean state.
//   ________________________________________
//   .expire()
//      Forces the TIMER to appear expired on the very next scheduler check. This is done by adjusting the internal timing 
//      reference so that the interval condition is immediately met.
//      Typical use: triggering timeout behavior early, such as simulating a watchdog expiry or forcing state transitions.
//   ________________________________________
//   .setInterval(unsigned long value)
//      Updates the TIMER’s interval value. The new interval takes effect immediately and applies to the next expiration calculation.
//      Typical use: dynamically adjusting task frequency at runtime (e.g., speed control, adaptive polling).
//   ________________________________________
//   .setOneShot(bool mode)
//      Enables or disables one-shot behavior for the TIMER. When one-shot mode is enabled, the TIMER automatically disables 
//      itself after a single expiration.
//      Typical use: timed pulses, delayed actions, solenoid control, or automatic shutdown events.
//   ________________________________________
//   .isExpired()
//      Returns the current state of the TIMER’s expiredFlag. This is primarily used with passive (no-callback) TIMERs 
//      to detect expiration via polling.
//      Typical use: watchdog timers, inactivity timeouts, or event detection without callbacks.
//   ________________________________________
//   .clearExpired()
//      Clears the TIMER’s expiredFlag. This is required after handling an expiration event for passive TIMERs to prevent 
//      repeated detection.
//      Typical use: acknowledging and resetting a polled timeout condition.
//   ________________________________________
//   .checkTimer()
//      Core scheduler function that checks whether the TIMER has expired. If expired, it updates timing, executes 
//      the callback (if present), or sets the expiredFlag for passive TIMERs. If one-shot mode is enabled, the TIMER 
//      automatically disables itself after execution.
//      Typical use: called repeatedly by the scheduler engine (e.g., inside runScheduler()).
//   
//
//  Design Philosophy:
//  ----------------------------------------------------------------------------------------------
//  This scheduler is cooperative. Tasks must execute quickly and return control
//  to loop(). It is intended for small to medium embedded applications where a full RTOS is
//  unnecessary.
//
//  This example serves as a reusable foundation for building:
//
//     • State machines
//     • Event-driven systems
//     • Soft real-time control loops
//     • Modular embedded frameworks
//     • Timed automation sequences
//
//  No delay() is used anywhere in this sketch.
//
//  Adding a new TIMER:
//  ----------------------------------------------------------------------------------------------
//    1. Write a new "callback function".
//       • Must return void
//       • Must not have parameters
//       • Must execute quickly
//       • Never use delay()
//       ex:
//         void myNewTask()
//         {
//           //Your task code here
//         }
//
//    2. In the "Callback area for Function Prototype", add your new function prototype.
//       ex: void myNewTask();
//       Note: non-callback TIMERs use "nullptr" as its callback element, a Function Prototype is not need.
//
//    3. In the "timer Table", define your new TIMER.
//       ex:{callback, interval, timeBase, enabled, oneShot}
//          {myNewTask, 1000, TIME_MILLIS, ENABLED, DISABLED}  //_myNewTask
//          OR
//          {nullptr,   5000, TIME_MILLIS, DISABLED, DISABLED} //_myNewTask
//
//    4. Note: The timer[] array order must match the TimerIndex enum order.
//       In the "TIMER index names area", define a new TIMER name.
//       For standardization, this name should be the same as the callback function's name,
//       however, place an under score infront of the index name, ex: _shortHeartbeat.
//       Non-callback TIMERs can be given the name you desire.
//       Add this TIMER name to enumeration, "enum TimerIndex {...}".
//       Use this name when accessing the TIMER's functions and variables.
//       example:
//       enum TimerIndex
//       {
//         _heartbeat, 
//         ...
//         _checkSwitches,
//         _watchdog,
//
//         TIMER_COUNT   //Leave this as the last entry.
//       };
//
//       MAKEtimer timer[] =
//       {
//         //{callback,  interval,    timeBase, enabled,   oneShot}       Indexes
//         {heartbeat, hbInterval, TIME_MILLIS, ENABLED,  DISABLED},   //_heartbeat
//         ...
//         {checkSwitches,     50, TIME_MILLIS, ENABLED,  DISABLED},   //_checkSwitches
//         {nullptr,         5000, TIME_MILLIS, DISABLED, DISABLED}    //_watchdog
//         //
//         //Next TIMER element goes above. Add a comma at the end of the previous element.
//       };
//
//
//================================================^================================================
//

#include <CooperativeScheduler.h>


#define ENABLED   true
#define DISABLED  false

#define LEDon     HIGH       //Pin---[220R]---A[LED]K---GND
#define LEDoff    LOW

#define PUSHED    LOW        //5V---[Internal Pullup]---[Pin]---[Switch]---GND
#define RELEASED  HIGH


//                                        M A K E t i m e r
//================================================^================================================
//

//============================================================  Callback area for Function Prototypes.
//
void heartbeat();
void shortHeartbeat();
void fiveToggle();
void slowToggle();
void oneShot();
void checkSwitches();
//Next prototype goes here.

//============================================================  TIMER index names area.
//
//Give a name to each index in tasks table.
//Use the same names as callback but add a under score in the front.
//Non-callback TIMERs can have whatever name you desire.
//►► Ensure the order of "this enum" matches the "timer table". ◄◄
//
enum TimerIndex
{
  _heartbeat,
  _shortHeartbeat,
  _fiveToggle,
  _slowToggle,
  _oneShot,
  _checkSwitches,
  _watchdog,

  TIMER_COUNT   //Leave this as the last entry.
};

//============================================================  timer Table
//Define all our scheduled TIMERs.
//
//Interval constants.
const unsigned long hbInterval = 500UL;

MAKEtimer timer[] =
{
  //{callback,  interval,    timeBase, enabled,   oneShot}      Indexes
  {heartbeat, hbInterval, TIME_MILLIS, ENABLED,  DISABLED},   //_heartbeat
  {shortHeartbeat,  1000, TIME_MICROS, DISABLED, DISABLED},   //_shortHeartbeat
  {fiveToggle,       400, TIME_MILLIS, ENABLED,  DISABLED},   //_fiveToggle
  {slowToggle,    800000, TIME_MICROS, ENABLED,  DISABLED},   //_slowToggle
  {oneShot,      1600000, TIME_MICROS, DISABLED, DISABLED},   //_oneShot
  {checkSwitches,     50, TIME_MILLIS, ENABLED,  DISABLED},   //_checkSwitches
  {nullptr,         5000, TIME_MILLIS, DISABLED, DISABLED}    //_watchdog
  //
  //Next TIMER element goes above. Add a comma at the end of the previous element.
};

//========================
//Single TIMER definition.
//i.e. Passive/no_callback TIMER.
//
//Make a TIMER called "shortHeartbeat".
//MAKEtimer shortHeartbeat =
//{
//  //{callback, interval, timeBase, enabled, oneShot}
//  nullptr, 1000, TIME_MICROS, DISABLED,  DISABLED
//};

//============================================================  Scheduler Engine
//
void runScheduler()
{
  for (byte i = 0; i < TIMER_COUNT; i++)
  {
    timer[i].checkTimer();
  }
}
//
//================================================^================================================


//============================================================  GPIOs and Variables
//
const byte oneShotSwitch                   = 2;
const byte watchdogSwitch                  = 3;
const byte expireSwitch                    = 4;

const byte fiveToggleLED                   = 5;
const byte slowToggleLED                   = 6;
const byte oneShotLED                      = 7;
const byte watchdogLED                     = 8;
const byte heartbeatLED                    = 13;

//Heartbeat blocking control.
const unsigned long triggerPoint           = 1000UL;   //1000us (1ms)
bool blockingFlag                          = false;


//                                       T I M E R   T a s k s 
//================================================^================================================
//
void fiveToggle()
{
  static bool state = LOW;
  static byte toggleCount = 0;
  static byte flashCount  = 0;

  state = !state;
  digitalWrite(fiveToggleLED, state);

  toggleCount++;

  //Have we done one flash cycle ?
  if (toggleCount >= 2)
  {
    toggleCount = 0;
    flashCount++;

    if (flashCount >= 5)
    {
      timer[_fiveToggle].disable();
      digitalWrite(fiveToggleLED, LEDoff);
    }
  }
}

//========================
void slowToggle()
{
  static bool state = LOW;
  state = !state;
  digitalWrite(slowToggleLED, state);
}

//========================
//This is a classic example of a "Timed Pulse" pattern.
//It’s a great way to handle things like solenoid valves or backlight timeouts!
void oneShot()
{
  //Turn OFF the LED.
  digitalWrite(oneShotLED, LEDoff);
}

//========================
void heartbeat()
{
  //Gives a rough indication of sustained long loop execution times
  //(often caused by blocking code).
  //Did we observe code blocking ?
  if (blockingFlag)
  {
    //The Flag has done its job.
    blockingFlag = false;

    digitalWrite(heartbeatLED, LEDon);

    //There was blocking code.
    //Set TIMER _shortHeartbeat to run once only.
    timer[_shortHeartbeat].setOneShot(ENABLED);

    //Enable and restart TIMER _shortHeartbeat.
    timer[_shortHeartbeat].enableRestart();
  }

  else
  {
    //Normal heartbeat toggle.
    digitalWrite(heartbeatLED, !digitalRead(heartbeatLED));
  }
}

//========================
//
void shortHeartbeat()
{
  digitalWrite(heartbeatLED, LEDoff);
}


//                                           s e t u p ( )
//================================================^================================================
//
void setup()
{
  Serial.begin(115200);

  pinMode(oneShotSwitch, INPUT_PULLUP);
  pinMode(watchdogSwitch, INPUT_PULLUP);
  pinMode(expireSwitch, INPUT_PULLUP);

  pinMode(heartbeatLED, OUTPUT);
  pinMode(fiveToggleLED, OUTPUT);
  pinMode(slowToggleLED, OUTPUT);
  pinMode(oneShotLED, OUTPUT);
  pinMode(watchdogLED, OUTPUT);

} //END of   setup()


//                                            l o o p ( )
//================================================^================================================
//
void loop()
{
  //============================================================  loop() execution time
  //Needed to control the action of the heartbeat LED.
  loopTiming();

  //============================================================  Service all TIMERs.
  //
  runScheduler();


  //============================================================  _watchdog
  //This is an example of a "Passive/no_callback" TIMER.
  //When the expired flag is set, the TIMER has expired.
  //Is it time to turn OFF the watchdogLED ?
  //
  if (timer[_watchdog].isExpired())
  {
    //Remove this TIMER from being checked by the scheduler.
    //i.e. disable the TIMER "_watchdog".
    timer[_watchdog].disable();

    //Do other things here.
    digitalWrite(watchdogLED, LEDoff);
  }


  //========================================
  //Other non blocking code goes here
  //========================================


} //END of   loop()


//                                      l o o p T i m i n g ( )
//================================================^================================================
//loop() execution time is used to control how the heartbeatLED reacts.
//
void loopTiming()
{
  static unsigned long loopStartMicros = micros();
  static unsigned int blockCount = 0;

  unsigned long nowMicros = micros();
  unsigned long loopTime  = nowMicros - loopStartMicros;

  loopStartMicros = nowMicros;

  //Avoids false positives due to Serial prints etc.
  //We have blocking code when when loop() execution time is 1ms or more.
  if (loopTime >= triggerPoint)
  {
    //Do we have 100 or more consecutive detections of blocking code.
    if (++blockCount >= 100)
    {
      //We have detected blocking code.
      blockingFlag = true;
    }
  }

  //Not enough blocking events detected.
  else
  {
    //loop() execution time is normal now.
    blockCount = 0;
  }

  //============================================================  Blocking test switch.
  // We could use this for testing the heartbeatLED response:
  // A momentary switch connected to pin 2 (INPUT_PULLUP) is used to intentionally
  // introduce a delay into the main loop. When the switch is pressed, a
  // delayMicroseconds(triggerPoint) call increases loop execution time beyond the blocking
  // threshold, allowing verification of the blocking-detection logic.
  //  if (digitalRead(oneShotSwitch) == LOW)
  //  {
  //    //Intentionally block this code.
  //    delayMicroseconds(triggerPoint);
  //  }


  //============================================================  Blocking test switch
  //Used for testing the blocking code detection.
  //
  static bool Flag = false;
  static byte lastStatus = HIGH;
  static unsigned int counter = 0;

  //Since it already exists, we will reuse the oneShotSwitch.
  byte status =  digitalRead(oneShotSwitch);

  //Did the switch change state ?
  if (lastStatus != status)
  {
    lastStatus = status;

    if (status == LOW)
    {
      //Enable consecutive blocking events.
      Flag = true;
      counter = 0;
    }
  }

  //Was the switch pressed ?
  if (Flag == true)
  {
    //The test switch generates 1000 blocking loops, while detection triggers after 100.
    //Let's try 1000 so we can observe the heartbeat flash changes.
    if (counter++ < 1000)
    {
      //Intentionally block this code.
      delayMicroseconds(triggerPoint);
    }

    else
    {
      //We are finshed testing.
      Flag = false;
    }
  }

} //END of   loopTiming()


//                                   c h e c k S w i t c h e s ( )
//================================================^================================================
//
void checkSwitches()
{
  static bool lastOneShotSwitch = RELEASED;
  static bool lastWatchdogSwitch = RELEASED;
  static bool lastExpireSwitch = RELEASED;

  bool currentState;

  //============================================================  oneShotSwitch
  //Example of using a one shot control.
  //
  currentState = digitalRead(oneShotSwitch);

  //Was there a change in the switches state ?
  if (lastOneShotSwitch != currentState)
  {
    //Update to the new state
    lastOneShotSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      digitalWrite(oneShotLED, LEDon);

      //Set TIMER _oneShot to run once only.
      timer[_oneShot].setOneShot(ENABLED);

      //Enable and restart TIMER _oneShot.
      timer[_oneShot].enableRestart();
    }

  } //END of   oneShotSwitch

  //============================================================  watchdogSwitch
  //This is a classic example of a "Watchdog" or "Timed Pulse" pattern.
  //
  currentState = digitalRead(watchdogSwitch);

  //Was there a change in the switches state ?
  if (lastWatchdogSwitch != currentState)
  {
    //Update to the new state.
    lastWatchdogSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      digitalWrite(watchdogLED, LEDon);

      //Enable and restart the TIMER "_watchdog".
      timer[_watchdog].enableRestart();
    }

  } //END of   watchdogSwitch

  //============================================================  expireSwitch
  //Example of making a TIMER expire prematurely.
  //
  currentState = digitalRead(expireSwitch);

  //Was there a change in the switches state ?
  if (lastExpireSwitch != currentState)
  {
    //Update to the new state
    lastExpireSwitch = currentState;

    //Is the switch pushed ?
    if (currentState == PUSHED)
    {
      //Make this TIMER expire.
      timer[_watchdog].expire();
    }

  } //END of   expireSwitch

} //END of   checkSwitches()

//                                              E N D
//================================================^================================================
//
1 Like

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.