State machine design?

I am trying to learn to design state machines. This is the nth rewrite of my mixing station software. I think that this will work, but I am not sure if I am following the intent of the state machine and using it correctly or not or what that really is supposed to be. In my last one I was just returning out of every state and calling the function repeatedly in loop, and that probably wasn't the best, but I am not sure what "good" design is here?

This chunk is the main loop and it shows how things are iterated within it, in the case of iterating the members of a "Formula" struct.

Just trying to learn to have do more a better. This has NOT been tested on hardware, it is only a concept at the moment.

unsigned long DELAY_START;
int DELAY_FOR;
void Delay(int dur) {
  STATE = DELAY;
  DELAY_START = millis();
  DELAY_FOR = dur;
}
states NEXT_STATE;

int currentDelay;
int currentRest;
Pump currentPump;
int mixingIndex = 0;
void loop() {
  switch (STATE) {
    case MIX:
      currentPump = CurrentFormula.pumps[mixingIndex];
      currentDelay = CurrentFormula.mix_times[mixingIndex];
      currentRest =  CurrentFormula.rest_times[mixingIndex];
      mixingIndex++;
      if (mixingIndex < 7) {
        STATE = IDLE;
      } else {
        STATE = DOSE_START;
      }

    case DOSE_START:
      digitalWrite(currentPump.pin, HIGH);
      NEXT_STATE = DOSE_END;
      Delay(currentDelay);

    case DOSE_END:
      digitalWrite(currentPump.pin, LOW);
      STATE = REST;

    case REST:
      NEXT_STATE = MIX;
      Delay(currentRest);

    case DELAY: // use Delay(duration)
      if (millis() - DELAY_START < DELAY_FOR) {
        return;
      } else {
        STATE = NEXT_STATE;
      }

    case IDLE: // waiting for a button to change the state
      return;
  }
}

First piece of advice: Turn on compiler warnings in the IDE preferences.

The first thing that I notice is that there is no break; statement at the end of each case code block. As a result the code for all cases will be executed in the order that they appear

Start by drawing a state transition diagram.

2 Likes

Thank you. I need to add this. It should be so.

I found the following useful to do so.

https://www.madebyevan.com/fsm/

1 Like

Thanks for that clue, I have never seen one that I know of.

I was however more interested in the basic logic of the machine. As stated there should be break statements which I just forgot to add (and did), but in general, is this how the thing is supposed to work?

Particularly, is the way I am handling "delay" sane? It seems better than the way I was doing it before.

I am feeling very uncertain about how deeply to split up the states but I think that maybe is making itself obvious as I go.

(and again i know I have to sort my breaks and returns out no big deal)

I produced a state machine model, code and even built a Wokwi simulation of it a couple of days ago as an answer to help someone on stack exchange who was struggling with some scrappy code which he could not get working but was obviously an ideal candidate for a finite state machine solution.

Digression and rant:
I thought it was quite good and worked as the guy had tried to explain how it should. The OP wrote some negative statements about it, it got marked down losing me two Brownie points, and the OP added his own answer, copying the basic structure idea from mine (but added blocking code) and accepted his own answer.

2 Likes

states could be described as different modes of operation.
on an everyday example of a washing-machine
there is a "state" or a "mode of operation"

fill in water until correct water-level is reached

mutually exclusive execute only that part of the code that is nescessary for that mode of operation.

In case of filling in water into the washing-machine the code that is nescessary to execute is
"check water-level"

controlling rotational speed of the drum is not nescessary as long as the only thing that shall be done is filling in water

in mode "washing" you expect the washing-mashine to be sealed everywhere that no water can pour out. This means checking the water-level is not nescessary in the mode "washing"

break-statement

the break-statement is that one thing to do at the end of each case.

Only the break; -statement makes the code-execution mutually exclusive
without the break; you stay inside the switch-cases and the next case will be executed

If your state-machine is coded really non-blocking which means there are zero delay()s.
Yes you read correct zero delay()s

All timing will be done non-blocking in this way

state "A"

  • initiate a timer-variable with actual value of millis() and immidiately change to state "B"

state "B"

  • check if the delaying-time has passed by through checking
    millis() - startTime > delayTime
  • if delaying-time has really passed by change to state "C"

state "C"

  • do what shall be done after the non-blocking delay performed by state "A" and "B"

best regards Stefan

1 Like

Sorry for your experience on that thread (not that I dug in), but seeing your finite machine design, with the "OFF" as the null state waiting for a button is reassuring to me, as that was the design I was after with a "dead loop" state at idle, waiting for my buttons (or the RTC timer) to kick something off.

When one designs the state machine diagram, is there some... I don't know how to ask this... "ground floor" of logic that helps one break up the states? Or is it just "write what you want and if it fits in a single state then fine, if not you must have more"?

this is very helpful and somewhat reassuring, thank you, I think I am on the right track

Very good advise,
@underhilll ... start with a diagram, code according to the diagram.

2 Likes

Not in the sense of that there would be one standard that should be applied to everything.

basically there are two kind of states

  1. initiate something and immindiately change to the next state
  2. do how many steps ever - check for a condition and if condition becomes true change to next state

condition can be almost everything:

  • a certain time has passed by
  • one or more IO-pins have a certain state
  • a value goes above / below a threshold value

combination of such things

  • a value has been above a threshold for a certain amount of time
    etc. etc. etc.

best regards Stefan

1 Like

You probably have to go through a number of iterations of a design before you can identify the states cleanly. It is probably easier to say what isn't a state. For example, you have a state in your original "Delay". That is, itself, not a state. Instead of the delay, the state machine must simply remain in its previous state until a timeout, or other trigger, occurs. Also, if you find yourself writing blocking code in a state, then start thinking of splitting a state into one or more states.

1 Like

OK. yes. excellent. This is how I designed my framework, to either have STATE > STATE or STATE > DELAY > NEXT_STATE, and also to have a "null" state in which the only command is break, waiting for something to change STATE.

A few things I am not sure about;

  1. I am using globals to control counts and indices like 'mixingIndex' and I am not sure if that is a bad practice? a good one? the only one? Controlling the indices manually always feels klunky to me but maybe that's just the name of the game here.

  2. RE: the "do one thing" paradigm mentioned above, I immediately think of the fact that this machine has different things that it does and one should not interrupt another. I am not sure how to design for that fact, except to track that cycles are currently running using a bool - which seems easiest.

  3. Goes with #2, I was planning to handle button scans by just running a function at the top of loop. The button state scanning function just changes the STATE. Is this right? It seems to work very well for a start/cancel toggle.

Here is an example of the 'formula' struct I am working with and iterating.

Formula GROW_MIN = {
  CA{"GROW MIN"},
  5,
        {PUMP_SILICA, PUMP_THRIVE, PUMP_MICRO, PUMP_BLOOM, PUMP_GROW},
  /*RUN*/    {11,         12,         13,         14,         15},
  /*MIX*/    {21,         22,         23,         24,         25},
  /*REST*/   {31,         32,         33,         34,         35}
};

No idea what you want to say with that and how it is related to state-machine-design

Well a lot of this seems to be based on what one is designing for... so I just dropped it for some context.

and this context stays complete unclear

Sorry for that, I could have explained up front. This is a nutrient mixing machine that waters deep water culture hydroponics plants. It has several things it does, among them dosing and mixing nutrients into a bucket and also pumping those nutrients into reservoirs to feed the plants and draining those out. It will/has cycles for cleaning itself and flushing the system. The capabilities are a work in progress.

Here's all of the code, which isn't ready for the machine yet at all. I do have functional code which does what I want, but it is not good and will evolve poorly.

class CA { // character array - "safe" featureless string replacement for comparison and printing
public:
  char characters[20];
  bool operator==(const CA& other) const {
    for (int i = 0; i < 20; ++i) {
      if (characters[i] != other.characters[i]) {
        return false;
      }
    }
    return true;
  }
};

// valves control the output line for the main pump
struct Valve {
  CA name;
  int pin;
};
Valve Valves[4];
Valve VALVE_INPUT = {CA{"INPUT VALVE"},  11};
Valve VALVE_MIX   = {CA{"MIXING VALVE"}, 11};
Valve VALVE_RES_1 = {CA{"RES 1 VALVE"},  11};
Valve VALVE_RES_2 = {CA{"RES 2 VALVE"},  11};

struct Sensor {
  CA name;
  int pin;
};
Sensor Sensors[9];
Sensor LEVEL_MIX_LOW    = {CA{"MIX LOW"}, 11};
Sensor LEVEL_MIX_MID    = {CA{"MIX MID"}, 11};
Sensor LEVEL_MIX_HIGH   = {CA{"MIX HIGH"}, 11};

Sensor LEVEL_RES_1_LOW  = {CA{"RES_1 LOW"}, 11};
Sensor LEVEL_RES_1_MID  = {CA{"RES_1 MID"}, 11};
Sensor LEVEL_RES_1_HIGH = {CA{"RES_1 HIGHJ"}, 11};

Sensor LEVEL_RES_2_LOW  = {CA{"RES_2 LOW"}, 11};
Sensor LEVEL_RES_2_MID  = {CA{"RES_2 MID"}, 11};
Sensor LEVEL_RES_2_HIGH = {CA{"RES_2 HIGH"}, 11};

struct Pump {
  CA name;
  int pin;
};
Pump Pumps[8]; // DOSING PUMPS
Pump WaterPumps[3]; // WATER PUMPS

Pump PUMP_MAIN     = {CA{"PUMP MAIN"},    10};
Pump PUMP_DRAIN_1  = {CA{"PUMP DRAIN 1"}, 10};
Pump PUMP_DRAIN_2  = {CA{"PUMP DRAIN 2"}, 10};

Pump PUMP_SILICA   = {CA{"SILICA"},       11};
Pump PUMP_THRIVE   = {CA{"SUPERTHRIVE"},  12};
Pump PUMP_MICRO    = {CA{"MICRO"},        13};
Pump PUMP_BLOOM    = {CA{"BLOOM"},        14};
Pump PUMP_GROW     = {CA{"GROW"},         15};
Pump PUMP_NECTAR   = {CA{"NECTAR"},       16};
Pump PUMP_WET      = {CA{"NATURAL WET"},  17};
Pump PUMP_PEROXIDE = {CA{"PEROXIDE"},     18};

// formula refers to a configuration of pump run, mix and rest times
struct Formula {
  CA name;
  int doses;
  Pump pumps[8];
  int run_times[8];
  int mix_times[8];
  int rest_times[8];
};
Formula Formulas[8];
Formula CurrentFormula;

Formula SEEDLING = {
  CA{"SEEDLING"},
  8,
        {PUMP_SILICA, PUMP_THRIVE, PUMP_MICRO, PUMP_BLOOM, PUMP_GROW, PUMP_NECTAR, PUMP_WET, PUMP_PEROXIDE},
  /*RUN*/    {11,         12,         13,         14,         15,         16,         17,         18},
  /*MIX*/    {21,         22,         23,         24,         25,         26,         27,         28},
  /*REST*/   {31,         32,         33,         34,         35,         36,         37,         38}
};

Formula GROW_MIN = {
  CA{"GROW MIN"},
  5,
        {PUMP_SILICA, PUMP_THRIVE, PUMP_MICRO, PUMP_BLOOM, PUMP_GROW},
  /*RUN*/    {11,         12,         13,         14,         15},
  /*MIX*/    {21,         22,         23,         24,         25},
  /*REST*/   {31,         32,         33,         34,         35}
};

Formula GROW_MAX = {
  CA{"GROW MAX"},
  5,
        {PUMP_SILICA, PUMP_THRIVE, PUMP_MICRO, PUMP_BLOOM, PUMP_GROW},
  /*RUN*/    {11,         12,         13,         14,         15},
  /*MIX*/    {21,         22,         23,         24,         25},
  /*REST*/   {31,         32,         33,         34,         35}
};

enum states {
  MIX, // mix a formula 
  DOSE_START,
  DOSE_END,
  DELAY,
  REST,
  IDLE
} STATE;

unsigned long DELAY_START;
int DELAY_FOR;
void Delay(int dur) {
  STATE = DELAY;
  DELAY_START = millis();
  DELAY_FOR = dur;
}
states NEXT_STATE;

int currentDelay;
int currentRest;
Pump currentPump;
int mixingIndex = 0;
void loop() {
  switch (STATE) {
    case MIX:
      currentPump = CurrentFormula.pumps[mixingIndex];
      currentDelay = CurrentFormula.mix_times[mixingIndex];
      currentRest =  CurrentFormula.rest_times[mixingIndex];
      mixingIndex++;
      if (mixingIndex < 7) {
        STATE = IDLE;
      } else {
        STATE = DOSE_START;
      }
      break;

    case DOSE_START:
      digitalWrite(currentPump.pin, HIGH);
      NEXT_STATE = DOSE_END;
      Delay(currentDelay);
      break;

    case DOSE_END:
      digitalWrite(currentPump.pin, LOW);
      STATE = REST;
      break;

    case REST:
      NEXT_STATE = MIX;
      Delay(currentRest);
      break;

    case DELAY: // use Delay(duration)
      if (millis() - DELAY_START < DELAY_FOR) {
        STATE = NEXT_STATE;
      }
      break;

    case IDLE: // waiting for a button to change the state
      break;
  }
}
void setup() {
  Serial.begin(9600);
  Serial.println("");

  Formulas[0] = SEEDLING       ;
  Formulas[1] = GROW_MIN       ; 
  Formulas[2] = GROW_MAX       ; 

  Valves[0] = VALVE_INPUT      ;
  Valves[1] = VALVE_MIX        ;
  Valves[2] = VALVE_RES_1      ;
  Valves[3] = VALVE_RES_2      ;
  
  WaterPumps[0] = PUMP_MAIN    ;
  WaterPumps[1] = PUMP_DRAIN_1 ;
  WaterPumps[2] = PUMP_DRAIN_2 ;
  
  Pumps[0] = PUMP_SILICA       ;
  Pumps[1] = PUMP_THRIVE       ;
  Pumps[2] = PUMP_MICRO        ;
  Pumps[3] = PUMP_BLOOM        ;
  Pumps[4] = PUMP_GROW         ;
  Pumps[5] = PUMP_NECTAR       ;
  Pumps[6] = PUMP_WET          ;
  Pumps[7] = PUMP_PEROXIDE     ;

  Sensors[0] = LEVEL_MIX_LOW      ;
  Sensors[1] = LEVEL_MIX_MID      ; 
  Sensors[2] = LEVEL_MIX_HIGH     ;
  Sensors[3] = LEVEL_RES_1_LOW    ;
  Sensors[4] = LEVEL_RES_1_MID    ;
  Sensors[5] = LEVEL_RES_1_HIGH   ;
  Sensors[6] = LEVEL_RES_2_LOW    ;
  Sensors[7] = LEVEL_RES_2_MID    ;
  Sensors[8] = LEVEL_RES_2_HIGH   ;

  Serial.println("CONFIGURED FORMULAS;");
  for (int i = 0; i < 9; i++ ) {
    if (Formulas[i].doses) {
      Serial.print("FORMULA: "); Serial.println(Formulas[i].name.characters); 
      for (int j = 0; j < 7; j++) {
        if (Formulas[i].pumps[j].pin) {
          Serial.print("PUMP: "); Serial.print(Formulas[i].pumps[j].name.characters);
          Serial.print(" ON PIN: "); Serial.print(Formulas[i].pumps[j].pin);
          Serial.print(" DUR: "); Serial.print(Formulas[i].run_times[j]);
          Serial.print(" MIX: "); Serial.print(Formulas[i].mix_times[j]);
          Serial.print(" REST: "); Serial.println(Formulas[i].rest_times[j]);
        }
      }
    }
  }
  
}

So you have five pumps
PUMP_SILICA,
PUMP_THRIVE,
PUMP_MICRO,
PUMP_BLOOM,
PUMP_GROW

which do all the exact same process ? Just with different timings?