目次 [ Contents ]
Arduinoで外部から値をコントロールしたいなら、可変抵抗器を使ってanalogReadするのが手っ取り早い方法ですが、他にロータリーエンコーダを使う手もあります。ただ、実装しようとすると現実的に難しい面が出てきます。いわゆるチャタリングの問題です。
そこでロータリーエンコーダを使うに当たって直面する問題を回避しつつ、実用的に使えるような方法を書いてみたいと思います。
クリックなしタイプのエンコーダは Part2 (2017/4/20)
外部割込みを使わないスケッチは Part3 (2017/10/22)
この記事での説明は自作フォローフォーカスを作るに当たって試行錯誤して考えた方法です。
ロータリーエンコーダとは
ロータリーエンコーダの仕組みには2種類ありますが、この記事ではインクリメンタル型についてのみ扱います。
ロータリーエンコーダは可変抵抗器と同じように回転するつまみです。ただし、中身は全然違います。その構造は連続したスイッチで、回した角度によってオンオフが切り替わるようになっています。この仕組みでつまみが動いたのかどうかが分かります。
ただ、これでは「どっちに回ったのか」方向の判別ができません。そこで、接点を2箇所用意して、ズラした状態にします。
2つの接点状態をまとめて考えると、方向によってパターンの違いが出来ます。その違いによって動いている方向を割り出す仕組みになっています。
A | 0 | 1 | 1 | 0 | ~ |
B | 0 | 0 | 1 | 1 | ~ |
回路図
- クリックありのロータリーエンコーダ
- タクトスイッチ
- LED & 抵抗 2個
ロータリエンコーダには細かく言うとクリック有り・無しがあります。クリックとはいわゆる回すとカチカチ感触があるやつです。今回はそれを使用します。
外部割込み
ロータリーエンコーダの使用では、一般的に「外部割込み」の機能を使います。 「外部割込み」はデジタルピンの状態を常に監視し、特定の変化を感知したら優先的に指定した関数を実行する機能です。その名の通り外部入力からの割り込みです。
#define ENC_A 2 #define ENC_B 3 void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE); Serial.begin(38400); } void loop() {} void ENC_READ() {}
「外部割込み」は上記のように、setup関数内で指定しておくのが普通です。 割り込みに使用できるピンはArduinoの種類によって違いますが、ArduinoUNOではD2、D3ピンになっています。
attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE);
attachInterrupt関数では(ピン番号、実行したい関数、実行する条件)の引数を指定します。
ピン番号
Arduinoでのピン番号ではなく、外部割り込みに対応しているピンに対するナンバリングになるので注意が必要です。ArduinoUNOでは、D2ピンは0、D3ピンは1となります。
実行したい関数
変化を感知したときに実行する関数名です。この記事では「ENC_READ」にしていますが、一般的な関数ルールと同じで、かぶらなければ任意の名称でOKです。
実行する条件
指定しているピンがどういう状態になったら反応するかを指定します。種類がいくつかあり、下記の文字列で指定します。
指定文字列 | 説明 |
LOW | ピンがLOWになった時 |
CHANGE | ピンの状態が変わった時 |
RISING | ピンの状態がLOWからHIGHに変わった時 |
FALLING | ピンの状態がHIGHからLOWに変わった時 |
詳しくはこちらを参照。
ロータリーエンコーダを読む
1.状態を見る
まずはエンコーダの状態を常時確認できるスケッチを書いてみます。結果はシリアルモニタに表示されます。
#define ENC_A 2 #define ENC_B 3 void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE); Serial.begin(38400); } void loop() {} void ENC_READ() { bool cur[2]; cur[0] = !digitalRead(ENC_A); cur[1] = !digitalRead(ENC_B); Serial.print("A:"); Serial.print(cur[0]); Serial.print(" "); Serial.print("B:"); Serial.print(cur[1]); Serial.println(); }
単純にピンの変化を検知したら両方のピン状態を読み(cur[0]、cur[1])、結果をSerial.printで返すだけを繰り返します。
本来は1クリックで4回だけ表示されるはずですが、チャタリングのせいで余分に表示されちゃいます。この問題は追々解決するので、今のところ無視してしてください。
基本の場所を‘あ’とすると、1クリック分回す間に両ピンのパターンは次の‘あ’へと連続的に変化します。まとめると、ピンの状態は下記の4パターンに分類できます。
パターン | い | う | え | あ | い | う | え | ~ |
D2 pin(A) | 1 | 1 | 0 | 0 | 1 | 1 | 0 | ~ |
D3 pin(B) | 0 | 1 | 1 | 0 | 0 | 1 | 1 | ~ |
2.パターンに分ける
とりあえず、2つのピン状態をセットにして扱えるよう書き換えます。この場合、片方の値をビットシフトし、2ビットで1つにまとめてしまうのが楽です。ビットシフトは2進数の位をずらすことができます。
void ENC_READ() { byte cur; cur = !digitalRead(ENC_B) << 1; cur += !digitalRead(ENC_A); Serial.println(cur, BIN); }
Bピンの1ビットをByte型の変数「cur」に押し込めます。この時、1ビット分シフト(桁の移動)します。ビットシフトは”>>”、”<<“とずらしたい桁数を指定することで行えます。
cur = !digitalRead(ENC_B) << 1;
つまり、ENC_Bピンの状態が1で返ってきたら、
B00000001 → B00000010
となります。これにAピンの1ビットを足せば、curの中に両方の情報が組み込めます。
ちなみにSerial.println(cur, BIN);のBINは「2進数(バイナリ)で表示しろ」という意味で、これを外しSerial.println(cur);にすると10進数の値で見ることが出来ます。
2進数と10進数の関係をまとめると下記のようになります。
パターン | う | え | あ | い | う | え |
2進数 | 11 | 10 | 00 | 01 | 11 | 10 |
10進数 | 3 | 2 | 0 | 1 | 3 | 2 |
2ビットでエンコーダのパターンが表現できるようになりました。ただ、10進数の並び方が0-1-3-2と紛らわしいので、便宜上、順当に並ぶよう書き換えちゃいます。
void ENC_READ() { byte cur; cur = (!digitalRead(ENC_B) << 1) + !digitalRead(ENC_A); if (cur == 3) cur = 2; else if (cur == 2) cur = 3; Serial.println(cur); }
パターン | う | え | あ | い | う | え |
10進数 | 2 | 3 | 0 | 1 | 2 | 3 |
3.方向を見極める
パターンが識別できるようなったら、次は回った方向の識別です。方向を知るには1つ前のパターンと照合する必要があります。
#define ENC_A 2 #define ENC_B 3 volatile byte pos; void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE); Serial.begin(38400); } void loop() {} void ENC_READ() { byte cur = (!digitalRead(ENC_B) << 1) + !digitalRead(ENC_A); byte old = pos & B00000011; if (cur == 3) cur = 2; else if (cur == 2) cur = 3; bool rote = 0; if (cur == 3 && old == 0) rote = 0; else if (cur == 0 && old == 3) rote = 1; else if (cur > old) rote = 1; pos = (old << 2) + cur; const char vector[2] = {'<', '>'}; Serial.print(vector[rote]); Serial.println(pos + 128, BIN); }
まず、byte型グローバル変数「pos」を作ります。
volatile byte pos;
グローバル変数でも、割り込みに絡む変数には「volatile」をつけないといけません。これを書き足さないと、割り込み関数で行った処理の結果が反映されず、正確な値として扱えません。
2ビットで1パターンを記憶できるので、今回分と前回分をposの中へまとめてしまいます。
pos = (old << 2) + cur;
B00000000 ← 青:old 黄色:cur
ここで代入した下位2ビット「cur」は、次回の読み取りでは前回分になります。なので、関数頭では前回パターンとして「old」にマスクして代入しています。「&(AND)」はビット演算子のひとつで、下記のようにtrue(1)にしている桁だけを有効になるよう変更出来ます。
byte old = pos & B00000011;
例えばposがB00001110だとすると B00001110 & B00000011 → B00000010 になり、前回パターンの値だけが「old」に代入されます。 そこで、現在値「cur」が「old」より大きければ正回転(増分)したと分かります。ただし、0→3、0←3の場合は数値が順当ではないので、例外として扱います。
bool rote = 0; if (cur == 3 && old == 0) rote = 0; else if (cur == 0 && old == 3) rote = 1; else if (cur > old) rote = 1;
こうして、結果の違いで「rote」に正反の方向を決め、Serial.printでモニタします。ちなみにシリアルモニタ上でバイナリ表示の桁数が変わるのを防ぐために最大ビットは1(128)にしています。
Serial.println(pos + 128, BIN);
ここで、前回のパターンを利用してチャタリング対策を追加します。 シリアルモニタを見ていると、前回と今回が重複していることがあるので、一緒でなければパターン記憶の更新を実行する形にします。
void ENC_READ() { byte cur = (!digitalRead(ENC_B) << 1) + !digitalRead(ENC_A); byte old = pos & B00000011; if (cur == 3) cur = 2; else if (cur == 2) cur = 3; if (cur != old) { bool rote = 0; if (cur == 3 && old == 0) rote = 0; else if (cur == 0 && old == 3) rote = 1; else if (cur > old) rote = 1; pos = (old << 2) + cur; const char vector[2] = {'<', '>'}; Serial.print(vector[rote]); Serial.println(pos + (1 << 7), BIN); if (cur == 0) Serial.println(); } }
反応は大分すっきりしましたが、やはりチャタリングによる誤作動を完璧に防ぐことは出来てません。
4.エンコーダでカウントする
今までのことを踏まえて、エンコーダの回転でカウントできるようにします。ただ、問題なのはチャタリングによる誤作動です。これを回避するためには考え方を改める必要があります。当時、この解決策に凄い悩まされましたが、一つの結論が出ました。 クリック付きのロータリーエンコーダは4つのパターンで1つの動きになるので、そもそも全てのパターンを追う必要はありません。「動きはじめ」と「動き終わり」さえちゃんと捕らえていれば、カウントとして機能させられるわけです。
パターン | う | え | あ | い | う | え |
10進数 | 2 | 3 | 0 | 1 | 2 | 3 |
正回転 | → | 終了 | 開始 | → | ||
反回転 | ← | 開始 | 終了 | ← |
そこで移動の有無を伝える「dir」変数を用意し、移動を開始したら起点のパターンを書き込み、移動が終われば0にリセットするようにします。
if (dir == 0) { if (cur == 1 || cur == 3) dir = cur; } else { if (cur == 0) { dir = 0; }
“終了時に「dir」と終了前の「old」が回転方向のパターンに合致すればカウントを実行する”とすれば、途中のパターン、チャタリングもろもろ関係ありません。
回転方向 | 終了判定 | 回転達成の判定 | |
正回転 | dir = 1 | cur = 0 | old = 3 |
反回転 | dir = 3 | cur = 0 | old = 1 |
最後、「pos」変数へ更にdirビットを仕込みます。具体的には、
pos = (dir << 4) + (old << 2) + cur;
以上の話をまとめるとこんな感じになります。カウント用には「enc_count」を新たに用意します。
#define ENC_A 2 #define ENC_B 3 volatile byte pos; volatile int enc_count; void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE); Serial.begin(38400); } void loop() {} void ENC_READ() { byte cur = (!digitalRead(ENC_B) << 1) + !digitalRead(ENC_A); byte old = pos & B00000011; byte dir = (pos & B00110000) >> 4; if (cur == 3) cur = 2; else if (cur == 2) cur = 3; if (cur != old) { if (dir == 0) { if (cur == 1 || cur == 3) dir = cur; } else { if (cur == 0) { if (dir == 1 && old == 3) enc_count++; else if (dir == 3 && old == 1) enc_count--; dir = 0; } } bool rote = 0; if (cur == 3 && old == 0) rote = 0; else if (cur == 0 && old == 3) rote = 1; else if (cur > old) rote = 1; pos = (dir << 4) + (old << 2) + cur; const char vector[2] = {'<', '>'}; Serial.print(vector[rote]); Serial.print(" "); Serial.print(enc_count); Serial.print(" "); Serial.println(pos + (1 << 7), BIN); if (cur == 0) Serial.println(); } }
どうでしょうか?かなり正確にカウントしていると思います。 「いやいや、早く回すとついてこないよ」と不満を持つ人もいるかもしれません。でも、じつはこれシリアル通信による遅延によるものなので、割り込みの中でシリアルプリントしないようにすれば、もっと反応が良くなります。
5.割り込みとカウント加算処理を分離
割り込み処理は変化があると常に即実行するので、「enc_count」をそのままメインのloop内で使おうとするとすれ違いが起きます。そこで、カウントの変化を監視し、値を引き出した時ごとに差分を返す関数を別に作ります。
int ENC_COUNT(int incoming) { static int enc_old = enc_count; int val_change = enc_count - enc_old; if (val_change != 0) { incoming += val_change; enc_old = enc_count; } return incoming; }
「enc_old」と最新の「enc_count」を比較して差があれば(val_change)、「enc_old」を更新しつつ「incoming」に差分を足して返します。こうすることで、希望の変数の値を簡単に操作することができます。
まとめスケッチ
最後に整理しつつ、ロータリーエンコーダで2つのLEDの明るさを個別に調整できるスケッチを描きます。スイッチで操作するLEDを切り替えられます。数値はシリアルモニタで確認できます。また、LED_SIZEとled_pinに入るピン番号を増やせば、扱えるLEDを増やすことも出来ます。 ENC_COUNT関数に任意の引数を投げれば、エンコーダで操作できます。今回のエンコーダ機能を自分の使いやすいようにカスタマイズしたければ、こちらのスケッチをベースに加工してください。ロータリエンコーダ使用に必要な最低限のコードはハイライトしています。
:ロータリーエンコーダでLEDの明るさ調整
#define ENC_A 2 #define ENC_B 3 volatile byte pos; volatile int enc_count; #define LED_SW 7 #define LED_SIZE 2 const byte led_pin[LED_SIZE] = {10, 11}; // digital pins for LED byte led_luma[LED_SIZE] = {255, 255}; // luma for each LED boolean sw = false; void setup() { pinMode(ENC_A, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); attachInterrupt(0, ENC_READ, CHANGE); attachInterrupt(1, ENC_READ, CHANGE); Serial.begin(38400); for (byte i = 0 ; i < LED_SIZE ; i++) { pinMode(led_pin[i], OUTPUT); analogWrite(led_pin[i], led_luma[i]); } pinMode(LED_SW, INPUT_PULLUP); SERIAL_MON(); } void loop() { // LED switch change unsigned long gauge; while (!digitalRead(LED_SW)) gauge++; if (gauge > 700) { sw = !sw; SERIAL_MON(); } // set led_luma of active LED short led_ref = ENC_COUNT(led_luma[sw]); led_ref = constrain(led_ref, 0, 255); // excute luma change if (led_luma[sw] != led_ref) { led_luma[sw] = led_ref; analogWrite(led_pin[sw], led_luma[sw]); SERIAL_MON(); } } void SERIAL_MON() { for (byte i = 0 ; i < LED_SIZE ; i++) { Serial.print((char)(i + 'A')); Serial.print(":"); Serial.print(led_luma[i]); if (sw == i) Serial.print("< "); else Serial.print(" "); } Serial.println(); } int ENC_COUNT(int incoming) { static int enc_old = enc_count; int val_change = enc_count - enc_old; if (val_change != 0) { incoming += val_change; enc_old = enc_count; } return incoming; } void ENC_READ() { byte cur = (!digitalRead(ENC_B) << 1) + !digitalRead(ENC_A); byte old = pos & B00000011; byte dir = (pos & B00110000) >> 4; if (cur == 3) cur = 2; else if (cur == 2) cur = 3; if (cur != old) { if (dir == 0) { if (cur == 1 || cur == 3) dir = cur; } else { if (cur == 0) { if (dir == 1 && old == 3) enc_count++; else if (dir == 3 && old == 1) enc_count--; dir = 0; } } pos = (dir << 4) + (old << 2) + cur; } }