2024-06-20

使用 中斷請求 獲取 旋轉編碼器 的 轉動方向

在下最近打算製作一個可以調整音量的工具
雖然一鍵一功能的設計不需要太多學習要求,製作又直觀,但佔據較多空間,因此想使用一個零件完成這個操作
例如以旋鈕順時針轉動提升音量、逆時針轉動降低音量,旋鈕按下時切換靜音

外觀

旋轉編碼器(Rotary Encoder)電位器(Potentiometer) 很相似
但 旋轉編碼器 內部並 沒有電阻,而 軸心 能夠無限制地轉動

引腳

一般 旋轉編碼器 通常有 3支引腳 ,分別是: 1號訊號引腳2號訊號引腳公共引腳
普遍 旋轉編碼器 的 軸心 還具備 按壓按鈕 功能,因此共有 5支引腳
通常為了方便區分,2支引腳為按壓按鈕功能,沒有區分功能,其中一支接地
3支引腳中,左引腳及右引腳分別為 2支訊號引腳,中央的引腳為 公共引腳(Common) ,通常連接到 接地

還有一些 旋轉編碼器 焊接到 印刷電路板 上,並標記引腳的功能

一款常見的模組化 旋轉編碼器 ,稱為 KY-040 ,通常會在一些 STEM 學習套裝中一同發售
印刷電路板 背面的線路焊接 電阻器 作為 上拉電阻

還有一些 旋轉編碼器 會焊接電容器 ,令訊號比較穩定

運作原理

0 1

實際上 2支訊號引腳 與 按壓按鈕 的功能完全相同,只是型態與操作方式不同
軸心 連接到到 公共引腳,而 2支訊號引腳 分別連接到 2個固定的終端
轉動軸心時,公共引腳 會依特定次序連接到 2個終端

L H H L L H H L L H H L L H H L L L H H L L H H L L H H L L H H 0 1 2 3 0 1 2 3 0 1 2 3 0 1 2 3 PinA PinB L H H L L H H L L H H L L H H L L L H H L L H H L L H H L L H H 3 2 1 0 3 2 1 0 3 2 1 0 3 2 1 0

由於轉動軸心的時間短而且快,因此會快速地切換 2個終端的狀態
從而產生 2組相同但有時間偏差的訊號,便可以得知轉動的方向

格雷碼

根據轉動軸心而令 2個端終 產生一種有次序的訊號,稱為 格雷碼(Gray Code)

引腳A 引腳B 狀態(順時針)
0 0 0 (靜止狀態)
1 0 1
1 1 2
0 1 3
0 0 0 (完成)
引腳A 引腳B 狀態(逆時針)
0 0 0 (靜止狀態)
0 1 3
1 1 2
1 0 1
0 0 0 (完成)

從狀態表中會發現當順時針轉動時,狀態次序為 01230 ;而當逆時針轉動時,狀態次序為 03210

然而在實際的電路中,由於電子訊號太快,不可能偵測到完整的 01230 或 03210 的訊號
因此順時針轉動時,只需要判斷 01, 12, 23, 30 其中一個狀態
而逆時針轉動時,同樣只需要判斷 03, 32, 21, 10 其中一個狀態

線路原型

在下使用 Raspberry Pi Pico 來測試

實際線路

由於原裝的 Raspberry Pi Pico 使用 Micro USB-Type B 插口
碰巧在下測試時找不到 Micro USB-Type B 的連接線
因此使用 Raspberry Pi Pico 的兼容開發板測試

編寫程式

Arduino
#define r1 14
#define r2 15
byte oldState = 0;
byte newState = 0;
int position = 0;
void setup() {
	Serial.begin(115200);
	pinMode(r1, INPUT_PULLUP);
	pinMode(r2, INPUT_PULLUP);
}
void loop() {
	bool v1 = !digitalRead(r1);
	bool v2 = !digitalRead(r2);
	if (!v1 && !v2) {
		newState = 0;
	} else if (v1 && !v2) {
		newState = 1;
	} else if (v1 && v2) {
		newState = 2;
	} else if (!v1 && v2) {
		newState = 3;
	}
	if (oldState == 3 && newState == 0) {
		position++;
		Serial.println(position);
	} else if (oldState == 1 && newState == 0) {
		position--;
		Serial.println(position);
	}
	oldState = newState;
}

使用 Arduino IDE 編寫的 Sketch

MicroPython
from machine import Pin
r1 = Pin(14, Pin.IN, Pin.PULL_UP)
r2 = Pin(15, Pin.IN, Pin.PULL_UP)
old_state = 0
new_state = 0
position = 0
while True:
	v1 = not r1.value()
	v2 = not r2.value()
	if not v1 and not v2:
		new_state = 0
	elif v1 and not v2:
		new_state = 1
	elif v1 and v2:
		new_state = 2
	elif not v1 and v2:
		new_state = 3
	if old_state == 3 and new_state == 0:
		position += 1
		print(position)
	elif old_state == 1 and new_state == 0:
		position -= 1
		print(position)
	old_state = new_state

在下亦使用 MicroPython 達到相同效果

CircuitPython

在下亦編寫 CircuitPython 測試

import board
from digitalio import DigitalInOut, Direction, Pull
r1 = DigitalInOut(board.GP14)
r1.direction = Direction.INPUT
r1.pull = Pull.UP
r2 = DigitalInOut(board.GP15)
r2.direction = Direction.INPUT
r2.pull = Pull.UP
old_state = 0
new_state = 0
position = 0
while True:
	v1 = not r1.value
	v2 = not r2.value
	if not v1 and not v2:
		new_state = 0
	elif v1 and not v2:
		new_state = 1
	elif v1 and v2:
		new_state = 2
	elif not v1 and v2:
		new_state = 3
	if old_state == 3 and new_state == 0:
		position += 1
		print(position)
	elif old_state == 1 and new_state == 0:
		position -= 1
		print(position)
	old_state = new_state

CircuitPython 與 MicroPython 的程式碼接近相同

測試效果

閣下如果仔細觀察的話,會發現轉動軸心時,出現一個錯誤數值
原因是 旋轉編碼器 有 制動設計,有機會在轉動時產生 回彈

物件導向

在下以 物件導向(Object Oriented) 方式編寫 旋轉編碼器類別 ,將 旋轉編碼器 與 主程式 分離
方便管理 主程式 與 其他裝置 互動

Arduino
// RotaryEncoder.h
class RotaryEncoder {
	private:
		byte r1;
		byte r2;
		byte oldState = 0;
		byte newState = 0;
		int position = 0;
	public:
		RotaryEncoder(byte r1, byte r2) {
			this->r1 = r1;
			this->r2 = r2;
			pinMode(this->r1, INPUT_PULLUP);
			pinMode(this->r2, INPUT_PULLUP);
		}
		void setPosition(int position) {
			this->position = position;
		}
		int getPosition() {
			bool v1 = !digitalRead(this->r1);
			bool v2 = !digitalRead(this->r2);
			if (!v1 && !v2) {
				newState = 0;
			} else if (v1 && !v2) {
				newState = 1;
			} else if (v1 && v2) {
				newState = 2;
			} else if (!v1 && v2) {
				newState = 3;
			}
			if (this->oldState == 3 && this->newState == 0) {
				position++;
			} else if (this->oldState == 1 && this->newState == 0) {
				position--;
			}
			this->oldState = this->newState;
			return this->position;
		}
};
// .ino
#include "RotaryEncoder.h"
RotaryEncoder rotaryEncoder(14, 15);
int oldPosition = 0;
void setup() {
	Serial.begin(115200);
}
void loop() {
	int position = rotaryEncoder.getPosition();
	if (oldPosition != position) {
		oldPosition = position;
		Serial.println(position);
	}
}
MicroPython
# rotary_encoder.py
from machine import Pin
class RotaryEncoder:
	def __init__(self, r1Pin, r2Pin):
		self.__r1 = Pin(r1Pin, Pin.IN, Pin.PULL_UP)
		self.__r2 = Pin(r2Pin, Pin.IN, Pin.PULL_UP)
		self.__old_state = 0
		self.__new_state = 0
		self.__position = 0
	def set_position(self, position):
		self.__position = position
	def get_position(self):
		v1 = not self.__r1.value()
		v2 = not self.__r2.value()
		if not v1 and not v2:
			self.__new_state = 0
		elif v1 and not v2:
			self.__new_state = 1
		elif v1 and v2:
			self.__new_state = 2
		elif not v1 and v2:
			self.__new_state = 3
		if self.__old_state == 3 and self.__new_state == 0:
			self.__position += 1
		elif self.__old_state == 1 and self.__new_state == 0:
			self.__position -= 1
		self.__old_state = self.__new_state
		return self.__position
# main.py
from rotary_encoder import RotaryEncoder
rotary_encoder = RotaryEncoder(14, 15)
old_position = 0
while True:
	position = rotary_encoder.get_position()
	if old_position != position:
		old_position = position
		print(position)
CircuitPython
# rotary_encoder.py
from digitalio import DigitalInOut, Direction, Pull
class RotaryEncoder:
	def __init__(self, r1Pin, r2Pin):
		self.__r1 = DigitalInOut(r1Pin)
		self.__r1.direction = Direction.INPUT
		self.__r1.pull = Pull.UP
		self.__r2 = DigitalInOut(r2Pin)
		self.__r2.direction = Direction.INPUT
		self.__r2.pull = Pull.UP
		self.__old_state = 0
		self.__new_state = 0
		self.__position = 0
	def set_position(self, position):
		self.__position = position
	def get_position(self):
		v1 = not self.__r1.value
		v2 = not self.__r2.value
		if not v1 and not v2:
			self.__new_state = 0
		elif v1 and not v2:
			self.__new_state = 1
		elif v1 and v2:
			self.__new_state = 2
		elif not v1 and v2:
			self.__new_state = 3
		if self.__old_state == 3 and self.__new_state == 0:
			self.__position += 1
		elif self.__old_state == 1 and self.__new_state == 0:
			self.__position -= 1
		self.__old_state = self.__new_state
		return self.__position
# code.py
import board
from rotary_encoder import RotaryEncoder
rotary_encoder = RotaryEncoder(board.GP14, board.GP15)
old_position = 0
while True:
	position = rotary_encoder.get_position()
	if old_position != position:
		old_position = position
		print(position)

基本上 Arduino 及 MicroPython 及 CircuitPython 大致上相同

CircuitPython 提供 原生函式庫 的 旋轉編碼器類別 來應用 旋轉編碼器
因此實際不需要自行編寫 旋轉編碼器類別

import board
from rotaryio import IncrementalEncoder
encoder = IncrementalEncoder(board.GP14, board.GP15)
old_position = 0
while True:
	position = encoder.position
	if old_position != position:
		old_position = position
		print(position)

中斷請求

不論使用 Arduino 或 MicroPython 或 CircuitPython ,都需要不斷檢查引腳的訊號
再因應訊號來執行對應操作,因此通常會將這些檢查編寫在迴圈中
這種方法稱為 輪詢(Polling)

由於 輪詢 需要不斷重覆檢查訊號,即使沒有訊號變化亦會檢查,導致資源浪費
如果需要檢查的裝置很多,編寫的內容亦會非常混亂,運作的速度亦會變較慢

在下發現很多 旋轉編碼器 的例子都會使用 中斷(Interrupt) 來達到效果
而且使用 中斷 的情況下,都不需要在 Arduino Sketch 的 loop 中編寫內容亦能達到檢查效果

Arduino

Arduino 可以使用 attachInterrupt(interruptNumber, callbackFunction, mode) 來達到效果

  • interruptNumber 不是 引腳編號,而是 中斷編號
    需要配合 digitalPinToInterrupt 將 引腳編號 轉換為 中斷編號
  • callbackFunction 是 回呼功能
  • mode 設定在甚麼狀態觸發 中斷
    • CHANGE 當訊號 改變時觸發
    • FALLING 當訊號 由高至低變化時觸發
    • RISING 當訊號 由低至高變化時觸發
    • LOW 當訊號 在低電壓時觸發
    • HIGH 當訊號 在高電壓時觸發(不是所有微控制器都支援)

了解用法後,可以之前的程式改寫成

#define r1 14
#define r2 15
#define sw 16
byte oldState = 0;
byte newState = 0;
int position = 0;
void rotateHandler() {
	bool v1 = !digitalRead(r1);
	bool v2 = !digitalRead(r2);
	if (!v1 && !v2) {
		newState = 0;
	} else if (v1 && !v2) {
		newState = 1;
	} else if (v1 && v2) {
		newState = 2;
	} else if (!v1 && v2) {
		newState = 3;
	}
	if (oldState == 3 && newState == 0) {
		position++;
		Serial.println(position);
	} else if (oldState == 1 && newState == 0) {
		position--;
		Serial.println(position);
	}
	oldState = newState;
}
void pressHandler() {
	if (!digitalRead(sw)) {
		position = 0;
		Serial.println(position);
	}
}
void setup() {
	Serial.begin(115200);
	pinMode(r1, INPUT_PULLUP);
	pinMode(r2, INPUT_PULLUP);
	pinMode(sw, INPUT_PULLUP);
	attachInterrupt(digitalPinToInterrupt(r1), rotateHandler, CHANGE);
	attachInterrupt(digitalPinToInterrupt(r2), rotateHandler, CHANGE);
	attachInterrupt(digitalPinToInterrupt(sw), pressHandler, CHANGE);
}
void loop() {
}

同樣能夠做到相同的效果

MicroPython

而 MicroPython 則使用 Pin.irq(callbackFunction, mode) 來達成

  • callbackFunction 是 回呼功能(與 Arduino 相似)
  • mode 設定在甚麼狀態觸發 中斷
    • Pin.IRQ_FALLING 當訊號由高至低變化時觸發
    • Pin.IRQ_RISING 當訊號由低至高變化時觸發

MicroPython 只有 2種觸發狀態 ,如果將 2種觸發狀態 同時觸發 (即是 Pin.IRQ_FALLING | Pin.IRQ_RISING)
都只有 3種觸發狀態 ,並沒有 低電壓 或 高電壓 的 觸發狀態

from machine import Pin
r1 = Pin(14, Pin.IN, Pin.PULL_UP)
r2 = Pin(15, Pin.IN, Pin.PULL_UP)
sw = Pin(16, Pin.IN, Pin.PULL_UP)
old_state = 0
new_state = 0
position = 0
def rotate_handler(pin):
	global r1, r2, old_state, new_state, position
	v1 = not r1.value()
	v2 = not r2.value()
	if not v1 and not v2:
		new_state = 0
	elif v1 and not v2:
		new_state = 1
	elif v1 and v2:
		new_state = 2
	elif not v1 and v2:
		new_state = 3
	if old_state == 3 and new_state == 0:
		position += 1
		print(position)
	elif old_state == 1 and new_state == 0:
		position -= 1
		print(position)
	old_state = new_state
def press_handler(pin):
	global sw, position
	if not sw.value():
		position = 0
		print(position)
r1.irq(rotate_handler, Pin.IRQ_RISING | Pin.IRQ_FALLING)
r2.irq(rotate_handler, Pin.IRQ_RISING | Pin.IRQ_FALLING)
sw.irq(press_handler, Pin.IRQ_RISING | Pin.IRQ_FALLING)
while True:
	pass

效果與 Arduino 相同

CircuitPython

CiruitPython 截至發佈文章前暫時沒有 中斷 ,但如果只是針對 旋轉編碼器 的實作
雖然 Adafruit 有提及使用 asyncio 及 countio 來達到效果,但實際並不是 中斷
而且 countio 並不支援 RP2040 ,即是 Raspberry Pi Pico 及其他使用 RP2040 的 微控制器都無法使用 中斷

語法限制

雖然 Arduino 及 MicroPython 都使用相似的方式 ,但 callbackFunction 存在限制

  • 不能提交擁有參數 回呼功能 (MicroPython 中斷 的 回呼功能 必須只有 1個參數)
  • 如果 回呼功能 在 類別 中使用,只可使用 靜態方法(Static Method)全局方法(Global Method)
  • 雖然支援 Lamdba 方法,但 Lamdba 只能存取 全局變數(Global Variable)
MicroPython

這些情況 Arduino 無法解決,但 MicroPython 卻不限制 回呼功能 只能使用 靜態方法
因此可以使用 物件方法(Object Method) 來替代,便可以同時使用 物件導向 及 中斷

# rotary_encoder.py
from machine import Pin
class RotaryEncoder:
	def __init__(self, r1Pin, r2Pin):
		self.__r1 = Pin(r1Pin, Pin.IN, Pin.PULL_UP)
		self.__r2 = Pin(r2Pin, Pin.IN, Pin.PULL_UP)
		self.__r1.irq(self.__rotate, Pin.IRQ_RISING | Pin.IRQ_FALLING)
		self.__r2.irq(self.__rotate, Pin.IRQ_RISING | Pin.IRQ_FALLING)
		self.__old_state = 0
		self.__new_state = 0
		self.position = 0
	def __rotate(self, pin):
		v1 = not self.__r1.value()
		v2 = not self.__r2.value()
		if not v1 and not v2:
			self.__new_state = 0
		elif v1 and not v2:
			self.__new_state = 1
		elif v1 and v2:
			self.__new_state = 2
		elif not v1 and v2:
			self.__new_state = 3
		if self.__old_state == 3 and self.__new_state == 0:
			self.position += 1
		elif self.__old_state == 1 and self.__new_state == 0:
			self.position -= 1
		self.__old_state = self.__new_state
# main.py
import rotary_encoder from RotaryEncoder
rotary_encoder = RotaryEncoder(14, 15)
old_position = 0
while True:
	position = rotary_encoder.position
	if old_position != position:
		old_position = position
		print(position)

MicroPython 暫時唯一能夠同時使用 物件導向 及 中斷,而且還可以省卻 setter 及 getter

補充資料

改裝

由於在下想用 原始的旋轉編碼器 ,不希望有太多電子零件影響實際效果,但原本的引腳又不方便連接到麵包板
因此萬用板改裝自 旋轉編碼器(連引腳)

使用 旋轉編碼器 製作的 音量控制器

速度過快

由於 Arduino 的執行速度比 MicroPython 或 CircuitPython 快
令 微控制器 有足夠時間捕捉到多次旋轉軸心時的回彈
因此需要加入 delay 來去除回彈訊號,但使用 中斷 則不能使用 delay 或 delayMicroseconds
(在 Arduino中斷 回呼函數 中的 micros 或 millis 必定傳回 0 ,因此無法計算延遲;但可以使用其他方法延遲)

二進制及格雷碼轉換

二進制 與 格雷碼 非常相似,可以使用

byte numberToGray(byte number) {
	byte gray = number >> 1;
	gray ^= number;
	return gray;
}
bool v1 = !digitalRead(r1);
bool v2 = !digitalRead(r2);
byte gray = numberToGray(v1 | v2 << 1);

二進制 與 格雷碼 非常相似,可以使用這個轉換程式簡化大量判斷

總結

旋轉編碼器 是其中一種基本電子零件
在下最初以為 旋轉編碼器 與 電位器 都似相同功能的電子零件,但實際兩者應用上完全不同

之前在下改裝的遊戲控制器,因為需要在 loop 的情況中不斷檢查輸入狀態
導致當需檢查大量按鈕時,變得很慢
因此在下將所有按鈕的使用中斷檢查,運作速度立即變得正常

參考資料

沒有留言 :

張貼留言