ClickEncoder

Projekt-Steckbrief

  • Schwierigkeitsgrad: Mittel 3/5
  • Kosten: 0€
  • Zeitaufwand: ~6h

Überblick

Image: C++ logo Bibliothek für das Arduino Framework, geschrieben in C++.

Der Quellcode selbst besteht nur aus ein paar hundert Zeilen. Er ist leichtgewichtig und leistungsfähig, da der verbesserte Algorithmus weniger Berechnungen benötigt und gleichzeitig eine bessere Zustandsinterpretation bietet als das ursprüngliche Projekt, von dem sie abgezweigt ist. Die Bibliothek ist offiziell auf PlatformIO IDE veröffentlicht und wird auf Github gehostet und gepflegt. Sie enthält Beispiele sowohl für Arduino- als auch für PlatformIO-IDEs und bietet Unittests, die nach der Anpassung an die spezifische Anwendung ausgeführt werden können.

Motivation

Ich habe zunächst die von 0xPIT veröffentlichte Originalbibliothek verwendet, aber diese verursachte bei mir einige Compiler-Warnungen, außerdem waren die Messwerte des Encoders nicht einwandfrei und manchmal gab es Jitter oder zurückspringende Werte, was ich nicht akzeptieren wollte.

Lösung

Da das Archiv ungepflegt aussah und meine Anfrage für Verbesserungen nicht beantwortet wurde, beschloss ich, diese Probleme selbst zu beheben. Ich habe also behutsam einen Teil des Codes umgeschrieben. Für mein damaliges Projekt benötigte ich auch ein “Wiederholungssignal”, wenn eine Taste kontinuierlich gedrückt wird, also fügte ich auch dies hinzu. Schließlich trennte ich den Encoder-Algorithmus von der Tastenbehandlung und stellte separate Klassen bereit, so dass nun bei Bedarf jede beliebige Kombination aus Taste und Encoder verwendet werden kann, jeweils mit konfigurierbarem Verhalten.

Encoder Algorithmus erklärt

API

Die API von ClickEncoder ist einfach und leicht zu verstehen: service() führt die Geschäftslogik aus und die Zustandsinterpretation durch. Diese Methode muss regelmäßig ausgeführt werden, um zu überprüfen, ob der Encoder seit seinem letzten Aufruf bewegt wurde, in welche Richtung, wie viele Schritte, und diese Werte dann in einer Akkumulatorvariablen speichern. getIncrement() gibt die Schrittänderungen des Encoders seit dem letzten Aufruf zurück. getAccumulate() gibt die Summe der Inkremente seit dem Start zurück. getButton() gibt den aktuellen Button-Status zurück.

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

Encoder-Status

Der Encoder verwendet einen Gray Code zur Kodierung der Schritte. getBitCode() wandelt diesen Code, der über die Hardware-Pins A und B des Encoders übertragen wird, in eine “0…3”-Rasten-Interpretation um, wobei die Zahlen bei einer Rechtsdrehung ansteigen und bei einer Linksdrehung abfallen. Dies geschieht effizient ohne Verwendung von if() oder anderer Verzweigungslogik.

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);
    
    // bit0 of result to be inverted if set
    currentEncoderRead ^= digitalRead(pinB);
    return currentEncoderRead;
}

Ich habe hier mit einem Trick gearbeitet, sodass man sich den Code-Schnipsel oben möglicherweise mehrfach ansehen muss. Der Zustand von PinA wird auf Bit1 von currentEncoderRead geschrieben, indem ich es mit der <<-Operation nach links schiebe. Gleichzeitig bleibt der Status des 0. Bits durch |= aber erhalten. Würde ich jetzt PinB einfach logisch &= anbinden, dann würde ich den Gray-Code verletzen, nachdem A&&B=2 sein sollte und nicht 3.

Sind A und B also beide nicht gesetzt, bleibt das Ergebnis 0, denn 00^00=00=0. Ist nur A gesetzt, B aber nicht, so lautet das Ergebnis 11^00=11=3. Wenn B gesetzt ist, A aber nicht, ergibt sich 00^01=01=1. Sind beide gesetzt, schließlich 11^01=10=2.

Für weitere Informationen zum Thema logische Binäroperationen siehe mein Steckbrett-Lagerfeuer Projekt

Zustandsinterpretation

Nachdem der Bitcode gelesen wurde, wird die Variable rawMovement gesetzt, die uns sagt, wie weit der Encoder zwischen zwei Durchläufen der service() Routine gedreht wurde. Hier sieht man, dass dieser Schritt zeitkritisch ist: Es kommt zu Aliasing-Fehlern, wenn der Geber schneller einmal alle Positionen wechselt, als dass service() erneut aufgerufen wird. In diesem Falle würden Schritte verloren gehen.

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);
}

Hier ein weiterer Trick: signedMovement wird berechnet, indem die Bewegung in eine vorzeichenbehaftete Ganzzahl umgewandelt wird, die 0 als “nicht gedreht”, -1 als “links gedreht” und 1 als “rechts gedreht” darstellt. Dabei wird das Verhalten bei Integer-Überlauf ausgenutzt, in Kombination mit der Interpretation von “signedness”.

Abschließend wird die Akkumulation durchgeführt und die Beschleunigung, sofern konfiguriert, dem Akkumulator hinzugefügt, je nachdem, wie schnell der Geber gedreht wird.

Um diese beiden Methoden ohne Verzweigungen zu programmieren, habe ich mehrere Abende Grübelei und praktisch einen halben Block kariertes Papier benötigt. Offen gesagt haben mich diese paar Zeilen Code an den Rande der Leistungsfähigkeit meines Hirns gebracht. Ich habe das Programmieren eben nicht von der Pike auf gelernt 😅

Die Methode getAccumulate()

Sie ist wirklich einfach. Sie gibt nur den internen akkumulierten Wert zurück, der der Konfiguration des Encoders entspricht, wie viele Ticks einen Schritt ausmachen.

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

Die Methode getIncrement()

Gibt zurück um wie viele Ticks sich der Encoder seit dem letzten Aufruf dieser Methode bewegt hat.

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

Ermittlung des Tasterzustands

Die Methode handleButton()

Alle Tasterzustände leiten sich von zwei Basiszuständen ab: gedrückt oder nicht gedrückt. Dies spiegelt sich in der Logik wider:

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

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

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

Der Taster kann so konfiguriert werden, dass er seltener ausgelesen wird als der Encoder. Wenn dies der Fall ist, bleibt diese Methode zwischenzeitlich ohne Aktion.

Taste gedrückt:

handleButtonPressed() kann entweder Closed, Held oder LongPressRepeat zurückgeben, je nachdem wie der Taster konfiguriert ist und wie lange er bereits gedrückt wurde.

Taste nicht gedrückt:

handleButtonReleased() kann entweder Clicked, Released oder Doubleclicked zurückgeben, abhängig von der Konfiguration des Tasters und der Anzahl der Klicks innerhalb einer bestimmten Zeit.

Konfiguration der Bibliothek

Das Verhalten der Encoder/Tastenerkennung kann in der Datei encoder.h mit Hilfe der unten aufgeführten Konstanten geändert werden. Die Werte wurden bereits optimiert und fühlen sich, zumindest für mich, natürlich an. Sie sollten geändert werden, wenn Sie ein anderes Serviceintervall als 1ms verwenden möchten, da diese Werte alle dieses Intervall als Referenz nehmen.

// ----------------------------------------------------------------------------
// 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
// ----------------------------------------------------------------------------

Verwenden Sie sie in Ihrem Projekt!

  • Die Bibliothek ist eine Open-Source-Software, die unter der MIT-Lizenz steht.
  • PlatformIO-Benutzer? Ganz einfach, suchen Sie sie in der Ansicht ‘Bibliotheken’, die Tags sind Arduino, Encoder, Schallbert. Dann ist es nur noch ein Klick, um sie zu Ihrer Lösung hinzuzufügen.
  • Alternativ können Sie auch das repository klonen oder forken und die Header/Cpp-Datei nach eigenem Ermessen verwenden.
  • Sie möchten etwas beitragen? Vielleicht haben Sie eine effizientere Zustandsberechnung für die Taster? Ich würde mich freuen, Ihren Pull Request in meinem Posteingang zu finden.