Select Page

Using KY-040 rotary encoder on Raspberry Pi to control volume

The last week I have been posting about using Raspberry Pi, HiFiBerry BEOCREATE and Moode Audio to revive my vintage Grundig Mandello e/St music cabinet. It is all working just fine, one last thing that had to be done is to put back to function a rotary controller. In the original box from 1969, it has been of course connected to the potentiometer. Well, that potentiometer was unfortunately the very first thing to die from the original equipment.

Why did I want a knob in the first place? I can control the volume through the app? First of all, there was already a hole drilled where rotary knob should come. I couldn’t just leave it there. Sure, I could cover it with a non-functional dummy knob, but that would be just lame. Plus, I really wanted to do it 🙂

I’ve got a Frei KY‑040 rotary encoder, which works well with the Raspberry Pi. A nice small piece of tech, well documented, and relatively easy to wire with the Pi.

It has got five pins – ground (GND), power (+), SW (push button switch), CLK (clock) and DT (direction). In theory, you need to connect those pins to the corresponding GPIO pins of the Raspberry PI, write a small Python script (or a C program) to fetch the input signals and control the volume, and the thing would work.

Well, of course things wouldn’t be that simple.

The main issue was that I did not want to send the vol+ and vol- signals directly to the BEOCREATE amplifier, since MoodeAudio software (which I am using as a media player) wouldn’t know anything about it, and those two would be out of sync. If someone would, for example, first change the volume with the knob (directly on the amplifier), and then through the app on Moode, the results would be very “interesting”, to say at least. No, I wanted my rotary knob to be sending instructions to the software, not to the amp. “All” what I wanted to do, is to send GET requests to the MoodeAudio APIs.

But wait – in that case, my rotary encoder did not even have to be connected to that Raspberry Pi, which was connected to the BEOCREATE! That way I didn’t have to fiddle with the “production” machine, fight for the processor time, or solder anything on the bottom side of the GPIO pins. So, another Raspberry Pi device was needed to join the setup!

In essence, an ultra cheap Raspberry Pi Zero would be more than enough for that, but, since I already had an old Pi 3 laying unused somewhere in a drawer, it was the time to power it up again. I have started with a fresh installation of Raspbian Lite (no need for a GUI here). Once that has been set up, it was a time to connect the rotary encoder to that Pi. This is the schema I was using:

GND “GND” (physical PIN 6)Ground
+3v3 Power (physical PIN 1)Power supply
SWPin 27 (physical PIN 13)Push button switch control
CLKPin 18 (physical PIN 12)Clock
DTPin 17 (physical PIN 11)Direction

The vast majority of schemas that you will find on internet does not include the “purple” line, which connecting the SW pin on the rotary control to the GPIO Pin 27. I have no idea why – the “switch push” gesture, when you “press” the knob can be used for a lot of interesting functions, such as mute/unmute, pause/resume, etc.

The next step was to write a Python script for fetching those signals, and doing some work. First, I wrote a very basic script to test how the rotary encoder was working:

import os
from RPi import GPIO

os.system('clear') #clear screen, this is just for the OCD purposes

step = 5 #linear steps for increasing/decreasing volume
paused = False #paused state

#tell to GPIO library to use logical PIN names/numbers, instead of the physical PIN numbers
GPIO.setmode(GPIO.BCM) 

#set up the pins we have been using
clk = 17
dt = 18
sw = 27

#set up the GPIO events on those pins
GPIO.setup(clk, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(dt, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
GPIO.setup(sw, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)

#get the initial states
counter = 0
clkLastState = GPIO.input(clk)
dtLastState = GPIO.input(dt)
swLastState = GPIO.input(sw)

#define functions which will be triggered on pin state changes
def clkClicked(channel):
        global counter
        global step

        clkState = GPIO.input(clk)
        dtState = GPIO.input(dt)

        if clkState == 0 and dtState == 1:
                counter = counter + step
                print ("Counter ", counter)

def dtClicked(channel):
        global counter
        global step

        clkState = GPIO.input(clk)
        dtState = GPIO.input(dt)
        
        if clkState == 1 and dtState == 0:
                counter = counter - step
                print ("Counter ", counter)

def swClicked(channel):
        global paused
        paused = not paused
        print ("Paused ", paused)             
                
print ("Initial clk:", clkLastState)
print ("Initial dt:", dtLastState)
print ("Initial sw:", swLastState)
print ("=========================================")

#set up the interrupts
GPIO.add_event_detect(clk, GPIO.FALLING, callback=clkClicked, bouncetime=300)
GPIO.add_event_detect(dt, GPIO.FALLING, callback=dtClicked, bouncetime=300)
GPIO.add_event_detect(sw, GPIO.FALLING, callback=swClicked, bouncetime=300)

raw_input("Start monitoring input")

GPIO.cleanup()

The Python script above “fetches” the impulses coming to the GPIO pins, and then increases or decreases the “counter” variable value, depending if the rotary encoder has been turned clockwise or counterclockwise.

But how do we know, when has it been turned clockwise, and when counterclockwise? It is important to know, that the “natural”, “default” state of all pins (CLK, DT, SW) is “1”. So we are checking when this value will change.

For a clockwise rotation, you will get two consecutive “signals”. After the first signal, CLK will be 0 (CLK has changed), whereas DT remains 1. After the second signal, both CLK and DT will be set to 0.

Clockwise rotationCLK StateDT State
1st signal01
2nd signal00

For a counterclockwise rotation, you will also get two consecutive signals. After the first signal, DT will be 0, whereas CLK remains 1. After the second signal, both CLK and DT will be set to 0.

Counterclockwise rotationCLK StateDT State
1st signal10
2nd signal00

In essence, we will catch the CLK and DT interrupts, and check for these combinations. As you are turning the knob clockwise or counterclockwise, you will see the Counter being increased or decreased by the step you have declared (5 in my case).

You can of course get very playful here, and measure the time intervals between interrupts, and increase/decrease step based on the time difference. So, for example, when someone is turning the knob fast, you can use exponential volume increase/decrease, whereas if the knob is being turned slower, you can keep it linear. This way, you are making the feeling of the “fast” but also “fine” volume control change.

Catching the push switch button event is easy. It has got its own interrupt, and you don’t need to check for the combination of the values, like with the knob turning gestures. In my scenario, I will be using it to pause/resume the music play. The source code for that interrupt is a very simple one.

But, we do not want just to increase/decrease the value of a “counter” variable, we actually want to turn the volume up or down. For Moode Audio, a media player which is based on the MPD server, those are the GET requests that you need to call in order to preform the volume and music control operations:

Playhttp://moode/command?cmd=play
Skip to next songhttp://moode/command?cmd=next
Stophttp://moode/command?cmd=stop
Pause / Resumehttp://moode/command?cmd=pause
Volume up by 5%http://moode/command?cmd=vol.sh up 5
Volume down by 5%http://moode/command?cmd=vol.sh dn 5
Mute / Unmutehttp://moode/command?cmd=vol.sh mute
Set volume to 25%http://moode/command?cmd=vol.sh 25

You will of course want to change the URL to the host name of your media player.

The code then looks something like this:

import requests

#here we define the commands we want to send to the player
urlmoode = "http://moode-studio/command"
paramVolUp = {'cmd' : 'vol.sh up 5'}
paramVolDn = {'cmd' : 'vol.sh dn 5'}
paramMute = {'cmd' : 'vol.sh mute'}
paramStop = {'cmd' : 'stop'}
paramPlay = {'cmd' : 'play'}
paramPause = {'cmd' : 'pause'}

#get request for volume down
r = requests.get(url = urlmoode, params = paramVolDn)

#get request for volume up
r = requests.get(url = urlmoode, params = paramVolUp)

#get request for pause/play
r = requests.get(url = urlmoode, params = paramPause)

Now, one thing to consider here is, if you make those GET requests synchronously, they will necessarily slow down your code, because they need time to execute, and some of the interrupts from the rotary encoder might be skipped. That is not a pleasant user experience. This is why I am suggesting you either use multithreading, or async patterns here. That way, your script will remain “responsive”, even if another thread, or an async block of code is executing that GET request somewhere.

These “delays”, caused by the web service calls, are in the same time the only reason why I would possibly think of sending the vol+ or vol- signals directly to the amplifier, instead of sending them to the software. In that case, the whole pause/resume use case would not be supported, the real volume would not be in sync to that what your player thinks it is, but it WOULD perform faster. I have still decided against it. But, if you are considering going down that path, please keep in mind that the GPIO pins 17, 18 and 27 are reserved by many amplifiers and DACs, for example BEOCREATE. In that case, think of using GPIO pins 5, 6, 12, 13 and 16 instead of the abovementioned ones – they are usually free. BEOCREATE, for example, exposes them through the own set of pins.

That would be all about rotary encoder and Pi. Once you grasp the concepts of clock and direction within the encoder, and once you understand the function of the GPIO pins on the raspberry board, it is a very simple procedure. The code itself can be made fancy (with exponential increase etc.), but the starter code here can get you going.

Previous