Before we look at a simple strategy for managing the Arduino user interface lets look at the 'RC Car/Go Kart Lap Timer' as an example of a typical Arduino project with a user interface.
The RC Car/Go Kart Lap Timer Project
The Physical Interface -
We have an LCD which is used to display several different sets of information.
We have four buttons - Up, Down, Ok and Cancel which are used to navigate through the information and operate upon it.
The system operates in the following modes -
Idle - this is the start up mode and shows summary information about the number of sessions, the number of laps and the number of laps that can be recorded with the remaining space.
Record - In this mode the system creates a new session and begins recording laps to the session
Session Summary - In this mode the user can use the up/down buttons to review session information and the Ok button to enter Session Review mode or the Cancel button to return to idle
Session Review - In this mode the up/down buttons are used to cycle through the individual laps within a session.
Additional modes are provided for clearing all laps and sessions and for deleting individual sessions.
The challenge we face is that in each mode (or context if you prefer) the purpose of the buttons can change, for example to access the 'delete all sessions' option the user can press the cancel button on the start up screen. This will take the user to a new context where in order to confirm the delete operation the user must then press OK, in this new context the cancel button means, cancel this current operation and take me back to where I was.
From an end user perspective this is a reasonable interface, cancel always means 'Clear this' or 'Get me out of here', whereas OK always means 'Go to this' or 'Yes, I really do want to do this'.
Hold this thought for a moment - within our code we need to manage multiple situations where the same buttons must execute different code.
For a very limited user interface such as an on/off toggle it is enough to simply track a state, for example in the 'radio controlled car - child mode' project elsewhere on this blog, there is a mode variable which is either set to child mode or dad mode. In this simple case there are no other controls and so this approach is entirely sufficient.
Further project details here -
http://rcarduino.blogspot.com/2012/01/traction-control-part-13-we-have.html
switch(nMode)
{
case DAD_MODE:
// Do or show this
break;
case CHILD_MODE:
// Do or show that
break;
}
{
case DAD_MODE:
// Do or show this
break;
case CHILD_MODE:
// Do or show that
break;
}
As a rule this approach is sufficient when there is no need to repurpose the controls to reflect the new mode and also when each mode is stateless. Take note of the term 'stateless' we will come back to it.
The child mode project, has two modes and one input to toggle between them = 2*1 = 2 possible interactions. If this is as far as your projects go, you can stop reading here, but if you ever want to add a data review mode, the option to clear part or all existing data or any other form of user interaction, read on.
It is tempting to try and extend this example to cope with the situation where there are multiple inputs and modes and your certainly welcome to try this -
switch(nMode)
{
case IDLE:
switch(nKeyPressed)
{
case IDLE:
switch(nKeyPressed)
{
case OK:
nMode = RECORD;
break;
case CANCEL:
nMode = CONFIRM_DELETE_SESSIONS;
break;
}
break;
case RECORD:
switch(nKeyPressed)
break;
case RECORD:
switch(nKeyPressed)
{
case OK:
// do nothing, keep recording
break;
case CANCEL:
// exit record mode back to idle
nMode = IDLE;
break;
}
break;
}
This is the approach I initially took when building the lap timer interface however it quickly gets out of hand.
The Lap Timer project, 5 Modes, 4 buttons = 5*4 = 20 Possible Interactions.
The typical approach is to nest switch statements inside switch statements. The outer statement manages the mode and then the inner statement manages the action for each key or button within the mode. The example code above covers only a small fraction of the modes and keys required for the lap timer and its already hard to read, it also does not actually do anything, at some point we still need to call the functions that will actually do the work. And thats where I got to thinking.
Functions
Functions are naturally hierarchical. In our Arduino code, loop is the head of the hierarchy, we can call other functions from loop but when they exit, they return to loop which is still exactly as it was when they left it.
Isn't that a bit like way most of us need our user interfaces to work ?
As an example in the lap timer I begin in an idle mode and can then enter 'session summary' and from there 'lap review'. If I exit lap review I want to fall back to 'session summary', it would also be neat if this mode was able to show me the session I was last looking at wouldnt it ?
Why introduce and have to manage a 'mode' variable when the hierarchical nature of functions naturally gives us the the flow we want ? I can't think of a single good reason to use a mode variable in place of this approach and it gets better.
Remember that term from earlier - 'stateless' ? well there is nothing better at describing the state of a function than the function itself. Why do we care ? well if we look at the preceding diagram, we want to give users the option to cancel out of the lap review screen and return to the session review screen, we also want to ensure that when they do they are presented with the session they last selected, thats the state.
Now go back and look at the example code that started to implement the Lap Timer interface using nested switch statements, where is the state ? There isn't any state, you need to code it alongside the 20 possible user interactions, I can't help but get the feeling that this is building mess on top of mess on top of mess.
So whats the alternative ? well very simply we can create each mode as a function and let the hierarchical and self describing nature of functions do the work for us.
In the case of the Lap Timer we have functions doIdle, doSessionReview, doLapReview, doRecord and doConfirmDelete. In each function we need a very simple version of the switch statement we tried to create earlier, but the beauty of this approach is the phrase 'a very simple version of the switch statement we tried to create' it really is so much easier to write, understand and maintain the entire user interface this way.
The advantages -
- Each state or mode is defined as a standalone function
- Each function describes its own state without additional variables or code to manage them
- The hierarchical nature of functions supports the hierarchical nature of many user interfaces without additional code or effort.
- Its clearer to read, understand, fix and maintain
- There is no overhead to maintain a redundant 'mode' variable.
What does it look like ?
A main loop might look something like this -
void loop()
{
// lets keep control of the loop
while(true)
{
// wait for a key command to tell us what to do
while((sKey = getKeys()) == KEY_NONE)
{
// do nothing
}
// I only need to consider what the keys do in this mode,
// I am by definition not in any other mode and so I have
// no concern what the keys might do if I were
switch(sKey)
{
// start recording
case KEY_OK:
{
// lets keep control of the loop
while(true)
{
// wait for a key command to tell us what to do
while((sKey = getKeys()) == KEY_NONE)
{
// do nothing
}
// I only need to consider what the keys do in this mode,
// I am by definition not in any other mode and so I have
// no concern what the keys might do if I were
switch(sKey)
{
// start recording
case KEY_OK:
// don't try and manage a mode and its state, just call a function that implements the mode
// when the function exits, we are not in the mode anymore so no need to keep track of the
// mode or its state. Keeping it simple, neat and efficient
doRecord();
break;
// delete all sessions
case KEY_CANCEL:
doConfirmDeleteSessions();
showTotals();
break;
// scroll through recorded session summaries
case KEY_UP:
case KEY_DOWN:
doShowSessionSummaries();
break;
}
// We will normally be here because cancel or ok was pressed to exit
// one of the other function so wait for the key to be released
waitForKeyRelease();
}
}
doRecord();
break;
// delete all sessions
case KEY_CANCEL:
doConfirmDeleteSessions();
showTotals();
break;
// scroll through recorded session summaries
case KEY_UP:
case KEY_DOWN:
doShowSessionSummaries();
break;
}
// We will normally be here because cancel or ok was pressed to exit
// one of the other function so wait for the key to be released
waitForKeyRelease();
}
}
This can be described with the following blue print -
1) Look for any events that might require us to change mode
2) Depending on the event, call a function that 'is the mode'
3) When the function we called exits, we are no longer in the mode, so we don't need to track it or its state here.
4) Lets get right back to the start and look for an event again
Each of the mode functions is implemented in exactly the same way -
//////////////////////////////////////////////////////////////////////////////////
//
// doShowSessionSummaries
//
//////////////////////////////////////////////////////////////////////////////////
void doShowSessionSummaries()
{
Serial.println("Entering doShowSessionSummaries");
short sCurrentSession = 0;
showCurrentSessionSummary(sCurrentSession);
short sKey = KEY_NONE;
boolean bFinished = false;
do
{
waitForKeyRelease();
switch(sKey)
{
case KEY_UP:
sCurrentSession = showCurrentSessionSummary(++sCurrentSession);
break;
case KEY_DOWN:
sCurrentSession = showCurrentSessionSummary(--sCurrentSession);
break;
case KEY_NONE:
break;
case KEY_CANCEL:
bFinished = true;
break;
case KEY_OK:
doLapScroll();
break;
}
}while(!bFinished);
Serial.println("Leaving doShowSessionSummaries");
}
//
// doShowSessionSummaries
//
//////////////////////////////////////////////////////////////////////////////////
void doShowSessionSummaries()
{
Serial.println("Entering doShowSessionSummaries");
short sCurrentSession = 0;
showCurrentSessionSummary(sCurrentSession);
short sKey = KEY_NONE;
boolean bFinished = false;
do
{
waitForKeyRelease();
switch(sKey)
{
case KEY_UP:
sCurrentSession = showCurrentSessionSummary(++sCurrentSession);
break;
case KEY_DOWN:
sCurrentSession = showCurrentSessionSummary(--sCurrentSession);
break;
case KEY_NONE:
break;
case KEY_CANCEL:
bFinished = true;
break;
case KEY_OK:
doLapScroll();
break;
}
}while(!bFinished);
Serial.println("Leaving doShowSessionSummaries");
}
The blue print is the same again here -
1) Check for an event that could cause us to change modes
2) Call a function that 'Is The Mode'
3) If the function exits, we are not in the mode anymore so don't need to track anything here
4) back to 1
The final case is quite interesting. If the user presses the button representing OK (case KEY_OK: in the example), we call doLapScroll to enter the lap review mode for the current session. The user can scroll back and forth through all the laps recorded for this session, however if they press cancel to exit they fall right back into the current mode exactly where they left it, even looking at the same session.
With a little bit of effort this approach - 'Functions for Modes' just works, it gives you mode management and state management without you having to explicitly code any of it.
Let me know what you think, could the explanation be clearer ? do you disagree with the approach or have a better alternative ?
Duane B