Introduction: Interactive Menus for Your Project With a Display and an Encoder

About: We make Embedded Design tools, boards, sensors, peripherals, DIY kits and awesome tutorials. We also help companies and individuals prototype embedded electronics.

Ever wondered about how simple an User Interface (UI) for your next electronics hack or project be? Indeed, and you should also seen the humble rotary encoder and a Display as UI for 3D printer or other gadgets. In this tutorial we will look at building such a system with a Nokia 5110 display and a rotary encoder KY-040.

You may use any other character or graphics displays as well as other encoder models. Instead making the code generic, we will start building an UI for a digital clock. On it's home screen it will show time. The pushing of the select switch on the encoder will display the Menu. The Menu in turn will have numerous setting for time, date, time format (12 Hour vs 24 Hour ), time Zone etc..,

[[Update: Need your help and support to continue making open source hardware and tutorials. We are crowdfunding an ESP32 based WiFi, Bluetooth dev board at breakthrough price. Get one at our Crowd Supply Campaign ]]

[[update: The code rendering is broken, please check a neat version of the tutorial on on our website.]]

Step 1: Components Required :

Following list components required for creating menu's.

Step 2: Hookup :

The basic hookup will remain same during the entire tutorial. We will test the encoder first and then the display. You may want to hook-up all the things at once or do it step by step. The encoder first and then the display. I would recommend doing it step by step.

Step 3: Encoder Basics :

Encoders are great input devices which have infinite travel in both clockwise(CW) and anti-clockwise directions(CCW). What that essentially means is that the input range for your gizmo can be decided in software. As in case of your clock, the user may have to transverse a menu of say 8 items. The range for the Menu will be 0 to 7 in both directions . However to input time from the user, the hours will be (0 to 23), minutes and seconds will be (0 to 60). Encoders will help us do all of the above. Apart from this the encoder we will be using also has a select switch. Which can be used to select menu items or confirm an action.

I would highly recommend you to go through the Improved Arduino Rotary Encoder Reading instructable to understand the types of encoders, the internal mechanism and all the related terminology.
I started out with hooking up just the encoder with Arduino and printing the message on the terminal. The code is brilliantly written. It simply increments a variable called encoderPos if the encoder is turned CW and decremented if turned CCW.

The code uses to Interrupt pins 2 and 3 on Arduino. These are setup to trigger on a rising edge of the pulse. The interrupts trigger functions PinA() and PinB() for interrupts on pins 2 and 3 respectively which eventually result in decremented or increment the count. Since these are interrupt functions these aren't called anywhere in the setup() and loop() functions. Also notice the use of keyword volatile for the variables used in the Interrupt functions. We will cover more on interrupts in some other tutorial. However note that this code will only work if the pins 2 and 3 are used. Because other pins on Atmega328 will not have interrupt handling capability.


The code below is all that is required to read the encoder position as well as the select switch.

The code below is broken, please find the github gist with the link below:

Test code on github

<p>/*******Interrupt-based Rotary Encoder Sketch*******</p><p><br>by Simon Merrett, based on insight from Oleg Mazurov, Nick Gammon, rt, Steve Spence
modified at EE to include the select switch
Tutorial at:</p><p><a href="http://exploreembedded.com/wiki/Interactive_Menus_for_your_project_with_a_Display_and_an_Encoder" rel="nofollow">http://exploreembedded.com/wiki/Interactive_Menus_...</a>
*/</p><p>static int pinA = 2; // Our first hardware interrupt pin is digital pin 2
static int pinB = 3; // Our second hardware interrupt pin is digital pin 3
static int selectSwitch = 9; //The select switch for our encoder.</p><p>volatile byte aFlag = 0; // let's us know when we're expecting a rising edge on pinA to signal that the encoder has arrived at a detent
volatile byte bFlag = 0; // let's us know when we're expecting a rising edge on pinB to signal that the encoder has arrived at a detent (opposite direction to when aFlag is set)
volatile uint16_t encoderPos = 0; //this variable stores our current value of encoder position. Change to int or uin16_t instead of byte if you want to record a larger range than 0-255
volatile uint16_t oldEncPos = 0; //stores the last encoder position value so we can compare to the current reading and see if it has changed (so we know when to print to the serial monitor)
volatile byte reading = 0; //somewhere to store the direct values we read from our interrupt pins before checking to see if we have moved a whole detent</p><p>void setup() {
  pinMode(pinA, INPUT_PULLUP); // set pinA as an input, pulled HIGH to the logic voltage (5V or 3.3V for most cases)
  pinMode(pinB, INPUT_PULLUP); // set pinB as an input, pulled HIGH to the logic voltage (5V or 3.3V for most cases)
  pinMode(selectSwitch, INPUT_PULLUP);
  attachInterrupt(0,PinA,RISING); // set an interrupt on PinA, looking for a rising edge signal and executing the "PinA" Interrupt Service Routine (below)
  attachInterrupt(1,PinB,RISING); // set an interrupt on PinB, looking for a rising edge signal and executing the "PinB" Interrupt Service Routine (below)
  Serial.begin(115200); // start the serial monitor link
}</p><p>void PinA(){
  cli(); //stop interrupts happening before we read pin values
  reading = PIND & 0xC; // read all eight pin values then strip away all but pinA and pinB's values
  if(reading == B00001100 && aFlag) { //check that we have both pins at detent (HIGH) and that we are expecting detent on this pin's rising edge
    encoderPos --; //decrement the encoder's position count
    bFlag = 0; //reset flags for the next turn
    aFlag = 0; //reset flags for the next turn
  }
  else if (reading == B00000100) bFlag = 1; //signal that we're expecting pinB to signal the transition to detent from free rotation
  sei(); //restart interrupts
}</p><p>void PinB(){
  cli(); //stop interrupts happening before we read pin values
  reading = PIND & 0xC; //read all eight pin values then strip away all but pinA and pinB's values
  if (reading == B00001100 && bFlag) { //check that we have both pins at detent (HIGH) and that we are expecting detent on this pin's rising edge
    encoderPos ++; //increment the encoder's position count
    bFlag = 0; //reset flags for the next turn
    aFlag = 0; //reset flags for the next turn
  }
  else if (reading == B00001000) aFlag = 1; //signal that we're expecting pinA to signal the transition to detent from free rotation
  sei(); //restart interrupts
}</p><p>void loop(){
  if(oldEncPos != encoderPos) {
    Serial.println(encoderPos);
    oldEncPos = encoderPos;
  }</p><p>  // The select switch is pulled high, hence the pin goes low if the switch is pressed. 
  if(digitalRead(selectSwitch)==0)
  {
    Serial.println("Key Pressed");
    delay(5); // wait for debounce to get over
  }
}</p>

Step 4: Output on the Terminal :

The output when the encoder is turned CW and CCW and when the switch is pressed.

Step 5: Display Basics :

As said earlier I will be using a Nokia 5110 84x48 pixels graphic display for this tutorial. I will be using the well written Adafruit display and graphics libraries. The display is instantiated with the following pins.

<p>// Software SPI: </p><p>// pin 7 - Serial clock out (SCLK) </p><p>// pin 6 - Serial data out (DIN) </p><p>// pin 5 - Data/Command select (D/C) </p><p>// pin 4 - LCD chip select (CS)</p><p> // pin 8 - LCD reset (RST) </p>

You may use the sample sketch that comes with the library to test if the display is indeed working with the setup.

Step 6: The Code :

The image below shows, exactly what we are trying to achieve. The Menu is stored in the memory as an array of character strings. The display can show 6 lines at one time. We would like to scroll through the menu, giving user an option to select one item at a time.

int displayMenu(char menuInput[][maxItemSize], int menuLength)

Takes the 2D character array as input shows it on the display and returns the item selected. Then we use a case switch statement to execute the next action. The code can be further expanded for all cases and user inputs can be validated to a certain range like 0 to 60 for minutes and seconds.

Code below is broken find the neat code on github gist

<p>/*<br>|
|  For tutorial visit:
|   http://exploreembedded.com/wiki/index.php?title=I... 
|
*/</p><p>#include 
#include 
#include </p><p>#define SECS_PER_MIN  (60UL)
#define SECS_PER_HOUR (3600UL)
#define SECS_PER_DAY  (SECS_PER_HOUR * 24L)</p><p>/* Useful Macros for getting elapsed time */
#define numberOfSeconds(_time_) (_time_ % SECS_PER_MIN)  
#define numberOfMinutes(_time_) ((_time_ / SECS_PER_MIN) % SECS_PER_MIN) 
#define numberOfHours(_time_) (( _time_% SECS_PER_DAY) / SECS_PER_HOUR)
#define elapsedDays(_time_) ( _time_ / SECS_PER_DAY)  </p><p>#define maxItemSize 10</p><p>const int itemsPerScreen = 7;
const int fontSize = 8;</p><p>static int pinA = 2; // Our first hardware interrupt pin is digital pin 2
static int pinB = 3; // Our second hardware interrupt pin is digital pin 3
static int enSW = 9;</p><p>volatile byte aFlag = 0; // let's us know when we're expecting a rising edge on pinA to signal that the encoder has arrived at a detent
volatile byte bFlag = 0; // let's us know when we're expecting a rising edge on pinB to signal that the encoder has arrived at a detent (opposite direction to when aFlag is set)
volatile uint16_t encoderPos = 0; //this variable stores our current value of encoder position. Change to int or uin16_t instead of byte if you want to record a larger range than 0-255
volatile uint16_t
oldEncPos = 0; //stores the last encoder position value so we can compare to the current reading and see if it has changed (so we know when to print to the serial monitor)
volatile byte reading = 0; //somewhere to store the direct values we read from our interrupt pins before checking to see if we have moved a whole detent</p><p>// Software SPI (slower updates, more flexible pin options):
// pin 7 - Serial clock out (SCLK)
// pin 6 - Serial data out (DIN)
// pin 5 - Data/Command select (D/C)
// pin 4 - LCD chip select (CS)
// pin 8 - LCD reset (RST)
Adafruit_PCD8544 display = Adafruit_PCD8544(7, 6, 5, 4, 8);</p><p>//Declare the Menus you need. 
char menu[][maxItemSize] ={"Date","Time","Alarm","Format","Zone","Daylight","BACK"};
// Only 2 sub-menus are shown. You can add as many as you wish.
char subMenu0[][maxItemSize] = {"Date", "Month", "Year", "BACK"};
char subMenu1[][maxItemSize] = {"Hours", "Min", "Secs", "BACK"};</p><p>int cnt = 0; 
int itemSelected, subMenuSelected;
int itemsToDisplay=0;
unsigned long startmillis, milliSecs,mins,secs,hour; 
void setup() {</p><p>  display.begin();
  pinMode(enSW, INPUT_PULLUP); 
  pinMode(pinA, INPUT_PULLUP); 
  pinMode(pinB, INPUT_PULLUP);
  attachInterrupt(0,PinA,RISING);
  attachInterrupt(1,PinB,RISING); 
  Serial.begin(115200); 
  display.setContrast(100);
  display.display();
  delay(2000);
  display.clearDisplay();  
  //display.setTextSize(fontSize/8);
  display.setTextColor(BLACK);</p><p>  startmillis = millis();</p><p>}</p><p>void loop() {</p><p>  //Show time on the default Screen.
  display.setTextSize(2);
  display.clearDisplay();
  display.setCursor(20,16);
  time(millis() / 1000);
  display.display();
  display.setTextSize(fontSize/8);
  
 // Enter the settings menu if select Switch is pressed
 if(digitalRead(enSW)==0){
    while(digitalRead(enSW)==0);//wait till switch is released.
    
    itemSelected = displayMenu(menu, sizeof(menu)/maxItemSize); 
    switch(itemSelected){
        
         case 0: 
                Serial.print("calling submenu");
                subMenuSelected = displayMenu(subMenu0, sizeof(subMenu0)/maxItemSize); 
                break;
  
         case 1: 
                subMenuSelected = displayMenu(subMenu1, sizeof(subMenu1)/maxItemSize); 
                break;</p><p>         //you may include other cases as required!        
  
         default: break;       
              
     }</p><p>   //if the selected option is BACK, we will go straight to the main menu.  
   if(itemSelected!= 6){
     switch(subMenuSelected){
          // Only case 0 is shown. Also the user input is not saved anywhere, which might be required in real use-case.
          case 0: display.clearDisplay();
                  display.setCursor(0,0);
                  display.println("Date");
                  display.setCursor(28,16);
                  display.println(encoderPos);
                  display.display();
                  
                  while(digitalRead(enSW)){
                        display.setCursor(0,0);
                        display.clearDisplay();
                        display.println("Date");
                  
                        display.setCursor(16,16);
                        display.println(encoderPos);
                        display.display(); 
                  }  </p><p>                  while(digitalRead(enSW)==0);
                  break;
          
          default:break;       
         
     }</p><p>   }   
    
 }</p><p> 
}</p><p>// This function accepts the a 2D character array and its length and display it on the screen.
// The function montiers the encoder position and moves the menu up and down.
// It returns the selected menu option to the calling functions
     
int displayMenu(char menuInput[][maxItemSize], int menuLength){
    int curPos,startPos, endPos;
 do{ 
      
            startPos = encoderPos%menuLength;    
            Serial.println("startPos:");
            Serial.println(startPos);
            display.clearDisplay();
      
            endPos = itemsPerScreen;
            
            if(menuLength < itemsPerScreen)
            {
                 endPos = menuLength -startPos;  
            }
      
            if((menuLength-startPos)</p><p>            Serial.print("endPos:");
            Serial.println(endPos);
      
            for(cnt = 0; cnt<=(endPos-1); cnt++){
                if(cnt == 0)
                {
                  display.setCursor(0,0);
                  display.print("->");
                }
                
                display.setCursor(16, cnt*fontSize);
                display.println(menuInput[cnt+startPos]);
                Serial.println(menuInput[cnt+startPos]);   
            }
            
            display.display();
            cnt = 0;</p><p>          if(oldEncPos != encoderPos) {
            oldEncPos = encoderPos;   
          }  
 }while(digitalRead(enSW));
 while(digitalRead(enSW)==0); //wait till switch is reseleased 
 return startPos;
}</p><p>/*******************************Utility Functions *******************************/
void time(long val){  
int days = elapsedDays(val);
int hours = numberOfHours(val);
int minutes = numberOfMinutes(val);
int seconds = numberOfSeconds(val);</p><p> // digital clock display of current time
// display.print(days,DEC);  
// printDigits(hours);  
 display.print(minutes);
 printDigits(seconds);
 display.println();  
 
}</p><p>void printDigits(byte digits){
 // utility function for digital clock display: prints colon and leading 0
 display.print(":");
 if(digits < 10)
   display.print('0');
 display.print(digits,DEC);  
}</p><p>void PinA(){
  cli(); //stop interrupts happening before we read pin values
  reading = PIND & 0xC; // read all eight pin values then strip away all but pinA and pinB's values
  if(reading == B00001100 && aFlag) { //check that we have both pins at detent (HIGH) and that we are expecting detent on this pin's rising edge
    encoderPos --; //decrement the encoder's position count
    bFlag = 0; //reset flags for the next turn
    aFlag = 0; //reset flags for the next turn
  }
  else if (reading == B00000100) bFlag = 1; //signal that we're expecting pinB to signal the transition to detent from free rotation
  sei(); //restart interrupts
}</p><p>void PinB(){
  cli(); //stop interrupts happening before we read pin values
  reading = PIND & 0xC; //read all eight pin values then strip away all but pinA and pinB's values
  if (reading == B00001100 && bFlag) { //check that we have both pins at detent (HIGH) and that we are expecting detent on this pin's rising edge
    encoderPos ++; //increment the encoder's position count
    bFlag = 0; //reset flags for the next turn
    aFlag = 0; //reset flags for the next turn
  }
  else if (reading == B00001000) aFlag = 1; //signal that we're expecting pinA to signal the transition to detent from free rotation
  sei(); //restart interrupts
}</p>

Step 7: References :

Going further, there are lots of improvements that can be done. Like inverted first row instead of simple arrow. The validation of user input range and other things. This can be best done in a end project.

If you've made a similar menu with the display and an encoder, do let us know in the comments!

Step 8: Support

You may get these parts from anywhere you wish, however getting these from our site www.exploreembedded.com, will help us to build these awesome tutorials, Thanks!