ClickEncoder

Project stats

  • Difficulty: medium 3/5
  • Cost: 0€
  • Time: ~6h

Abstract

C++Library for the Arduino Framework, written in c++.

It is lightweight because the source code itself is a few hundred lines only, and powerful, because of the improved algorithm uses less calculations while providing a better state interpretation than the original project this is forked from. It is officially released on PlatformIO IDE and hosted on Github. It contains examples both for Arduino and PlatformIO IDEs and provides unittests that can be run upon customization.

Motivation

I used the original library published by 0xPIT at first but that one caused some compiler warnings for me, plus the encoder readings were not flawless and sometimes there was jitter or bouncing I didn’t want to happen.

Solution

As the repo looked unmaintained, I decided to fix these issues by rewriting some code. For my project at that time, I also needed a “repeat” signal when a button is pressed continuously, so I added this, too. And finally, I separated the encoder algorithm from the button handling and provided separate classes so that, if needed, any combination of button and encoder could be used, each with configurable behavior.

Encoder Algorithm explained

API

ClickEncoder’s API is simple and easy to understand: service() runs the business logic and does the state interpretation. It has to be run regularly to check if the encoder has been moved since its last call, into which direction, how many steps, and has these values saved in an accumulator variable. getIncrement() returns the encoder step changes since its last call. getAccumulate() returns the sum of steps taken since startup. getButton() returns the current Button state.

void service();
int16_t getIncrement();
int16_t getAccumulate();
Button::eButtonStates getButton();

Encoder state

The encoder uses a Gray Code to encode the steps. getBitCode() converts this code transmitted via the Encoder’s hardware pins A and B to a 0...3 notch interpretation, numbers increasing when turning right, and decreasing when turning left. This is done efficiently without use of if() or other branching logic.

uint8_t Encoder::getBitCode()
{
    // GrayCode convert
    // !A && !B --> 0
    // !A &&  B --> 1
    //  A &&  B --> 2
    //  A && !B --> 3
    uint8_t currentEncoderRead = digitalRead(pinA);
    currentEncoderRead |= (currentEncoderRead << 1);
    
    // invert result's 0th bit if set
    currentEncoderRead ^= digitalRead(pinB);
    return currentEncoderRead;
}

I used a trick here, so you may have to look at the code snippet above several times. The state of PinA is written to bit1 of currentEncoderRead by me shifting it to the left with the << operation. But at the same time the state of the 0th bit is preserved by |=. If I now bind PinB simply by logical &=, then I would violate the Gray code, after which A&&B=2 should be and not 3.

So if A and B are both not set, the result remains 0, because 00^00=00=0. If only A is set, but B is not, the result is 11^00=11=3. If B is set, but A is not, the result is 00^01=01=1. If both are set, finally 11^01=10=2.

For more information about how logical binary operators work, please refer to my LED fireplace project

State interpretation

After the bit code is read, rawMovement variable is set that tells us how much the encoder has been turned between two runs of the service() routine. Here you can see the time criticality because there will be aliasing errors if the encoder changes notches in a time similarly short to the service interval.

void Encoder::handleEncoder()
{
    uint8_t encoderRead = getBitCode();
    // bit0 set = status changed, bit1 set = "overflow 3" where it goes 0->3 or 3->0
    uint8_t rawMovement = encoderRead - lastEncoderRead;
    lastEncoderRead = encoderRead;
    // This is the uint->int magic, converts raw to: -1 counterclockwise, 0 no turn, 1 clockwise
    int8_t signedMovement = ((rawMovement & 1) - (rawMovement & 2));

    encoderAccumulate += signedMovement;
    encoderAccumulate += handleAcceleration(signedMovement);
}

Here is another trick: signedMovement is calculated by converting the movement into a signed integer representing 0 as “not rotated, -1 as “rotated left” and 1 as “rotated right”. This exploits the integer overflow behavior, in combination with the interpretation of signedness.

Finally, the accumulation is performed and the acceleration, if configured, is added to the accumulator depending on how fast the encoder is rotated.

To program these two methods without branching took me several evenings of grubbling and practically half a pad of squared paper. Frankly, these few lines of code have brought me to the edge of my brain’s capacity. I am just not a “real” programmer 😅

The getAccumulate() method

It’s really simple, it just returns the internal accumulated value accounted to the encoder’s configuration about how many notches make a step.

int16_t Encoder::getAccumulate()
{
    return (encoderAccumulate / stepsPerNotch);
}

The getIncrement() method

Returns how much the encoder’s values have changed since its last call.

int16_t Encoder::getIncrement()
{
    int16_t accu = getAccumulate();
    int16_t encoderIncrements = accu - lastEncoderAccumulate;
    lastEncoderAccumulate = accu;
    return (encoderIncrements);
}

Button state calculation explained

The handleButton() method

All button states derive from the two basic states: pressed or not pressed. This is reflected in the logic as well:

void Button::handleButton()
{
    if (lastGetButtonCount < ENC_BUTTONINTERVAL)
    {
        return;
    }
    lastGetButtonCount = 0;

    if (digitalRead(pinBTN) == pinActiveState)
    {
        handleButtonPressed();
    }
    else
    {
        handleButtonReleased();
    }

    if (doubleClickTicks > 0)
    {
        --doubleClickTicks;
    }
}

The button can be configured to be read less often than the encoder. If this is the case, this method will be left without any action.

Button pressed:

handleButtonPressed() can either return Closed, Held, or LongPressRepeat depending on how the button is configured and how long it has been pressed already.

Button not pressed:

handleButtonReleased() can either return Clicked, Released, or Doubleclicked depending on button configuration and click count within a certain time.

Library configuration

The encoder/button detection behavior can be modified in the encoder.h file using the constants shown below. The values have been tweaked already and feel natural to me, at least. They should be modified if you choose to use a different service interval than 1ms because those values all take this interval as reference.

// ----------------------------------------------------------------------------
// Acceleration configuration (for 1ms calls to ::service())
//
constexpr uint8_t ENC_ACCEL_START = 150; // The smaller this value, the quicker you must turn to activate acceleration.
constexpr uint8_t ENC_ACCEL_SLOPE = 75; // the smaller this value, the stronger the acceleration will manipulate values.

// Button configuration (values for 1ms timer service calls)
//
constexpr uint8_t ENC_BUTTONINTERVAL = 20;            // check button every x ms, also debouce time
constexpr uint16_t ENC_DOUBLECLICKTIME = 400;         // second click within x ms
constexpr uint16_t ENC_LONGPRESSREPEATINTERVAL = 200; // reports repeating-held every x ms
constexpr uint16_t ENC_HOLDTIME = 1200;               // report held button after x ms
// ----------------------------------------------------------------------------

Use it in your project!

  • The library is open source software, using the MIT license.
  • PlatformIO user? Easy, just search it in the ‘Libraries’ view, Tags are Arduino, Encoder, Schallbert. It’s then just one more click to add it to your solution
  • Alternatively, just clone or fork the repository and use the header/cpp file as you deem fit.
  • Contribute? Maybe you have a more efficient state calculation of the button? I’d be happy to review your pull request :)