├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── check-arduino.yml │ └── compile-examples.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── BasicRotaryEncoder │ └── BasicRotaryEncoder.ino ├── ButtonPressDuration │ └── ButtonPressDuration.ino ├── LeftOrRight │ └── LeftOrRight.ino └── TwoRotaryEncoders │ └── TwoRotaryEncoders.ino ├── keywords.txt ├── library.json ├── library.properties └── src ├── ESP32RotaryEncoder.cpp └── ESP32RotaryEncoder.h /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | 10 | [*.{h,cpp,json,yml,yaml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.ino] 15 | indent_style = tab 16 | indent_size = 4 17 | 18 | [*.{md,txt}] 19 | indent_style = space 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/check-arduino.yml: -------------------------------------------------------------------------------- 1 | name: Check Arduino 2 | 3 | # See: https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows 4 | on: 5 | push: 6 | pull_request: 7 | schedule: 8 | # Run every Tuesday at 8 AM UTC to catch breakage caused by new rules added to Arduino Lint. 9 | - cron: "0 8 * * TUE" 10 | workflow_dispatch: 11 | repository_dispatch: 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Arduino Lint 22 | uses: arduino/arduino-lint-action@v1.0.2 23 | with: 24 | compliance: specification 25 | library-manager: update 26 | project-type: library 27 | -------------------------------------------------------------------------------- /.github/workflows/compile-examples.yml: -------------------------------------------------------------------------------- 1 | name: Compile Examples 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/*.h' 7 | - 'src/*.cpp' 8 | - 'examples/**.ino' 9 | pull_request: 10 | paths: 11 | - 'src/*.h' 12 | - 'src/*.cpp' 13 | - 'examples/**.ino' 14 | 15 | jobs: 16 | compile-examples: 17 | runs-on: ubuntu-latest 18 | 19 | env: 20 | SKETCHES_REPORTS_PATH: sketches-reports 21 | 22 | strategy: 23 | matrix: 24 | fqbn: 25 | - arduino:esp32:nano_nora 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Compile sketches 32 | uses: arduino/compile-sketches@v1.1.2 33 | with: 34 | fqbn: ${{ matrix.fqbn }} 35 | 36 | sketch-paths: | 37 | - examples 38 | 39 | libraries: | 40 | - source-path: ./ 41 | 42 | enable-deltas-report: false 43 | sketches-report-path: ${{ env.SKETCHES_REPORTS_PATH }} 44 | 45 | - name: Save sketches report as workflow artifact 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: ${{ env.SKETCHES_REPORTS_PATH }} 49 | path: ${{ env.SKETCHES_REPORTS_PATH }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/windows,platformio,visualstudiocode,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,platformio,visualstudiocode,macos 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### PlatformIO ### 38 | .pioenvs 39 | .piolibdeps 40 | .clang_complete 41 | .gcc-flags.json 42 | .pio 43 | 44 | ### VisualStudioCode ### 45 | .vscode/* 46 | !.vscode/settings.json 47 | !.vscode/tasks.json 48 | !.vscode/launch.json 49 | !.vscode/extensions.json 50 | !.vscode/*.code-snippets 51 | 52 | # Local History for Visual Studio Code 53 | .history/ 54 | 55 | # Built Visual Studio Code Extensions 56 | *.vsix 57 | 58 | ### VisualStudioCode Patch ### 59 | # Ignore all local history of files 60 | .history 61 | .ionide 62 | 63 | ### Windows ### 64 | # Windows thumbnail cache files 65 | Thumbs.db 66 | Thumbs.db:encryptable 67 | ehthumbs.db 68 | ehthumbs_vista.db 69 | 70 | # Dump file 71 | *.stackdump 72 | 73 | # Folder config file 74 | [Dd]esktop.ini 75 | 76 | # Recycle Bin used on file shares 77 | $RECYCLE.BIN/ 78 | 79 | # Windows Installer files 80 | *.cab 81 | *.msi 82 | *.msix 83 | *.msm 84 | *.msp 85 | 86 | # Windows shortcuts 87 | *.lnk 88 | 89 | # End of https://www.toptal.com/developers/gitignore/api/windows,platformio,visualstudiocode,macos -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matthew Clark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESP32RotaryEncoder 2 | 3 | [![Arduino Lint](https://github.com/MaffooClock/ESP32RotaryEncoder/actions/workflows/check-arduino.yml/badge.svg)](https://github.com/MaffooClock/ESP32RotaryEncoder/actions/workflows/check-arduino.yml) [![Compile Examples](https://github.com/MaffooClock/ESP32RotaryEncoder/actions/workflows/compile-examples.yml/badge.svg)](https://github.com/MaffooClock/ESP32RotaryEncoder/actions/workflows/compile-examples.yml) [![Arduino Library](https://www.ardu-badge.com/badge/ESP32RotaryEncoder.svg?)](https://www.ardu-badge.com/ESP32RotaryEncoder) [![PlatformIO Registry](https://badges.registry.platformio.org/packages/maffooclock/library/ESP32RotaryEncoder.svg)](https://registry.platformio.org/libraries/maffooclock/ESP32RotaryEncoder) [![License](https://img.shields.io/badge/license-MIT%20License-blue.svg)](http://doge.mit-license.org) 4 | 5 | A simple Arduino library for implementing a rotary encoder on an ESP32 using interrupts and callbacks. 6 | 7 | 8 | ## Description 9 | 10 | This library makes it easy to add one or more rotary encoders to your ESP32 project. It uses interrupts to instantly detect when the knob is turned or the pushbutton is pressed and fire a custom callback to handle those events. 11 | 12 | It works with assembled modules that include their own pull-up resistors as well as raw units without any other external components (thanks to the ESP32 having software controlled pull-up resistors built in). You can also specify a GPIO pin to supply the Vcc reference instead of tying the encoder to a 3v3 source. 13 | 14 | You can specify the boundaries of the encoder (minimum and maximum values), and whether turning past those limits should circle back around to the other side. 15 | 16 | 17 | ## Inspiration 18 | 19 | There are already many rotary encoder libraries for Arduino, but I had trouble finding one that met my requirements. I did find a couple that were beautifully crafted, but wouldn't work on the ESP32. Others I tried were either bulky or clumsy, and I found myself feeling like it would be simpler to just setup the interrupts and handle the callbacks directly in my own code instead of through a library. 20 | 21 | Of the many resources I used to educate myself on the best ways to handle the input from a rotary encoder was, the most notable one was [a blog post](https://garrysblog.com/2021/03/20/reliably-debouncing-rotary-encoders-with-arduino-and-esp32/) by [@garrysblog](https://github.com/garrysblog). In his article, he cited another article, [Rotary Encoder: Immediately Tame your Noisy Encoder!](https://www.best-microcontroller-projects.com/rotary-encoder.html), which basically asserted that when turning the knob right or left, the pulses from the A and B pins can only happen in a specific order between detents, and any other pulses outside of that prescribed order could be ignored as noise. 22 | 23 | Thus, by running the pulses received on the A and B inputs through a lookup table, it doesn't just de-bounce the inputs -- it actually guarantees that every click of the rotary encoder increments or decrements as the user would expect, regardless of how fast or slow or "iffy" the movement is. 24 | 25 | Garry wrote some functions that incorporated the use of a lookup table, which is what I used myself initially -- and it worked _beautifully_. In fact, it worked so well that I decided to turn it into a library to make it even simpler to use. And that worked so well that I decided I should package it up and share it with others. 26 | 27 | 28 | ## Installation 29 | 30 | #### PlatformIO 31 | 32 | There are a few ways, choose whichever you prefer (pick **one**, don't do all three!): 33 | 34 | - Search the [Library Registry](https://registry.platformio.org/search?t=library) for `MaffooClock/ESP32RotaryEncoder` and install it automatically. 35 | 36 | - Edit your [platformio.ini](https://docs.platformio.org/en/latest/projectconf/index.html) file and add `MaffooClock/ESP32RotaryEncoder@^1.1.2` to your [`lib_deps`](https://docs.platformio.org/en/latest/projectconf/sections/env/options/library/lib_deps.html) stanza. 37 | 38 | - Use the command line interface: 39 | ```shell 40 | cd MyProject 41 | pio pkg install --library "MaffooClock/ESP32RotaryEncoder@^1.1.2" 42 | ``` 43 | 44 | #### Arduino IDE 45 | 46 | There are two ways (pick **one**, don't do both!): 47 | 48 | - Search the Library Manager for `ESP32RotaryEncoder` 49 | 50 | - Manual install: download the [latest release](https://github.com/MaffooClock/ESP32RotaryEncoder/releases/latest), then see the documentation on [Importing a .zip Library](https://docs.arduino.cc/software/ide-v1/tutorials/installing-libraries#importing-a-zip-library). 51 | 52 | ### After Installation 53 | 54 | Just add `include ` to the top of your source file. 55 | 56 | 57 | ## Usage 58 | 59 | Adding a rotary encoder instance is easy: 60 | 61 | 1. Include the library: 62 | 63 | ```c++ 64 | #include 65 | ``` 66 | 67 | 68 | 2. Define which pins to use, if you prefer to do it this way -- you could also just set the pins in the constructor (step 3): 69 | 70 | ```c++ 71 | // Change these to the actual pin numbers that you've connected your rotary encoder to 72 | const int8_t DO_ENCODER_VCC = D2; // Only needed if you're using a GPIO pin to supply the 3.3v reference 73 | const int8_t DI_ENCODER_SW = D3; // Pushbutton, if your rotary encoder has it 74 | const uint8_t DI_ENCODER_A = D5; // Might be labeled CLK 75 | const uint8_t DI_ENCODER_B = D4; // Might be labeled DT 76 | ``` 77 | 78 | 3. Instantiate a `RotaryEncoder` object: 79 | 80 | a) This uses a GPIO pin to provide the 3.3v reference: 81 | ```c++ 82 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, DI_ENCODER_SW, DO_ENCODER_VCC ); 83 | ``` 84 | 85 | b) ...or you can free up the GPIO pin and tie Vcc to 3V3, then just omit that argument: 86 | ```c++ 87 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, DI_ENCODER_SW ); 88 | ``` 89 | 90 | c) ...or maybe your rotary encoder doesn't have a pushbutton? 91 | ```c++ 92 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B ); 93 | ``` 94 | 95 | d) ...or you want to use a different library with the pushbutton, but still use a GPIO to provide the 3.3v reference: 96 | ```c++ 97 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, -1, DO_ENCODER_VCC ); 98 | ``` 99 | 100 | 4. Add callbacks: 101 | 102 | ```c++ 103 | void knobCallback( long value ) 104 | { 105 | // This gets executed every time the knob is turned 106 | 107 | Serial.printf( "Value: %i\n", value ); 108 | } 109 | 110 | void buttonCallback( unsigned long duration ) 111 | { 112 | // This gets executed every time the pushbutton is pressed 113 | 114 | Serial.printf( "boop! button was down for %u ms\n", duration ); 115 | } 116 | ``` 117 | 118 | 5. Configure and initialize the `RotaryEncoder` object: 119 | 120 | ```c++ 121 | void setup() 122 | { 123 | Serial.begin( 115200 ); 124 | 125 | // This tells the library that the encoder has its own pull-up resistors 126 | rotaryEncoder.setEncoderType( EncoderType::HAS_PULLUP ); 127 | 128 | // Range of values to be returned by the encoder: minimum is 1, maximum is 10 129 | // The third argument specifies whether turning past the minimum/maximum will 130 | // wrap around to the other side: 131 | // - true = turn past 10, wrap to 1; turn past 1, wrap to 10 132 | // - false = turn past 10, stay on 10; turn past 1, stay on 1 133 | rotaryEncoder.setBoundaries( 1, 10, true ); 134 | 135 | // The function specified here will be called every time the knob is turned 136 | // and the current value will be passed to it 137 | rotaryEncoder.onTurned( &knobCallback ); 138 | 139 | // The function specified here will be called every time the button is pushed and 140 | // the duration (in milliseconds) that the button was down will be passed to it 141 | rotaryEncoder.onPressed( &buttonCallback ); 142 | 143 | // This is where the inputs are configured and the interrupts get attached 144 | rotaryEncoder.begin(); 145 | } 146 | ``` 147 | 148 | 6. Done! The library doesn't require you to do anything in `loop()`: 149 | 150 | ```c++ 151 | void loop() 152 | { 153 | // Your stuff here 154 | } 155 | ``` 156 | 157 | There are other options and methods you can call, but this is just the most basic implementation. 158 | 159 | > [!IMPORTANT] 160 | > Keep the `onTurned()` and `onPressed()` callbacks lightweight, and definitely _do not_ use any calls to `delay()` here. If you need to do some heavy lifting or use delays, it's better to set a flag here, then check for that flag in your `loop()` and run the appropriate functions from there. 161 | 162 | 163 | ## Debugging 164 | 165 | This library makes use of the ESP32-IDF native logging to output some helpful debugging messages to the serial console. To see it, you may have to add a build flag to set the logging level. For PlatformIO, add `-DCORE_DEBUG_LEVEL=4` to the [`build_flags`](https://docs.platformio.org/en/stable/projectconf/sections/env/options/build/build_flags.html) option in [platformio.ini](https://docs.platformio.org/en/stable/projectconf/index.html). 166 | 167 | After debugging, you can either remove the build flag (if you had to add it), or just reduce the level from debug (4) to info (3), warning (2), or error (1). You can also use verbose (5) to get a few more messages beyond debug, but the overall output from sources other than this library might be noisy. 168 | 169 | See [esp32-hal-log.h](https://github.com/espressif/arduino-esp32/blob/master/cores/esp32/esp32-hal-log.h) for more details. 170 | 171 | 172 | ## Compatibility 173 | 174 | So far, this has only been tested on an [Arduino Nano ESP32](https://docs.arduino.cc/hardware/nano-esp32) and a generic ESP-WROOM-32 module. This _should_ work on any ESP32 in Arduino IDE and PlatformIO as long as your framework packages are current. 175 | 176 | This library more than likely won't work at all on non-ESP32 devices -- it uses features from the ESP32 IDF, such as [esp_timer.h](https://github.com/espressif/esp-idf/blob/master/components/esp_timer/include/esp_timer.h), along with [FunctionalInterrupt.h](https://github.com/espressif/arduino-esp32/blob/master/cores/esp32/FunctionalInterrupt.h) from the Arduino API. So, to try and use this on a non-ESP32 might require some serious overhauling. 177 | 178 | 179 | ## Examples 180 | 181 | Check the [examples](/examples) folder. 182 | 183 | -------------------------------------------------------------------------------- /examples/BasicRotaryEncoder/BasicRotaryEncoder.ino: -------------------------------------------------------------------------------- 1 | /** 2 | * ESP32RotaryEncoder: BasicRotaryEncoder.ino 3 | * 4 | * This is a basic example of how to instantiate a single Rotary Encoder. 5 | * 6 | * Turning the knob will increment/decrement a value between 1 and 10 and 7 | * print it to the serial console. 8 | * 9 | * Pressing the button will output "boop!" to the serial console. 10 | * 11 | * Created 3 October 2023 12 | * Updated 1 November 2023 13 | * By Matthew Clark 14 | */ 15 | 16 | #include 17 | 18 | 19 | // Change these to the actual pin numbers that 20 | // you've connected your rotary encoder to 21 | const uint8_t DI_ENCODER_A = 27; 22 | const uint8_t DI_ENCODER_B = 14; 23 | const int8_t DI_ENCODER_SW = 12; 24 | const int8_t DO_ENCODER_VCC = 13; 25 | 26 | 27 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, DI_ENCODER_SW, DO_ENCODER_VCC ); 28 | 29 | 30 | void knobCallback( long value ) 31 | { 32 | Serial.printf( "Value: %ld\n", value ); 33 | } 34 | 35 | void buttonCallback( unsigned long duration ) 36 | { 37 | Serial.printf( "boop! button was down for %lu ms\n", duration ); 38 | } 39 | 40 | void setup() 41 | { 42 | Serial.begin( 115200 ); 43 | 44 | // This tells the library that the encoder has its own pull-up resistors 45 | rotaryEncoder.setEncoderType( EncoderType::HAS_PULLUP ); 46 | 47 | // Range of values to be returned by the encoder: minimum is 1, maximum is 10 48 | // The third argument specifies whether turning past the minimum/maximum will 49 | // wrap around to the other side: 50 | // - true = turn past 10, wrap to 1; turn past 1, wrap to 10 51 | // - false = turn past 10, stay on 10; turn past 1, stay on 1 52 | rotaryEncoder.setBoundaries( 1, 10, true ); 53 | 54 | // The function specified here will be called every time the knob is turned 55 | // and the current value will be passed to it 56 | rotaryEncoder.onTurned( &knobCallback ); 57 | 58 | // The function specified here will be called every time the button is pushed and 59 | // the duration (in milliseconds) that the button was down will be passed to it 60 | rotaryEncoder.onPressed( &buttonCallback ); 61 | 62 | // This is where the inputs are configured and the interrupts get attached 63 | rotaryEncoder.begin(); 64 | } 65 | 66 | void loop() 67 | { 68 | // Your stuff here 69 | } 70 | -------------------------------------------------------------------------------- /examples/ButtonPressDuration/ButtonPressDuration.ino: -------------------------------------------------------------------------------- 1 | /** 2 | * ESP32RotaryEncoder: ButtonPressDuration.ino 3 | * 4 | * This example shows how to handle long button-presses differently 5 | * from long button-presses 6 | * 7 | * Turning the knob will increment/decrement a value between 1 and 10 and 8 | * print it to the serial console. 9 | * 10 | * Pressing the button will output "boop!" to the serial console. 11 | * 12 | * Created 1 November 2023 13 | * By Matthew Clark 14 | */ 15 | 16 | #include 17 | 18 | 19 | // Change these to the actual pin numbers that 20 | // you've connected your rotary encoder to 21 | const uint8_t DI_ENCODER_A = 27; 22 | const uint8_t DI_ENCODER_B = 14; 23 | const int8_t DI_ENCODER_SW = 12; 24 | const int8_t DO_ENCODER_VCC = 13; 25 | 26 | // A button-press is considered "long" if 27 | // it's held for more than two seconds 28 | const uint8_t LONG_PRESS = 2000; 29 | 30 | 31 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, DI_ENCODER_SW, DO_ENCODER_VCC ); 32 | 33 | 34 | void buttonShortPress() 35 | { 36 | Serial.println( "boop!" ); 37 | } 38 | 39 | void buttonLongPress() 40 | { 41 | Serial.println( "BOOOOOOOOOOP!" ); 42 | } 43 | 44 | void knobCallback( long value ) 45 | { 46 | Serial.printf( "Value: %ld\n", value ); 47 | } 48 | 49 | void buttonCallback( unsigned long duration ) 50 | { 51 | if( duration > LONG_PRESS ) 52 | { 53 | buttonLongPress(); 54 | } 55 | else 56 | { 57 | buttonShortPress(); 58 | } 59 | } 60 | 61 | void setup() 62 | { 63 | Serial.begin( 115200 ); 64 | 65 | // This tells the library that the encoder has its own pull-up resistors 66 | rotaryEncoder.setEncoderType( EncoderType::HAS_PULLUP ); 67 | 68 | // Range of values to be returned by the encoder: minimum is 1, maximum is 10 69 | // The third argument specifies whether turning past the minimum/maximum will 70 | // wrap around to the other side: 71 | // - true = turn past 10, wrap to 1; turn past 1, wrap to 10 72 | // - false = turn past 10, stay on 10; turn past 1, stay on 1 73 | rotaryEncoder.setBoundaries( 1, 10, true ); 74 | 75 | // The function specified here will be called every time the knob is turned 76 | // and the current value will be passed to it 77 | rotaryEncoder.onTurned( &knobCallback ); 78 | 79 | // The function specified here will be called every time the button is pushed and 80 | // the duration (in milliseconds) that the button was down will be passed to it 81 | rotaryEncoder.onPressed( &buttonCallback ); 82 | 83 | // This is where the inputs are configured and the interrupts get attached 84 | rotaryEncoder.begin(); 85 | } 86 | 87 | void loop() 88 | { 89 | // Your stuff here 90 | } 91 | -------------------------------------------------------------------------------- /examples/LeftOrRight/LeftOrRight.ino: -------------------------------------------------------------------------------- 1 | /** 2 | * ESP32RotaryEncoder: LeftOrRight.ino 3 | * 4 | * This is a simple example of how to track whether the knob was 5 | * turned left or right instead of tracking a numeric value 6 | * 7 | * Created 1 November 2023 8 | * By Matthew Clark 9 | */ 10 | 11 | #include 12 | 13 | 14 | // Change these to the actual pin numbers that 15 | // you've connected your rotary encoder to 16 | const uint8_t DI_ENCODER_A = 27; 17 | const uint8_t DI_ENCODER_B = 14; 18 | const int8_t DI_ENCODER_SW = 12; 19 | const int8_t DO_ENCODER_VCC = 13; 20 | 21 | 22 | RotaryEncoder rotaryEncoder( DI_ENCODER_A, DI_ENCODER_B, DI_ENCODER_SW, DO_ENCODER_VCC ); 23 | 24 | 25 | // Used by the `loop()` to know when to 26 | // fire an event when the knob is turned 27 | volatile bool turnedRightFlag = false; 28 | volatile bool turnedLeftFlag = false; 29 | 30 | void turnedRight() 31 | { 32 | Serial.println( "Right ->" ); 33 | 34 | // Set this back to false so we can watch for the next move 35 | turnedRightFlag = false; 36 | } 37 | 38 | void turnedLeft() 39 | { 40 | Serial.println( "<- Left" ); 41 | 42 | // Set this back to false so we can watch for the next move 43 | turnedLeftFlag = false; 44 | } 45 | 46 | void knobCallback( long value ) 47 | { 48 | // See the note in the `loop()` function for 49 | // an explanation as to why we're setting 50 | // boolean values here instead of running 51 | // functions directly. 52 | 53 | // Don't do anything if either flag is set; 54 | // it means we haven't taken action yet 55 | if( turnedRightFlag || turnedLeftFlag ) 56 | return; 57 | 58 | // Set a flag that we can look for in `loop()` 59 | // so that we know we have something to do 60 | switch( value ) 61 | { 62 | case 1: 63 | turnedRightFlag = true; 64 | break; 65 | 66 | case -1: 67 | turnedLeftFlag = true; 68 | break; 69 | } 70 | 71 | // Override the tracked value back to 0 so that 72 | // we can continue tracking right/left events 73 | rotaryEncoder.setEncoderValue( 0 ); 74 | } 75 | 76 | void buttonCallback( unsigned long duration ) 77 | { 78 | Serial.printf( "boop! button was down for %lu ms\n", duration ); 79 | } 80 | 81 | void setup() 82 | { 83 | Serial.begin( 115200 ); 84 | 85 | // This tells the library that the encoder has its own pull-up resistors 86 | rotaryEncoder.setEncoderType( EncoderType::HAS_PULLUP ); 87 | 88 | // The encoder will only return -1, 0, or 1, and will not wrap around. 89 | rotaryEncoder.setBoundaries( -1, 1, false ); 90 | 91 | // The function specified here will be called every time the knob is turned 92 | // and the current value will be passed to it 93 | rotaryEncoder.onTurned( &knobCallback ); 94 | 95 | // The function specified here will be called every time the button is pushed and 96 | // the duration (in milliseconds) that the button was down will be passed to it 97 | rotaryEncoder.onPressed( &buttonCallback ); 98 | 99 | // This is where the inputs are configured and the interrupts get attached 100 | rotaryEncoder.begin(); 101 | } 102 | 103 | void loop() 104 | { 105 | // Check to see if a flag is set (is true), and if so, run a function. 106 | // 107 | // Why do it like this instead of within the `knobCallback()` function? 108 | // 109 | // Because the `knobCallback()` function is executed by a internal 110 | // timer ISR (interrupt service routine), which needs to be fast and 111 | // lean, so setting a boolean is the fastest way, and then let the main 112 | // `loop()` do the heavy-lifting. 113 | // 114 | // If you were to let the `knobCallback()` function do all the work, 115 | // there's a chance you'd have issues with WiFi or Bluetooth connections, 116 | // or even cause the MCU to crash. 117 | 118 | if( turnedRightFlag ) 119 | turnedRight(); 120 | 121 | else if( turnedLeftFlag ) 122 | turnedLeft(); 123 | } 124 | -------------------------------------------------------------------------------- /examples/TwoRotaryEncoders/TwoRotaryEncoders.ino: -------------------------------------------------------------------------------- 1 | /** 2 | * ESP32RotaryEncoder: TwoRotaryEncoders.ino 3 | * 4 | * This is a basic example of how to instantiate two distinct Rotary Encoders. 5 | * 6 | * Rotary Encoder #1: 7 | * - Turning the knob will increment/decrement a value between 1 and 10, 8 | * and print it to the serial console. 9 | * 10 | * - Pressing the button will enable/disable Rotary Encoder #2. 11 | * 12 | * Rotary Encoder #2: 13 | * - Turning the knob will increment/decrement a value between -100 and 100, 14 | * and print it to the serial console. 15 | * 16 | * - Pressing the button will enable/disable Rotary Encoder #1. 17 | * 18 | * While a rotary encoder is disabled, turning the knob or pressing the button 19 | * will have no effect. 20 | * 21 | * Created 3 October 2023 22 | * Updated 1 November 2023 23 | * By Matthew Clark 24 | */ 25 | 26 | #include 27 | 28 | 29 | const uint8_t RE1_DI_ENCODER_A = 27; 30 | const uint8_t RE1_DI_ENCODER_B = 14; 31 | const int8_t RE1_DI_ENCODER_SW = 12; 32 | const int8_t RE1_DO_ENCODER_VCC = 13; 33 | 34 | const uint8_t RE2_DI_ENCODER_A = 35; 35 | const uint8_t RE2_DI_ENCODER_B = 32; 36 | const int8_t RE2_DI_ENCODER_SW = 33; 37 | const int8_t RE2_DO_ENCODER_VCC = 25; 38 | 39 | 40 | RotaryEncoder rotaryEncoder1( RE1_DI_ENCODER_A, RE1_DI_ENCODER_B, RE1_DI_ENCODER_SW, RE1_DO_ENCODER_VCC ); 41 | RotaryEncoder rotaryEncoder2( RE2_DI_ENCODER_A, RE2_DI_ENCODER_B, RE2_DI_ENCODER_SW, RE2_DO_ENCODER_VCC ); 42 | 43 | 44 | void printKnob1Value( long value ) 45 | { 46 | Serial.printf( "RE1 value: %ld\n", value ); 47 | } 48 | 49 | void printKnob2Value( long value ) 50 | { 51 | Serial.printf( "RE2 value: %ld\n", value ); 52 | } 53 | 54 | void button1ToggleRE2( unsigned long duration ) 55 | { 56 | if( rotaryEncoder2.isEnabled() ) 57 | { 58 | rotaryEncoder2.disable(); 59 | Serial.println( "RE2 is disabled." ); 60 | } 61 | else 62 | { 63 | rotaryEncoder2.enable(); 64 | Serial.println( "RE2 is enaabled." ); 65 | } 66 | } 67 | 68 | void button2ToggleRE1( unsigned long duration ) 69 | { 70 | if( rotaryEncoder1.isEnabled() ) 71 | { 72 | rotaryEncoder1.disable(); 73 | Serial.println( "RE1 is disabled." ); 74 | } 75 | else 76 | { 77 | rotaryEncoder1.enable(); 78 | Serial.println( "RE1 is enaabled." ); 79 | } 80 | } 81 | 82 | void setup_RE1() 83 | { 84 | // This tells the library that the encoder has its own pull-up resistors 85 | rotaryEncoder1.setEncoderType( EncoderType::HAS_PULLUP ); 86 | 87 | // Range of values to be returned by the encoder: minimum is 1, maximum is 10 88 | // The third argument specifies whether turning past the minimum/maximum will 89 | // wrap around to the other side. 90 | // In this example, turn past 10, wrap to 1; turn past 1, wrap to 10 91 | rotaryEncoder1.setBoundaries( 1, 10, true ); 92 | 93 | // The function specified here will be called every time the knob is turned 94 | // and the current value will be passed to it 95 | rotaryEncoder1.onTurned( &printKnob1Value ); 96 | 97 | // The function specified here will be called every time the button is pushed and 98 | // the duration (in milliseconds) that the button was down will be passed to it 99 | rotaryEncoder1.onPressed( &button1ToggleRE2 ); 100 | 101 | // This is where the inputs are configured and the interrupts get attached 102 | rotaryEncoder1.begin(); 103 | } 104 | 105 | void setup_RE2() 106 | { 107 | // This tells the library that the encoder does not have its own pull-up 108 | // resistors, so the internal pull-up resistors will be enabled 109 | rotaryEncoder2.setEncoderType( EncoderType::FLOATING ); 110 | 111 | // Range of values to be returned by the encoder: minimum is -100, maximum is 100 112 | // The third argument specifies whether turning past the minimum/maximum will wrap 113 | // around to the other side. 114 | // In this example, turn past 100, stay on 100; turn past -100, stay on -100 115 | rotaryEncoder2.setBoundaries( -100, 100, false ); 116 | 117 | // The function specified here will be called every time the knob is turned 118 | // and the current value will be passed to it 119 | rotaryEncoder2.onTurned( &printKnob2Value ); 120 | 121 | // The function specified here will be called every time the button is pushed and 122 | // the duration (in milliseconds) that the button was down will be passed to it 123 | rotaryEncoder2.onPressed( &button2ToggleRE1 ); 124 | 125 | // This is where the inputs are configured and the interrupts get attached 126 | rotaryEncoder2.begin(); 127 | } 128 | 129 | void setup() 130 | { 131 | Serial.begin( 115200 ); 132 | 133 | setup_RE1(); 134 | setup_RE2(); 135 | } 136 | 137 | void loop() 138 | { 139 | // Your stuff here 140 | } 141 | -------------------------------------------------------------------------------- /keywords.txt: -------------------------------------------------------------------------------- 1 | ############################################ 2 | # Syntax Coloring Map For ESP32RotaryEncoder 3 | ############################################ 4 | 5 | ####################################################### 6 | # The Arduino IDE requires the use of a tab separator 7 | # between the name and identifier. Without this tab the 8 | # keyword is not highlighted. 9 | # 10 | # Reference: https://github.com/arduino/Arduino/wiki/Arduino-IDE-1.5:-Library-specification#keywords 11 | ####################################################### 12 | 13 | ####################################### 14 | # Datatypes & Classes (KEYWORD1) 15 | ####################################### 16 | 17 | RotaryEncoder KEYWORD1 18 | 19 | ####################################### 20 | # Methods and Functions (KEYWORD2) 21 | ####################################### 22 | 23 | RotaryEncoder::attachInterrupts KEYWORD2 24 | RotaryEncoder::begin KEYWORD2 25 | RotaryEncoder::beginLoopTimer KEYWORD2 26 | RotaryEncoder::buttonPressed KEYWORD2 27 | RotaryEncoder::constrainValue KEYWORD2 28 | RotaryEncoder::detachInterrupts KEYWORD2 29 | RotaryEncoder::disable KEYWORD2 30 | RotaryEncoder::enable KEYWORD2 31 | RotaryEncoder::encoderChanged KEYWORD2 32 | RotaryEncoder::getEncoderValue KEYWORD2 33 | RotaryEncoder::isEnabled KEYWORD2 34 | RotaryEncoder::onPressed KEYWORD2 35 | RotaryEncoder::onTurned KEYWORD2 36 | RotaryEncoder::setBoundaries KEYWORD2 37 | RotaryEncoder::setEncoderType KEYWORD2 38 | RotaryEncoder::setEncoderValue KEYWORD2 39 | 40 | ####################################### 41 | # Constants (LITERAL1) 42 | ####################################### 43 | 44 | RE_DEFAULT_PIN LITERAL1 45 | RE_DEFAULT_STEPS LITERAL1 46 | RE_LOOP_INTERVAL LITERAL1 47 | -------------------------------------------------------------------------------- /library.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ESP32RotaryEncoder", 3 | "keywords": "arduino, rotary encoder, button, gpio, interrupts", 4 | "description": "ESP32RotaryEncoder is a small library that makes implementing a rotary encoder on ESP32 easy. It uses interrupts for instant detection of knob turns or button presses without blocking or other delays.", 5 | "version": "1.1.2", 6 | "authors": [ 7 | { 8 | "name": "Matthew Clark", 9 | "url": "https://github.com/MaffooClock", 10 | "maintainer": true 11 | } 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/MaffooClock/ESP32RotaryEncoder" 16 | }, 17 | "headers": "ESP32RotaryEncoder.h", 18 | "examples": [ 19 | { 20 | "name": "Basic Example", 21 | "base": "examples/BasicRotaryEncoder", 22 | "files": [ "BasicRotaryEncoder.ino" ] 23 | }, 24 | { 25 | "name": "Two Rotary Encoders", 26 | "base": "examples/TwoRotaryEncoders", 27 | "files": [ "TwoRotaryEncoders.ino" ] 28 | }, 29 | { 30 | "name": "Left or Right", 31 | "base": "examples/LeftOrRight", 32 | "files": [ "LeftOrRight.ino" ] 33 | }, 34 | { 35 | "name": "Button Press Duration", 36 | "base": "examples/ButtonPressDuration", 37 | "files": [ "ButtonPressDuration.ino" ] 38 | } 39 | ], 40 | "frameworks": "arduino", 41 | "platforms": "espressif32", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /library.properties: -------------------------------------------------------------------------------- 1 | name=ESP32RotaryEncoder 2 | version=1.1.2 3 | author=Matthew Clark 4 | maintainer=Matthew Clark 5 | sentence=Use a rotary encoder with your ESP32 easily! 6 | paragraph=This library uses interrupts for instant detection of knob turns or button presses (with software de-bounce) without blocking or other delays. 7 | category=Signal Input/Output 8 | url=https://github.com/MaffooClock/ESP32RotaryEncoder 9 | architectures=esp32 10 | -------------------------------------------------------------------------------- /src/ESP32RotaryEncoder.cpp: -------------------------------------------------------------------------------- 1 | #include "ESP32RotaryEncoder.h" 2 | 3 | RotaryEncoder::RotaryEncoder( uint8_t encoderPinA, uint8_t encoderPinB, int8_t encoderPinButton, int8_t encoderPinVcc, uint8_t encoderSteps ) 4 | { 5 | this->encoderPinA = encoderPinA; 6 | this->encoderPinB = encoderPinB; 7 | this->encoderPinButton = encoderPinButton; 8 | this->encoderPinVcc = encoderPinVcc; 9 | this->encoderTripPoint = encoderSteps - 1; 10 | 11 | ESP_LOGD( LOG_TAG, "Initialized: A = %u, B = %u, Button = %i, VCC = %i, Steps = %u", encoderPinA, encoderPinB, encoderPinButton, encoderPinVcc, encoderSteps ); 12 | } 13 | 14 | RotaryEncoder::~RotaryEncoder() 15 | { 16 | detachInterrupts(); 17 | 18 | esp_timer_stop( loopTimer ); 19 | esp_timer_delete( loopTimer ); 20 | } 21 | 22 | void RotaryEncoder::setEncoderType( EncoderType type ) 23 | { 24 | switch( type ) 25 | { 26 | case FLOATING: 27 | encoderPinMode = INPUT_PULLUP; 28 | buttonPinMode = INPUT_PULLUP; 29 | break; 30 | 31 | case HAS_PULLUP: 32 | encoderPinMode = INPUT; 33 | buttonPinMode = INPUT; 34 | break; 35 | 36 | case SW_FLOAT: 37 | encoderPinMode = INPUT; 38 | buttonPinMode = INPUT_PULLUP; 39 | break; 40 | 41 | default: 42 | ESP_LOGE( LOG_TAG, "Invalid encoder type %i", type ); 43 | return; 44 | } 45 | 46 | ESP_LOGD( LOG_TAG, "Encoder type set to %i", type ); 47 | } 48 | 49 | void RotaryEncoder::setBoundaries( long minValue, long maxValue, bool circleValues ) 50 | { 51 | portENTER_CRITICAL( &mux ); 52 | 53 | if( minValue > maxValue ) 54 | ESP_LOGW( LOG_TAG, "Minimum value (%ld) is greater than maximum value (%ld); behavior is undefined.", minValue, maxValue ); 55 | 56 | setMinValue( minValue ); 57 | setMaxValue( maxValue ); 58 | setCircular( circleValues ); 59 | 60 | portEXIT_CRITICAL( &mux ); 61 | } 62 | 63 | void RotaryEncoder::setMinValue( long minValue ) 64 | { 65 | portENTER_CRITICAL( &mux ); 66 | 67 | ESP_LOGD( LOG_TAG, "minValue = %ld", minValue ); 68 | 69 | this->minEncoderValue = minValue; 70 | 71 | portEXIT_CRITICAL( &mux ); 72 | } 73 | 74 | void RotaryEncoder::setMaxValue( long maxValue ) 75 | { 76 | portENTER_CRITICAL( &mux ); 77 | 78 | ESP_LOGD( LOG_TAG, "maxValue = %ld", maxValue ); 79 | 80 | this->maxEncoderValue = maxValue; 81 | 82 | portEXIT_CRITICAL( &mux ); 83 | } 84 | 85 | void RotaryEncoder::setCircular( bool circleValues ) 86 | { 87 | portENTER_CRITICAL( &mux ); 88 | 89 | ESP_LOGD( LOG_TAG, "Boundaries %s circular", ( circleValues ? "are" : "are not" ) ); 90 | 91 | this->circleValues = circleValues; 92 | 93 | portEXIT_CRITICAL( &mux ); 94 | } 95 | 96 | void RotaryEncoder::setStepValue( long stepValue ) 97 | { 98 | portENTER_CRITICAL( &mux ); 99 | 100 | ESP_LOGD( LOG_TAG, "stepValue = %ld", stepValue ); 101 | 102 | if( stepValue > maxEncoderValue || stepValue < minEncoderValue ) 103 | ESP_LOGW( LOG_TAG, "Step value (%ld) is outside the bounds (%ld...%ld); behavior is undefined.", stepValue, minEncoderValue, maxEncoderValue ); 104 | 105 | this->stepValue = stepValue; 106 | 107 | portEXIT_CRITICAL( &mux ); 108 | } 109 | 110 | void RotaryEncoder::onTurned( EncoderCallback f ) 111 | { 112 | callbackEncoderChanged = f; 113 | } 114 | 115 | void RotaryEncoder::onPressed( ButtonCallback f ) 116 | { 117 | callbackButtonPressed = f; 118 | } 119 | 120 | void RotaryEncoder::beginLoopTimer() 121 | { 122 | /** 123 | * We're using esp_timer.h from ESP32 SDK rather than esp32-hal-timer.h from Arduino API 124 | * because the `timerAttachInterrupt()` won't accept std::bind like `attachInterrupt()` in 125 | * FunctionalInterrupt will. We have a static method that `timerAttachInterrupt()` will take, 126 | * but we'd lose instance context. But the `esp_timer_create_args_t` will let us set a callback 127 | * argument, which we set to `this`, so that the static method maintains instance context. 128 | * 129 | * As of 29 September 2023, there is an open issue to allow `std::function` in esp32-hal-timer: 130 | * https://github.com/espressif/arduino-esp32/issues/8427 131 | * 132 | * ...for now (and maybe forever?), we'll do it this way. 133 | */ 134 | 135 | esp_timer_create_args_t _timerConfig; 136 | _timerConfig.arg = this; 137 | _timerConfig.callback = reinterpret_cast( timerCallback ); 138 | _timerConfig.dispatch_method = ESP_TIMER_TASK; 139 | _timerConfig.skip_unhandled_events = true; 140 | _timerConfig.name = "RotaryEncoder::loop_ISR"; 141 | 142 | esp_timer_create( &_timerConfig, &loopTimer ); 143 | esp_timer_start_periodic( loopTimer, RE_LOOP_INTERVAL ); 144 | } 145 | 146 | void RotaryEncoder::attachInterrupts() 147 | { 148 | #if defined( BOARD_HAS_PIN_REMAP ) && ( ESP_ARDUINO_VERSION < ESP_ARDUINO_VERSION_VAL(3,0,0) ) 149 | /** 150 | * The io_pin_remap.h in Arduino-ESP32 cores of the 2.0.x family 151 | * (since 2.0.10) define an `attachInterrupt()` macro that folds-in 152 | * a call to `digitalPinToGPIONumber()`, but FunctionalInterrupt.cpp 153 | * does this too, so we actually don't need the macro at all. 154 | * Since 3.x the call inside the function was removed, so the wrapping 155 | * macro is useful again. 156 | */ 157 | #undef attachInterrupt 158 | #endif 159 | 160 | attachInterrupt( encoderPinA, std::bind( &RotaryEncoder::_encoder_ISR, this ), CHANGE ); 161 | attachInterrupt( encoderPinB, std::bind( &RotaryEncoder::_encoder_ISR, this ), CHANGE ); 162 | 163 | if( encoderPinButton > RE_DEFAULT_PIN ) 164 | attachInterrupt( encoderPinButton, std::bind( &RotaryEncoder::_button_ISR, this ), CHANGE ); 165 | 166 | ESP_LOGD( LOG_TAG, "Interrupts attached" ); 167 | } 168 | 169 | void RotaryEncoder::detachInterrupts() 170 | { 171 | detachInterrupt( encoderPinA ); 172 | detachInterrupt( encoderPinB ); 173 | detachInterrupt( encoderPinButton ); 174 | 175 | ESP_LOGD( LOG_TAG, "Interrupts detached" ); 176 | } 177 | 178 | void RotaryEncoder::begin( bool useTimer ) 179 | { 180 | resetEncoderValue(); 181 | 182 | encoderChangedFlag = false; 183 | buttonPressedFlag = false; 184 | buttonPressedTime = 0; 185 | buttonPressedDuration = 0; 186 | 187 | pinMode( encoderPinA, encoderPinMode ); 188 | pinMode( encoderPinB, encoderPinMode ); 189 | 190 | if( encoderPinButton > RE_DEFAULT_PIN ) 191 | pinMode( encoderPinButton, buttonPinMode ); 192 | 193 | if( encoderPinVcc > RE_DEFAULT_PIN ) 194 | { 195 | pinMode( encoderPinVcc, OUTPUT ); 196 | digitalWrite( encoderPinVcc, HIGH ); 197 | } 198 | 199 | delay( 20 ); 200 | attachInterrupts(); 201 | 202 | if( useTimer ) 203 | beginLoopTimer(); 204 | 205 | ESP_LOGD( LOG_TAG, "RotaryEncoder active" ); 206 | } 207 | 208 | bool RotaryEncoder::isEnabled() 209 | { 210 | return _isEnabled; 211 | } 212 | 213 | void RotaryEncoder::enable() 214 | { 215 | if( _isEnabled ) 216 | return; 217 | 218 | attachInterrupts(); 219 | 220 | _isEnabled = true; 221 | 222 | ESP_LOGD( LOG_TAG, "Input enabled" ); 223 | } 224 | 225 | void RotaryEncoder::disable() 226 | { 227 | if( !_isEnabled ) 228 | return; 229 | 230 | detachInterrupts(); 231 | 232 | _isEnabled = false; 233 | 234 | ESP_LOGD( LOG_TAG, "Input disabled" ); 235 | } 236 | 237 | bool RotaryEncoder::buttonPressed() 238 | { 239 | portENTER_CRITICAL( &mux ); 240 | 241 | if( !_isEnabled ) 242 | return false; 243 | 244 | if( buttonPressedFlag ) 245 | ESP_LOGD( LOG_TAG, "Button pressed for %lu ms", buttonPressedDuration ); 246 | 247 | bool wasPressed = buttonPressedFlag; 248 | 249 | buttonPressedFlag = false; 250 | 251 | portEXIT_CRITICAL( &mux ); 252 | 253 | return wasPressed; 254 | } 255 | 256 | bool RotaryEncoder::encoderChanged() 257 | { 258 | portENTER_CRITICAL( &mux ); 259 | 260 | if( !_isEnabled ) 261 | return false; 262 | 263 | if( encoderChangedFlag ) 264 | ESP_LOGD( LOG_TAG, "Knob turned; value: %ld", getEncoderValue() ); 265 | 266 | bool hasChanged = encoderChangedFlag; 267 | 268 | encoderChangedFlag = false; 269 | 270 | portEXIT_CRITICAL( &mux ); 271 | 272 | return hasChanged; 273 | } 274 | 275 | long RotaryEncoder::getEncoderValue() 276 | { 277 | portENTER_CRITICAL( &mux ); 278 | 279 | constrainValue(); 280 | 281 | long value = currentValue; 282 | 283 | portEXIT_CRITICAL( &mux ); 284 | 285 | return value; 286 | } 287 | 288 | void RotaryEncoder::constrainValue() 289 | { 290 | long unconstrainedValue = currentValue; 291 | 292 | if( currentValue < minEncoderValue ) 293 | currentValue = circleValues ? maxEncoderValue : minEncoderValue; 294 | 295 | else if( currentValue > maxEncoderValue ) 296 | currentValue = circleValues ? minEncoderValue : maxEncoderValue; 297 | 298 | if( unconstrainedValue != currentValue ) 299 | ESP_LOGD( LOG_TAG, "Encoder value '%ld' constrained to '%ld'", unconstrainedValue, currentValue ); 300 | } 301 | 302 | void RotaryEncoder::setEncoderValue( long newValue ) 303 | { 304 | portENTER_CRITICAL( &mux ); 305 | 306 | if( newValue != currentValue ) 307 | ESP_LOGD( LOG_TAG, "Overriding encoder value from '%ld' to '%ld'", currentValue, newValue ); 308 | 309 | currentValue = newValue; 310 | 311 | constrainValue(); 312 | 313 | portEXIT_CRITICAL( &mux ); 314 | } 315 | 316 | void ARDUINO_ISR_ATTR RotaryEncoder::loop() 317 | { 318 | if( callbackEncoderChanged != NULL && encoderChanged() ) 319 | callbackEncoderChanged( getEncoderValue() ); 320 | 321 | if( callbackButtonPressed != NULL && buttonPressed() ) 322 | callbackButtonPressed( buttonPressedDuration ); 323 | } 324 | 325 | void ARDUINO_ISR_ATTR RotaryEncoder::_button_ISR() 326 | { 327 | portENTER_CRITICAL_ISR( &mux ); 328 | 329 | static unsigned long _lastInterruptTime = 0; 330 | 331 | // Simple software de-bounce 332 | if( ( millis() - _lastInterruptTime ) < 30 ) 333 | { 334 | portEXIT_CRITICAL_ISR( &mux ); 335 | return; 336 | } 337 | 338 | // HIGH = idle, LOW = active 339 | bool isPressed = !digitalRead( encoderPinButton ); 340 | 341 | if( isPressed ) 342 | { 343 | buttonPressedTime = millis(); 344 | 345 | ESP_EARLY_LOGV( LOG_TAG, "Button pressed at %u", buttonPressedTime ); 346 | } 347 | else 348 | { 349 | unsigned long now = millis(); 350 | buttonPressedDuration = now - buttonPressedTime; 351 | 352 | ESP_EARLY_LOGV( LOG_TAG, "Button released at %u", now ); 353 | 354 | buttonPressedFlag = true; 355 | } 356 | 357 | _lastInterruptTime = millis(); 358 | 359 | portEXIT_CRITICAL_ISR( &mux ); 360 | } 361 | 362 | void ARDUINO_ISR_ATTR RotaryEncoder::_encoder_ISR() 363 | { 364 | portENTER_CRITICAL_ISR( &mux ); 365 | 366 | /** 367 | * Almost all of this came from a blog post by Garry on GarrysBlog.com: 368 | * https://garrysblog.com/2021/03/20/reliably-debouncing-rotary-encoders-with-arduino-and-esp32/ 369 | * 370 | * Read more about how this works here: 371 | * https://www.best-microcontroller-projects.com/rotary-encoder.html 372 | */ 373 | 374 | static uint8_t _previousAB = 3; 375 | static int8_t _encoderPosition = 0; 376 | static unsigned long _lastInterruptTime = 0; 377 | static long _stepValue; 378 | 379 | bool valueChanged = false; 380 | 381 | _previousAB <<=2; // Remember previous state 382 | 383 | if( digitalRead( encoderPinA ) ) _previousAB |= 0x02; // Add current state of pin A 384 | if( digitalRead( encoderPinB ) ) _previousAB |= 0x01; // Add current state of pin B 385 | 386 | _encoderPosition += encoderStates[( _previousAB & 0x0f )]; 387 | 388 | 389 | /** 390 | * Based on how fast the encoder is being turned, we can apply an acceleration factor 391 | */ 392 | 393 | unsigned long speed = micros() - _lastInterruptTime; 394 | 395 | if( speed > 40000UL ) // Greater than 40 milliseconds 396 | _stepValue = this->stepValue; // Increase/decrease by 1 x stepValue 397 | 398 | else if( speed > 20000UL ) // Greater than 20 milliseconds 399 | _stepValue = ( this->stepValue <= 9 ) ? // Increase/decrease by 3 x stepValue 400 | this->stepValue : ( this->stepValue * 3 ) // But only if stepValue > 9 401 | ; 402 | 403 | else // Faster than 20 milliseconds 404 | _stepValue = ( this->stepValue <= 100 ) ? // Increase/decrease by 10 x stepValue 405 | this->stepValue : ( this->stepValue * 10 ) // But only if stepValue > 100 406 | ; 407 | 408 | 409 | /** 410 | * Update counter if encoder has rotated a full detent 411 | * For the following comments, we'll assume it's 4 steps per detent 412 | * The tripping point is `STEPS - 1` (so, 3 in this example) 413 | */ 414 | 415 | if( _encoderPosition > encoderTripPoint ) // Four steps forward 416 | { 417 | this->currentValue += _stepValue; 418 | valueChanged = true; 419 | } 420 | else if( _encoderPosition < -encoderTripPoint ) // Four steps backwards 421 | { 422 | this->currentValue -= _stepValue; 423 | valueChanged = true; 424 | } 425 | 426 | if( valueChanged ) 427 | { 428 | encoderChangedFlag = true; 429 | 430 | // Reset our "step counter" 431 | _encoderPosition = 0; 432 | 433 | // Remember current time so we can calculate speed 434 | _lastInterruptTime = micros(); 435 | } 436 | 437 | portEXIT_CRITICAL_ISR( &mux ); 438 | } 439 | -------------------------------------------------------------------------------- /src/ESP32RotaryEncoder.h: -------------------------------------------------------------------------------- 1 | #ifndef _RotaryEncoder_h 2 | #define _RotaryEncoder_h 3 | 4 | #if defined( ARDUINO ) && ARDUINO >= 100 5 | #include 6 | 7 | #elif defined( WIRING ) 8 | #include 9 | 10 | #else 11 | #include 12 | #include 13 | 14 | #endif 15 | 16 | #if defined( ESP32 ) 17 | #define RE_ISR_ATTR IRAM_ATTR 18 | 19 | #ifdef ARDUINO_ISR_ATTR 20 | #undef ARDUINO_ISR_ATTR 21 | #define ARDUINO_ISR_ATTR IRAM_ATTR 22 | #endif 23 | 24 | #if defined( ESP_ARDUINO_VERSION ) && ( ESP_ARDUINO_VERSION == ESP_ARDUINO_VERSION_VAL(2,0,10) ) 25 | /** 26 | * BUG ALERT! 27 | * 28 | * With Arduino-ESP32 core 2.0.10, the #include statement below 29 | * fails to compile due to a bug. 30 | * Also see `attachInterrupts()` in ESP32RotaryEncoder.cpp for 31 | * the note about the `attachInterrupt()` macro in 2.x cores. 32 | */ 33 | #error Please upgrade the Arduino-ESP32 core to use this library. 34 | #else 35 | #include 36 | #endif 37 | #endif 38 | 39 | #define RE_DEFAULT_PIN -1 40 | #define RE_DEFAULT_STEPS 4 41 | #define RE_LOOP_INTERVAL 100000U // 0.1 seconds 42 | 43 | typedef enum { 44 | FLOATING, 45 | HAS_PULLUP, 46 | SW_FLOAT 47 | } EncoderType; 48 | 49 | class RotaryEncoder { 50 | 51 | protected: 52 | mutable portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; 53 | 54 | #if defined( ESP32 ) 55 | typedef std::function EncoderCallback; 56 | typedef std::function ButtonCallback; 57 | #else 58 | typedef void (*EncoderCallback)(long); 59 | typedef void (*ButtonCallback)(unsigned long); 60 | #endif 61 | 62 | 63 | public: 64 | 65 | /** 66 | * @brief Construct a new Rotary Encoder instance 67 | * 68 | * @param encoderPinA The A pin on the encoder, sometimes marked "CLK" 69 | * @param encoderPinB The B pin on the encoder, sometimes marked "DT" 70 | * @param encoderPinButton Optional; the pushbutton pin, could be marked "SW" 71 | * @param encoderPinVcc Optional; the voltage reference input, could be marked "+" or "V+" or "VCC"; defaults to -1, which is ignored 72 | * @param encoderSteps Optional; the number of steps per detent; usually 4 (default), could be 2 73 | */ 74 | RotaryEncoder( 75 | uint8_t encoderPinA, 76 | uint8_t encoderPinB, 77 | int8_t encoderPinButton = RE_DEFAULT_PIN, 78 | int8_t encoderPinVcc = RE_DEFAULT_PIN, 79 | uint8_t encoderSteps = RE_DEFAULT_STEPS 80 | ); 81 | 82 | /** 83 | * @brief Responsible for detaching interrupts and clearing the loop timer 84 | * 85 | */ 86 | ~RotaryEncoder(); 87 | 88 | /** 89 | * @brief Specifies whether the encoder pins need to use the internal pull-up resistors. 90 | * 91 | * @note Call this in `setup()`. 92 | * 93 | * @param type FLOATING if you're using a raw encoder not mounted to a PCB (internal pull-ups will be used); 94 | * HAS_PULLUP if your encoder is a module that has pull-up resistors, (internal pull-ups will not be used); 95 | * SW_FLOAT your encoder is a module that has pull-up resistors, but the resistor for the switch is missing (internal pull-up will be used for switch input only) 96 | */ 97 | void setEncoderType( EncoderType type ); 98 | 99 | /** 100 | * @brief Set the minimum and maximum values that the encoder will return. 101 | * 102 | * @note This is a convenience function that calls `setMinValue()`, `setMaxValue()`, and `setCircular()` 103 | * 104 | * @param minValue Minimum value (e.g. 0) 105 | * @param maxValue Maximum value (e.g. 10) 106 | * @param circleValues If true, turning past the maximum will wrap around to the minimum and vice-versa 107 | * If false (default), turning past the minimum or maximum will return that boundary 108 | */ 109 | void setBoundaries( long minValue, long maxValue, bool circleValues = false ); 110 | 111 | /** 112 | * @brief Set the minimum value that the encoder will return. 113 | * 114 | * @note Call this in `setup()` 115 | * 116 | * @param minValue Minimum value 117 | */ 118 | void setMinValue( long minValue ); 119 | 120 | /** 121 | * @brief Set the maximum value that the encoder will return. 122 | * 123 | * @note Call this in `setup()` 124 | * 125 | * @param maxValue Maximum value 126 | */ 127 | void setMaxValue( long maxValue ); 128 | 129 | /** 130 | * @brief Set whether the minimum or maximum value will wrap around to the other. 131 | * 132 | * @note Call this in `setup()` 133 | * 134 | * @param maxValue Maximum value 135 | */ 136 | void setCircular( bool circleValues ); 137 | 138 | /** 139 | * @brief Set the amount of increment/decrement by which the value tracked by the encoder will change. 140 | * 141 | * @note Call this in `setup()` 142 | * 143 | * @param stepValue Step value 144 | */ 145 | void setStepValue( long stepValue ); 146 | 147 | /** 148 | * @brief Set a function to fire every time the value tracked by the encoder changes. 149 | * 150 | * @note Call this in `setup()`. May be set/changed at runtime if needed. 151 | * 152 | * @param handler The function to call; it must accept one parameter 153 | * of type long, which will be the current value 154 | */ 155 | void onTurned( EncoderCallback f ); 156 | 157 | /** 158 | * @brief Set a function to fire every time the the pushbutton is pressed. 159 | * 160 | * @note Call this in `setup()`. May be set/changed at runtime if needed. 161 | * 162 | * @param handler The function to call; it must accept one parameter of type long, which 163 | * will be the duration (in milliseconds) that the button was active 164 | */ 165 | void onPressed( ButtonCallback f ); 166 | 167 | /** 168 | * @brief Sets up the GPIO pins specified in the constructor and attaches the ISR callback for the encoder. 169 | * 170 | * @note Call this in `setup()` after other "set" methods. 171 | * 172 | */ 173 | void begin( bool useTimer = true ); 174 | 175 | /** 176 | * @brief Enables the encoder knob and pushbutton if `disable()` was previously used. 177 | * 178 | */ 179 | void enable(); 180 | 181 | /** 182 | * @brief Disables the encoder knob and pushbutton. 183 | * 184 | * Knob rotation and button presses will have no effect until after `enable()` is called 185 | * 186 | */ 187 | void disable(); 188 | 189 | /** 190 | * @brief Confirms whether the encoder knob and pushbutton have been disabled. 191 | * 192 | */ 193 | bool isEnabled(); 194 | 195 | /** 196 | * @brief Check if the pushbutton has been pressed. 197 | * 198 | * @note Call this in `loop()` to fire a handler. 199 | * 200 | * @return true if the button was pressed since the last time it was checked, 201 | * false if the button has not been pressed since the last time it was checked 202 | */ 203 | bool buttonPressed(); 204 | 205 | /** 206 | * @brief Check if the value tracked by the encoder has changed. 207 | * 208 | * @note Call this in `loop()` to fire a handler for the new value. 209 | * 210 | * @return true if the value is different than the last time it was checked, 211 | * false if the value is the same as the last time it was checked 212 | */ 213 | bool encoderChanged(); 214 | 215 | /** 216 | * @brief Get the current value tracked by the encoder. 217 | * 218 | * @return A value between the minimum and maximum configured by `setBoundaries()` 219 | */ 220 | long getEncoderValue(); 221 | 222 | /** 223 | * @brief Override the value tracked by the encoder. 224 | * 225 | * @note If the new value is outside the minimum or maximum configured 226 | * by `setBoundaries()`, it will be adjusted accordingly 227 | * 228 | * @param newValue 229 | */ 230 | void setEncoderValue( long newValue ); 231 | 232 | /** 233 | * @brief Reset the value tracked by the encoder. 234 | * 235 | * @note This will try to set the value to 0, but if the minimum and maximum configured 236 | * by `setBoundaries()` does not include 0, then the minimum or maximum will be 237 | * used instead 238 | * 239 | */ 240 | void resetEncoderValue() { setEncoderValue( 0 ); } 241 | 242 | /** 243 | * @brief Synchronizes the encoder value and button state from ISRs. 244 | * 245 | * Runs on a timer and calls `encoderChanged()` and `buttonPressed()` to determine 246 | * if user-specified callbacks should be run. 247 | * 248 | * This would normally be called in userspace `loop()`, but we're using the `loopTimer` instead. 249 | * 250 | */ 251 | void ARDUINO_ISR_ATTR loop(); 252 | 253 | private: 254 | 255 | const char *LOG_TAG = "ESP32RotaryEncoder"; 256 | 257 | EncoderCallback callbackEncoderChanged = NULL; 258 | ButtonCallback callbackButtonPressed = NULL; 259 | 260 | typedef enum { 261 | LEFT = -1, 262 | STILL = 0, 263 | RIGHT = 1 264 | } Rotation; 265 | 266 | Rotation encoderStates[16] = { 267 | STILL, LEFT, RIGHT, STILL, 268 | RIGHT, STILL, STILL, LEFT, 269 | LEFT, STILL, STILL, RIGHT, 270 | STILL, RIGHT, LEFT, STILL 271 | }; 272 | 273 | int encoderPinMode = INPUT; 274 | int buttonPinMode = INPUT; 275 | 276 | uint8_t encoderPinA; 277 | uint8_t encoderPinB; 278 | int8_t encoderPinButton; 279 | int8_t encoderPinVcc; 280 | uint8_t encoderTripPoint; 281 | 282 | /** 283 | * @brief Determines whether knob turns or button presses will be ignored. ISRs still fire, 284 | * 285 | * Set by `enable()` and `disable()`. 286 | * 287 | */ 288 | bool _isEnabled = true; 289 | 290 | /** 291 | * @brief Sets the minimum and maximum values of `currentValue`. 292 | * 293 | * Set in `setBoundaries()` and used in `constrainValue()`. 294 | * 295 | */ 296 | long minEncoderValue = -1; long maxEncoderValue = 1; 297 | 298 | /** 299 | * @brief The amount of increment/decrement that will be applied to the 300 | * encoder value when the knob is turned. 301 | * 302 | */ 303 | long stepValue = 1; 304 | 305 | /** 306 | * @brief Determines whether attempts to increment or decrement beyond 307 | * the boundaries causes `currentValue` to wrap to the other boundary. 308 | * 309 | * Set in `setBoundaries()`. 310 | * 311 | */ 312 | bool circleValues = false; 313 | 314 | /** 315 | * @brief The value tracked by `encoder_ISR()` when the encoder knob is turned. 316 | * 317 | * This value can be overwritten in `constrainValue()` whenever 318 | * `getEncoderValue()` or `setEncoderValue()` are called. 319 | * 320 | */ 321 | volatile long currentValue; 322 | 323 | /** 324 | * @brief Becomes `true` when `encoder_ISR()` changes `currentValue`, 325 | * then becomes `false` when caught by `loop()` via `encoderChanged()` 326 | * 327 | */ 328 | volatile bool encoderChangedFlag; 329 | 330 | /** 331 | * @brief Becomes `true` when `button_ISR()` changes `currentValue`, 332 | * then becomes `false` when caught by `loop()` via `buttonPressed()` 333 | * 334 | */ 335 | volatile bool buttonPressedFlag; 336 | 337 | /** 338 | * @brief 339 | * 340 | */ 341 | volatile unsigned long buttonPressedTime, buttonPressedDuration; 342 | 343 | /** 344 | * @brief The loop timer configured and started in `beginLoopTimer()`. 345 | * 346 | * This replaces the need to run the class loop in userspace `loop()`. 347 | * 348 | */ 349 | esp_timer_handle_t loopTimer; 350 | 351 | /** 352 | * @brief Constrains the value set by `encoder_ISR()` and `setEncoderValue()` 353 | * to be in the range set by `setBoundaries()`. 354 | * 355 | */ 356 | void constrainValue(); 357 | 358 | /** 359 | * @brief Attaches ISRs to encoder and button pins. 360 | * 361 | * Used in `begin()` and `enable()`. 362 | * 363 | */ 364 | void attachInterrupts(); 365 | 366 | /** 367 | * @brief Detaches ISRs from encoder and button pins. 368 | * 369 | * Used in the destructor and in `disable()`. 370 | * 371 | */ 372 | void detachInterrupts(); 373 | 374 | /** 375 | * @brief Sets up the loop timer and starts it. 376 | * 377 | * Called in `begin()`. 378 | * 379 | */ 380 | void beginLoopTimer(); 381 | 382 | /** 383 | * @brief Static method called by the loop timer, which calls the loop function on a given instance. 384 | * 385 | * We need this because the timer cannot call a class method directly, but it can 386 | * call a static method with an argument, so this gets around that limitation. 387 | * 388 | * @param arg 389 | */ 390 | static void ARDUINO_ISR_ATTR timerCallback( void *arg ) 391 | { 392 | RotaryEncoder *instance = (RotaryEncoder *)arg; 393 | instance->loop(); 394 | } 395 | 396 | /** 397 | * @brief Interrupt Service Routine for the encoder. 398 | * 399 | * Detects direction of knob turn and increments/decrements `currentValue`, and sets 400 | * the `encoderChangedFlag` to be picked up by `encoderChanged()` in `_loop()`. 401 | * 402 | */ 403 | void ARDUINO_ISR_ATTR _encoder_ISR(); 404 | 405 | /** 406 | * @brief Interrupt Service Routine for the pushbutton. 407 | * 408 | * Sets the `buttonPressedFlag` to be picked up by `buttonPressed()` in `_loop()`. 409 | * 410 | */ 411 | void ARDUINO_ISR_ATTR _button_ISR(); 412 | }; 413 | 414 | #endif 415 | --------------------------------------------------------------------------------