Hacking an analog input to support a rotary encoder.
2015.06.14 23:15
Hacking an analog input to support a rotary encoder.
Push button rotary encoders are popular input devices for making simple user interfaces.
Using a pushbutton rotary encoder requires 3 input pins on your microcontroller: 1 for the switch and 2 for the quadrature outputs.
However, in a recent application I had only 1 pin remaining on my MSP430G2553.
My only choice was to leverage that pin's analog input capabilities.
I first sketched out my requirements, then designed a circuit, measured the performance of my design and wrote microcontroller software to realize it.
Usage Specification
Determing the feasibility of using an analog input requires knowing how the user will use the knob.
The application of this encoder is to select menu items from an LCD menu.
There are two possible rotation speeds:
- User is turning the knob slow enough so as to read which menu selection is highlighted.
- User is turning the knob quickly to hurry through the menu.
In the first case, the user is actively monitoring which specific menu choice is highlighted; thus encoder counts MUST NOT be lost.
In the second case, the user will be ignoring most items so it is OK to lose a few encoder counts as long as the direction does not reverse.
Further specifications are:
- Pulses will not be counted when the button is pushed (the knob becomes slightly harder to turn in this case).
- A plastic knob will be attached to the encoder, reducing the angular speed and thus the rate of encoder pulses.
Interface the encoder to an analog input
The push button switch is trivial; pushing the encoder shorts two contacts.
The quadrature operation of the rotary encoder is as follows:
- There are three terminals on the rotary output: Common, Contact 1 and Contact2
- As the knob turns counter clockwise, the Common terminal shorts to the following repeating sequence:
- Open (no contacts)
- Contact 1
- Contact 1 and 2
- Contact 2
Therefore, as the state of the contacts change the voltage must change to specific discrete levels.
A voltage level must map to only one of the 4 contact states; voltage levels that do not match can be discarded.
The following circuit schematic illustrates this approach.
- R30 is a 15k pullup resistor to VCC, which is 3.3V.
- The Common quadrature terminal is pin 4 on J10 and connects to ground.
- Closing Contact 1 (which is pin 3 on J10) connects the analog signal to ground through 15k pulldown resistor R32.
- Closing Contact 2 (which is pin 5 on J10) connects the analog signal to ground through 33k pulldown resistor R31.
- The pushbutton switch directly shorts the analog input to ground.
- C30 is a 10nF filtering capacitor to remove contact bounce and minimize reflections.
I attempted to choose the resistor values that maximized the difference between voltage levels.
I did not follow a formal optimization process; such an optimization warrants a detailed algebraic analysis.
However, as shown in the next section these resistor values worked well enough.
Measuring Circuit Peformance
At this point I was able to construct my circuit and determine how quickly the encoder outputs changed.
I turned the bare encoder without an aftermarket knob.
The oscilloscope capture shows a qualitatively typical single knob cycle.
The signal rests at each intermediate voltage level for at least 5ms; the first level a bit longer as it takes time to accelerate the knob.
At a 5ms/per division resolution the signal is extremely clean.
For the next test I turned the bare encoder as quickly as I could.
I searched the capture for the fastest pulses and found that a complete cycle could occur in 1ms.
This was a very hard pace to keep up though and should be considered worst case.
At this scale the rise time induced by the 10nF capacitor becomes more apparent and the upward transition becomes messy.
However the general lack of contact bounce makes the ADC more reliable so I would not reduce the value of C30.
For the next two tests I printed out
Knob2 from this Thingiverse thingie.
The oscilloscope capture shows a qualitatively typical single knob cycle.
The cycle time is only about 5ms longer than without the knob.
Next I turned the knob as quickly as I could.
The cycle time was 4ms, much longer than without knob, confirming obvious physical principles.
Given the choice of clock sources and requirements, I concluded that sampling the signal every millisecond would be sufficient for my application.
Code Implementation
The following is the ADC interrupt handler.
The ADC is in single shot mode, clocked by MCLK with with the longest sample and hold times.
The ADC is gated by the watchdog timer every millisecond.
/* This routine runs every millisecond. */
__attribute__((interrupt(ADC10_VECTOR)))
void ADC10_ISR(void)
{
register uint16_t v = ADC10MEM;
/* Check for pushbutton press.
Pushbutton shorts to ground so ADC value should be well below 100. */
if(v < 100)
s_cur_button = 1;
else
s_cur_button = 0;
/* If a change in button press occurred then wake up the main loop. */
if(s_cur_button != s_last_button)
LPM0_EXIT;
/* Now we just compare thresholds and see where our ADC value lies.
Most intermediate values will be tossed out as invalid.
This routine could use some optimization but it is working OK for me now
as of 2015-06-14.
Invalid levels will set v to 0 and not wake up the main loop
(unless a button push occurred). */
if(v > calibration.mid_compare){
if(v > calibration.high_level + calibration.fuzz){
v = 4;
}
else if(v > calibration.high_level - calibration.fuzz){
v = 3;
}
else
v = 0;
}
else{
if(v < calibration.low_level){
if(v > calibration.low_level - calibration.fuzz){
v = 1;
}
else if(v < calibration.low_low_level - calibration.fuzz){
v = 0;
}
else if(v > calibration.low_low_level + calibration.fuzz){
v = 0;
}
else
v = 2;
}
else if(v < calibration.low_level + calibration.fuzz){
v = 1;
}
else
v = 0;
}
s_cur_pos = v;
if(v)
LPM0_EXIT;
}
Interpreting the encoder counts was the task in my main loop. The loop sleeps until woken up by the ADC interrupt.
while(1){
LPM0;
if(s_cur_pos){
if(s_cur_pos != s_last_pos){
if(1 == s_last_pos && 4 == s_cur_pos){
/* CCW */
--counter;
}
else if(1 == s_cur_pos && 4 == s_last_pos){
/* CW */
++counter;
}
else if(s_cur_pos < s_last_pos){
/* CCW */
--counter;
}
else{
/* CW */
++counter;
}
s_last_pos = s_cur_pos;
/* Print counter value on line 2
Converted to hex...if you like symbols instead of letters */
uint8_t output[5] = {
LCD_LINE_2[0]
,LCD_LINE_2[1]
,(counter >> 8) + '0'
,((counter >> 4) & 0xF) + '0'
,(counter & 0xF) + '0'
};
begin_lcd_write(output, 5);
}
}
else{
/* Pushing button resets counter to 512. */
if(s_last_button != s_cur_button){
if(s_cur_button)
counter = 512;
}
s_last_button = s_cur_button;
}
}
Future Improvements
- Do not discard voltage levels that do not match to the a priori levels; instead infer the rotation direction by comparing the new level to the old level.
- Supersample the analog signal 8 or 16 times and bit shift to average the result.
- Optimize the resistor values to maximize the difference in voltage levels.
- I am still getting an occasional reverse tick so more debugging is needed.
References:
The following rotary encoders were the ones I used (I have no affiliation with the supplier).