Easy Arduino Menus for Rotary Encoders
Intro: Easy Arduino Menus for Rotary Encoders
Rotary encoders with centre-push buttons are cool input hardware for projects, especially if you want to be able to scroll through menus and select options. Hopefully this Instructable will give you the confidence to try a basic menu system out and combine it with a rotary encoder to give a professional feel to your next project!
Why menus and rotary encoders need code
I wanted to have a menu in an upcoming project and use a rotary encoder with a centre push button as my input hardware. This is really similar to the LCD controllers for RAMPS and Arduino MEGA 3D printers. The rotary encoder will allow me to scroll through the menu options, i.e. navigate the menu, select sub-menus and also change values within sub-menus/settings - very versatile for one hardware interface! A microcontroller is needed to manage all of this and microcontrollers need instructions, AKA code!
Other options
The problem I had with existing Arduino menu libraries and menu code is that for simple menus they were overly complicated. Another drawback of many alternatives was that the code was designed for LCD screens and momentary push buttons, not rotary encoders and adaptable to other display outputs. These menus were geared around selecting between a small number of modes and incrementing values relatively slowly. We know that rotary encoders are a great hardware input option because they afford relatively fast input value changes while retaining fine control at slow speed. I wanted to write code which would allow unambiguous top level menu navigation but also allow you to quickly scroll through a large range of values within each sub-menu/setting, exploiting the strengths of the rotary encoder.
The approach
I decided to follow some advice to use if() statements for a simple menu structure and keep it sketch-based. The resultant code builds on my previous Instructable which sought to reliably read the rotation pulses and direction. Please check it out for background.
In this sketch, we add the reading of the centre push button on the rotary encoder shaft, using code that Nick Gammon developed to record button state changes with debouncing and without relying on the Arduino's delay() function that prevents the microcontroller from executing other code and would potentially introduce noticeable delay in our sketch, e.g. slow display refresh rates. Button state change code is much more useful than just reading digital logic high or low when using a button to select something once, like a menu option, as it can help you prevent unintentional multiple selections for each button press.
Let's take a look at what you need to set up to use this example code in Step 1.
STEP 1: Preparation
If you haven't yet, please see my other Instructable on rotary encoder reading to find out how to set up your hardware and Arduino IDE software.
Hardware
The additional hardware connections you need to make use of centre push button are shown in the pictures. I used Fritzing to draw the diagram but it didn't have a rotary encoder component which represented the most likely pin layout, so just use that diagram in conjunction with the notes and look at the photo of the rotary encoder to see what you are more likely to be looking for in terms of rotary encoder pin layout.
One of the two pins on one side of the rotary encoder (as opposed to the side with three pins) needs to be connected to ground and another to a digital pin on the Arduino. I have used D4 for the example sketch. If you choose a different pin, don't forget to change the value of buttonPin in the sketch.
Next comes the code in Step 2.
STEP 2: Code
This is the code. By looking at the structure and the comments I hope you will find it easy to adapt for your specific needs!
/*******Interrupt-based Rotary Encoder Menu Sketch******* * by Simon Merrett, based on insight from Oleg Mazurov, Nick Gammon, rt and Steve Spence, and code from Nick Gammon * 3,638 bytes with debugging on UNO, 1,604 bytes without debugging */ // Rotary encoder declarations 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 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 byte 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 byte 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 // Button reading, including debounce without delay function declarations const byte buttonPin = 4; // this is the Arduino pin we are connecting the push button to byte oldButtonState = HIGH; // assume switch open because of pull-up resistor const unsigned long debounceTime = 10; // milliseconds unsigned long buttonPressTime; // when the switch last changed state boolean buttonPressed = 0; // a flag variable // Menu and submenu/setting declarations byte Mode = 0; // This is which menu mode we are in at any given time (top level or one of the submenus) const byte modeMax = 3; // This is the number of submenus/settings you want byte setting1 = 0; // a variable which holds the value we set byte setting2 = 0; // a variable which holds the value we set byte setting3 = 0; // a variable which holds the value we set /* Note: you may wish to change settingN etc to int, float or boolean to suit your application. Remember to change "void setAdmin(byte name,*BYTE* setting)" to match and probably add some "modeMax"-type overflow code in the "if(Mode == N && buttonPressed)" section*/ void setup() { //Rotary encoder section of 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) 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) // button section of setup pinMode (buttonPin, INPUT_PULLUP); // setup the button pin // DEBUGGING section of setup Serial.begin(9600); // DEBUGGING: opens serial port, sets data rate to 9600 bps } void loop() { rotaryMenu(); // carry out other loop code here } void rotaryMenu() { //This handles the bulk of the menu functions without needing to install/include/compile a menu library //DEBUGGING: Rotary encoder update display if turned if(oldEncPos != encoderPos) { // DEBUGGING Serial.println(encoderPos);// DEBUGGING. Sometimes the serial monitor may show a value just outside modeMax due to this function. The menu shouldn't be affected. oldEncPos = encoderPos;// DEBUGGING }// DEBUGGING // Button reading with non-delay() debounce - thank you Nick Gammon! byte buttonState = digitalRead (buttonPin); if (buttonState != oldButtonState){ if (millis () - buttonPressTime >= debounceTime){ // debounce buttonPressTime = millis (); // when we closed the switch oldButtonState = buttonState; // remember for next time if (buttonState == LOW){ Serial.println ("Button closed"); // DEBUGGING: print that button has been closed buttonPressed = 1; } else { Serial.println ("Button opened"); // DEBUGGING: print that button has been opened buttonPressed = 0; } } // end if debounce time up } // end of state change //Main menu section if (Mode == 0) { if (encoderPos > (modeMax+10)) encoderPos = modeMax; // check we haven't gone out of bounds below 0 and correct if we have else if (encoderPos > modeMax) encoderPos = 0; // check we haven't gone out of bounds above modeMax and correct if we have if (buttonPressed){ Mode = encoderPos; // set the Mode to the current value of input if button has been pressed Serial.print("Mode selected: "); //DEBUGGING: print which mode has been selected Serial.println(Mode); //DEBUGGING: print which mode has been selected buttonPressed = 0; // reset the button status so one press results in one action if (Mode == 1) { Serial.println("Mode 1"); //DEBUGGING: print which mode has been selected encoderPos = setting1; // start adjusting Vout from last set point } if (Mode == 2) { Serial.println("Mode 2"); //DEBUGGING: print which mode has been selected encoderPos = setting2; // start adjusting Imax from last set point } if (Mode == 3) { Serial.println("Mode 3"); //DEBUGGING: print which mode has been selected encoderPos = setting3; // start adjusting Vmin from last set point } } } if (Mode == 1 && buttonPressed) { setting1 = encoderPos; // record whatever value your encoder has been turned to, to setting 3 setAdmin(1,setting1); //code to do other things with setting1 here, perhaps update display } if (Mode == 2 && buttonPressed) { setting2 = encoderPos; // record whatever value your encoder has been turned to, to setting 2 setAdmin(2,setting2); //code to do other things with setting2 here, perhaps update display } if (Mode == 3 && buttonPressed){ setting3 = encoderPos; // record whatever value your encoder has been turned to, to setting 3 setAdmin(3,setting3); //code to do other things with setting3 here, perhaps update display } } // Carry out common activities each time a setting is changed void setAdmin(byte name, byte setting){ Serial.print("Setting "); //DEBUGGING Serial.print(name); //DEBUGGING Serial.print(" = "); //DEBUGGING Serial.println(setting);//DEBUGGING encoderPos = 0; // reorientate the menu index - optional as we have overflow check code elsewhere buttonPressed = 0; // reset the button status so one press results in one action Mode = 0; // go back to top level of menu, now that we've set values Serial.println("Main Menu"); //DEBUGGING } //Rotary encoder interrupt service routine for one encoder pin 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 } //Rotary encoder interrupt service routine for the other encoder pin 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 } // end of sketch!
I have used "DEBUGGING" at the start of every comment on any line which isn't critical for the menu to do its thing. If you are happy with the menu's function, you might want to comment out or delete these lines for smaller compiled sketch size.
Be aware that a key part of menu navigation is feedback to the user while they are scrolling through the option and settings. Therefore if you choose not to include the DEBUGGING lines, you should probably use another visual indicator (e.g. LCD text display, LEDs) that encoder inputs are navigating the menu and changing settings.
If I comment out the DEBUGGING lines (noting that some visual feedback would still be needed for menu navigation) the compiled code is around 1,650 bytes for the Arduino Uno, hopefully leaving plenty of room on your ATMEGA328P for the more exciting parts of your sketch!
Go to Step 3 to find out how the menu system works.
STEP 3: Operation and Conclusion
Operation
If you open the serial monitor in Arduino after uploading this sketch, and start to turn the encoder shaft, you should see the top level menu rotating through the number of sub-menus/options you have (limited using the modeMax variable). If you press the centre-push button you will see that the mode/sub-menu you have scrolled to is selected and now you have free reign to scroll through 0-255 values in that sub-menu. Now, if you press the centre-push button you will set that value to setting1 or setting2 or setting3 etc. The Arduino automatically and instantaneously returns you to the top level menu once this has happened.
While powered up, the Arduino remembers what you set each setting to and if you go back to the sub-menu for a setting you have already set a value to, it will start your encoder adjustments from the last value you chose!
Conclusion
I set out to write some sketch-based code that would allow rotary encoders to navigate a basic menu for Arduinos. I also tried to make it readable so that, unlike some alternatives, someone could see the menu structure and know what changes to the code they would need to make to tailor the menu to their needs.
This code is basic and generic, specifically to demonstrate the functionality while being easily adaptable to your own application. It uses the serial monitor as a basic debugging tool which also removes the need for a separate display if you want to see how the code works. I hope you find it useful and are inspired to edit, adapt and improve it!
Please share anything you do with it in the comments!
103 Comments
medvenx 2 years ago
bowlerhatman 3 years ago
Thanks, Frazer
Spa2036 4 years ago
Thank you very much for posting your code and explications on how to use and implement this. I am working on automating an incubator my father build 40 years ago with an arduino Nano, I am in need of a menu. I have tried a lot of menu's for Arduino but they were too slow, bulky in code, not running, didn't want to function with Serial initialized and so on. Found your code and I have been playing around with it.
On my oled display I have standard reading of Temp and Humidity and Days in process. I am trying to activate the menu with the push of the rotary switch and than make the selections for what specy to incubate and show in a second menu the parameters and eventualy modify some of the values, start initializing the incubator ( tamp and humidity) and start the process..
My problem is that I can access the menu with a press of the button, but I am no longer able to return to the main screen, or the menu is visible only when I press the button. I am missing something but I don't know what. If you or anyone else can give me a push in the right direction I will be a happy man. Thanks again.
SimonM83 4 years ago
Spa2036 4 years ago
Thanks again, and if you need more specifications please let me know.
SimonM83 4 years ago
1) you don't appear to have any button debouncing in your main loop (although it is in the menu code, so can be copied from there or reused...), so you could be getting very confusing button input logic received by the microcontroller as a result. Here's my go-to for that: http://www.gammon.com.au/switches
2) You only seem to call openMenu() if you press the button, and that is where the encoderPos printing/updating happens, so I don't know if that's an issue. The updating of your setting variables from the encoder should be allowed to continue even if the button isn't pressed, once you have entered the menu, no?
3) You only seem to update the display when you enter the menu? But I haven't read it in more detail, just trying to give ideas about where to look.
4) Is your button logic consistent between the menu states? e.g. 0 is select in main loop, is that the same in e.g. the openMenu() function? Just wondering if it is entering an endless loop because of the state that variable leaves the functions in.
Just some ideas - I apologise if they are not useful.
Spa2036 4 years ago
1. Ok no problem to add some debouncing. I guess 10 ms will be enough.
2. Yes I open the menu by pressing the encoder switch. Then I need to select the species and some sub menu's related to that species. The encoder shows the options and the switch selects the option.
3. Well I made a mess of the menu and started all over again for the xxth time. So I might have erased a little too much of my code.
4. openMenu() function is when I press the switch when the main program is running. So the top level menu is "0". After I have made a selection in the menu or not, I need to return to the main screen that indicates Temp Humidity and days ( if started).
I am thinking about doing something with switch and case, because there I have a break command to quit the menu and I need some submenu's. Also I am in need of a menu submenu counter that when break is used I return to the previous (sub)menu. I also need and a maximum possible menu option selection per subMenu. Ok back to the drawing board. Thanks again!
Boomer48 4 years ago
If I want to step through a function (like setting hours, then minutes of a clock) I detect the difference between long and short switch presses. For example, short presses to increment the hours, then a long press (typically one second) to move to the minutes setting. Hope this helps.
Spa2036 4 years ago
For the debouncing it is easy to play with the variables as it is set as a variable.
Thank you for your input on how you handle switch presses. This certainly helps.
SimonM83 4 years ago
Boomer48 4 years ago
Spa2036 4 years ago
Boomer48 4 years ago
SimonM83 4 years ago
Spa2036 4 years ago
Boomer48 4 years ago
If I want to step through a function (like setting hours, then minutes of a clock) I detect the difference between long and short switch presses. For example, short presses to increment the hours, then a long press (typically one second) to move to the minutes setting. Hope this helps.
bowlerhatman 4 years ago
Having fixed that though, the code still doesn't behave as you describe, but I'm having a hard time figuring out why. Firstly, when I start it up, rotating the encoder doesn't scroll through the menu options, I get no serial output. If I press the button, I get:
Button closed
Mode selected: 1
Mode 1
0
Button opened
Rotating the encoder then still doesn't give any output.
Combinations of holding the button and rotating, or giving multiple presses and rotating, seem to yield almost random results.
Other than the attach interrupt syntax, and making sure the pin assignments are correct, I haven't changed your code at all. Do you have any advice that could help me get this working? From your description of the function, it does exactly what I need!
ETA: Also, when I am able to see the encoder changing a value, it only goes in one direction. My hardware is fine, as other sketches are able to see it go both ways. Hardware is a Leonardo, and I have debouncing caps on the encoder. pinA = 2, pinB = 3, buttonPin = 1 (all updated in the code). In addition, I've tried your first set of code, with and without the attachInterrupt modification, and get no output at all.
Thanks,
Fraz
SimonM83 4 years ago
My inclination would be to try connecting a multimeter in continuity mode (or oscilliscope if you have one) to ensure the voltage logic between detents is as expected with your current wiring.
bowlerhatman 4 years ago
Can you expand a little on this:
...
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
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)
reading == B00001100 && aFlag (and the other three instances)
...
I'm a little confused about why digital pins 2 and 3 are declared for the encoder pins, but then pins 0 and 1 are attached as the interrupt, but then the 3rd and 4th bits on PORTD are polled?
My wiring is attached. The Leonardo is represented by that connector, but the pins numbers match up. I have adjusted buttonPin to 1 to suit my connections.
I have tried adjusting the PIND section to:
reading = PIND & 0x3
and shifting the two polled bits down by two, thus:
reading == B00000011 && aFlag (and so on)
But still have no joy.
I've got the encoder, wired as is, running successfully with this library: https://www.pjrc.com/teensy/td_libs_Encoder.html
So I'm confident that the wiring is not the issue, but I'm clearly missing something else. Beyond the port assignments, I can't see anything in the code that should vary much from 'duino to 'duino?
Thank you for your help,
Frazer
SimonM83 4 years ago
Did you check against this tip in that instructible:
"Make sure you find out where the detent state is for your encoder and adapt your code accordingly. My example uses an encoder whose pins are both disconnected from ground and are pulled high by their respective input pullup resistors. This drives my selection of a RISING interrupt. If both pins were connected to ground when at detent, they would need code which was looking for FALLING pin voltage."?
Chips come with pins assigned in banks, called ports. Arduino assigns its own pin numbers to particular pins, so that they can match the peripheral functions across chips and achieve some commonality that e.g. PWM will be available on D5 for as many Arduinos as possible (to enable analogWrite() to be portable between Arduinos). Finally, there are hardware interrupts. The ATMEGA328P has two, starting at 0 and ending with 1. The ATMEGA32U4 appears to have 4 hardware interrupts (0-4). It just so happens that port pin number, arduino pin number and hardware interrupt number have very little to do with each other. Does that help?
If you are sure there are no other components involved in your wiring, the schematic looks good, as long as it is correct! What value capacitors have you used? Do you have a spec for your encoder - model number or photo?
Without going in to the pin assignments, port masking and interrupt setting, I think you would do well to firstly check that your encoder is contacting each pin to ground just using some form of continuity checker. If you don't have a multimeter, you could use a battery, suitable value resistor and LED to check this.
Once you have confirmed that you are definitely seeing both pin A and pin B make AND break contact with pin C for each detent, make note of whether the connection is open or closed at detent.
Then I think you should set up a simple sketch with only one of the A or B pins connected and set up the hardware interrupt. Then just make sure you can get e.g. Serial monitor or LED flash from a separate GPIO when the interrupt fires. Once you have this, move on to add the other pin B or A and its respective interrupt. Confirm now that both interrupts are firing.
From a quick look at the pinout of the Leonardo, you need to do something like this in setup (I'll leave it to you to set some suitable volatile variables to use as flags in your ISR):
pinMode(2, INPUT_PULLUP);
pinMode(3, INPUT_PULLUP);
attachInterrupt(0,yourRoutineA,CHANGE);
attachInterrupt(1,yourRoutineB,CHANGE);
Then write your ISRs called yourRoutine() to change the value of a flag variable.
Get your main loop to check for changes to the flag variable, reset it and notify you somehow (LED/serial monitor). See how you get on.