概要
タッチパネル付TFT液晶モジュールを使った赤外線リモコンに関する話の2回目。今回からはプログラム(スケッチ)について。プログラムの一人ソースレビューといったところ。
スケッチ全体は以下の3本で構成した。C++のクラスを使わなかったので1本にしてもよかったのだけど、全体で950行程になったので分割することにした。
- メインモジュール (A_PROMINI_TFT_IR3.ino)
タッチパネル、赤外線信号、セットアップ、メインループ。 - リモコン操作パネルモジュール (rcPanel.h)
各操作パネルを描画するための機能。 - リモコン操作パネルデータ (panelData.h)
操作パネルのための定義やパネル描画用のデータなど。
なお、メインモジュールはrcPanel.hに依存しており、パネルモジュールはpanelData.hに依存している。今回は、メインモジュールについて。
A_PROMINI_TFT_IR3.ino
特にファイルの命名規則はないのだけど、Arduino PRO MINI用の場合、”A_PROMINI_xxxx.ino”という名前にすることが多い。
メインモジュールは、おおまかに以下のような部分から成り立っている。
- I/Oポート関係の定義
- タッチパネルの制御とタッチ位置の読み取り
- ATMega328PのPWM機能を使った赤外線信号の発生
- タッチ検出時の挙動をつかさどるメインループ
- 各種セットアップ
#include <avr/sleep.h>
#include <avr/wdt.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include "rcPanel.h"
#define TFT_RST 6
#define TFT_DC 7
#define TFT_CS 5
#define LED_PIN 4
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC);
#define TFT_ROTATION 3 // J2端子側を左に見たとき左上が、1: (319, 239) or 3 : (0, 0)。
#define TSC2046_SS 8 // touch panel spi SS
#define PWM_OC1A 9 // PWM
#define PWM_OC1B 10 // PWM (to IrLEDs)
//
// ------- touch-panel
//
static int rx, ry, rz; // tsc2046 raw data.
void tsc2046_read_adc(bool pd_only = false) {
SPI.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0));
digitalWrite(TSC2046_SS, LOW);
if (pd_only) { // force power-down mode.
SPI.transfer16(B11010000); // get Y and request Power down.
SPI.transfer16(0); // dummy read.
digitalWrite(TSC2046_SS, HIGH); //
SPI.endTransaction();
return;
} else {
int z1 = 0;
int z2 = 0;
rz = rx = ry = 0;
SPI.transfer(B10110011); // request Z1
for(int i = 0; i < 4; i++) { // 4回読んで平均値を求める。
z1 += SPI.transfer16(B11000011) >> 3; // get Z1 and request Z2
z2 += SPI.transfer16(B11010011) >> 3; // get Z2 and request X
rx += SPI.transfer16(B10010011) >> 3; // get X and request Y
ry += SPI.transfer16(B10110011) >> 3; // get Y and request Z1.
}
SPI.transfer16(B11010000); //request Power down.
SPI.transfer16(0); // dummy read.
digitalWrite(TSC2046_SS, HIGH);
SPI.endTransaction();
z1 >>= 2, z2 >>= 2, rx >>= 2, ry >>= 2;
// 強く押すほど抵抗値(読み取り値)は小さくなる。rx値で補正して最大値から引く。
if (z1 > 0 && z2 > 0)
rz = 4096 - (int)((double)(z2 / z1 * rx / 4.0 ));
else
rz = 0;
}
}
// タッチ可能領域。
// 実際にタッチして得た値。座標は raw value (rx, ry)から得ている。
enum { tsc_min_x = 280, tsc_min_y = 150, tsc_max_x = 4000, tsc_max_y = 3700 };
// 押圧が指定値より小さいとfalse
// 液晶の座標系に変換した値を戻す。
// 液晶の長手方向をxとする。
const bool tsc2046_read_pos(int& x, int& y) {
tsc2046_read_adc();
if (rz < 800)
return false;
if (TFT_ROTATION & 1) {
y = map(rx, tsc_min_x, tsc_max_x, 0, ILI9341_TFTWIDTH);
x = map(ry, tsc_min_y, tsc_max_y, 0, ILI9341_TFTHEIGHT);
if (TFT_ROTATION == 3) { // J2端子側を左に見たとき左上が、1: (319, 239) or 3 : (0, 0)。
x = ILI9341_TFTHEIGHT - x;
y = ILI9341_TFTWIDTH - y;
}
}
return (x >= 0 && y >= 0);
}
//
// --------- IR related.
//
#define NEC_T 560
#define AEHA_T 450 // 425
static int ir_format_T = NEC_T; // 基準フレーム時間
// system clk = 8MHz. (no prescaler)
// 38KHz, duty 1:3 (25% on)
// 26.3us ÷ 0.125 = 210 : 周期
// 26.3 × 0.25 ÷ 0.125 = 52.6 : H期間
// FastPWM mode. WGM(13-11)=1111
void pwm_out(int t_count) {
TCNT1 = 0;
TCCR1A = B01100011; // Inverted, HIGH at BOTTOM, LOW on match.
TCCR1B = B00011001; // ICNC1 = 0, ICES1 = 0, no prescaler.
OCR1A = 209; // TOP value.
OCR1B = 52; // H period of OC1B
delayMicroseconds(ir_format_T * t_count);
TCCR1A = B00000000;
TCCR1B = B00000000;
}
void send_bit(int value) {
pwm_out(1);
delayMicroseconds(value ? ir_format_T * 3 : ir_format_T);
}
void send_byte(byte value) {
send_bit(value & 1);
send_bit(value & 2);
send_bit(value & 4);
send_bit(value & 8);
send_bit(value & 16);
send_bit(value & 32);
send_bit(value & 64);
send_bit(value & 128);
}
void send_leader() {
if (ir_format_T == NEC_T) {
pwm_out(16);
delayMicroseconds(ir_format_T * 8);
} else {
pwm_out(8);
delayMicroseconds(ir_format_T * 4);
}
}
// '0'を4回送信。
void send_preframe() {
for(int i = 0; i < 4; i++)
send_bit(0);
}
void send_trailer() {
send_bit(0);
delay(10);
}
void send_frame(uint8_t* p, int8_t bytes) {
send_leader();
for(int i = 0; i < bytes; i++)
send_byte(p[i]);
send_trailer();
}
void send_ir_data(int8_t id) {
bool nec = false;
int8_t frames = get_ir_frame_info(nec);
if (frames > 0) {
ir_format_T = nec ? NEC_T : AEHA_T;
digitalWrite(LED_PIN, 0); // back-light off
for(int8_t i = 1; i <= frames; i++) {
int8_t bytes = get_ir_frame_size(i);
uint8_t* p = (uint8_t*)malloc(bytes + 1);
if (p) {
if (get_ir_data(id, p, bytes, i)) {
if (i == 1)
send_preframe();
send_frame(p, bytes);
}
free(p);
}
}
digitalWrite(LED_PIN, 1); // back-light on
}
}
// ------- Manipulation
static bool action_done = 0;
static int8_t last_touched = 0;
static void clear_touched() {
if (last_touched) {
draw_button(last_touched, false);
last_touched = 0;
}
action_done = 0;
}
#define LOOP_DELAY 30
#define SLEEP_COUNT (3000 / LOOP_DELAY)
#define TOUCH_THRES (300 / LOOP_DELAY)
#define REPEAT_THRES (600 / LOOP_DELAY)
void isr() {}
static int sleep_counter = 0;
static int8_t touch_counter = 0;
static bool eco = true;
const bool eco_mode() { return eco; }
void set_eco_mode(bool flag = true) { eco = flag; }
static void touch_loop() {
for(;;) {
delay(LOOP_DELAY);
if (eco && sleep_counter < 1) {
tft.tft_sleep();
digitalWrite(LED_PIN, 0); // back-light off.
attachInterrupt(0, isr, FALLING);
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_enable();
MCUCR |= 0x60;
MCUCR = (MCUCR & 0xdf) | 0x40;
sleep_cpu();
detachInterrupt(0);
digitalWrite(LED_PIN, 1); // back-light on.
tft.tft_wakeup();
sleep_counter = SLEEP_COUNT;
}
sleep_counter--;
int x, y;
if (!tsc2046_read_pos(x, y)) {
clear_touched();
continue;
}
int8_t id = button_touched(x, y);
if (id < 1) {
clear_touched();
continue;
}
if (last_touched != id) {
clear_touched();
draw_button(id, true);
touch_counter = 0;
last_touched = id;
sleep_counter = SLEEP_COUNT;
continue;
}
if (++touch_counter < TOUCH_THRES)
continue;
if (action_done) { // 送信済
if (!repeat(id)) {
sleep_counter = SLEEP_COUNT;
continue;
} else if (touch_counter < REPEAT_THRES)
continue;
}
if (action_done = panel_action(id)) // ir 送出時にはtrue
send_ir_data(id);
touch_counter = 0;
sleep_counter = SLEEP_COUNT;
}
}
//void loop() {}
void setup() {
ADCSRA &= 0x7f; // ADC禁止。MSB を 0に。
ACSR |= 0x80; // アナログコンパレータ禁止
delay(50);
SPI.begin();
pinMode(TSC2046_SS, OUTPUT);
digitalWrite(TSC2046_SS, HIGH);
tsc2046_read_adc(true);
pinMode(PWM_OC1A, OUTPUT);
pinMode(PWM_OC1B, OUTPUT);
digitalWrite(PWM_OC1A, 0);
digitalWrite(PWM_OC1B, 0);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, 0);
pinMode(2, INPUT_PULLUP);
pinMode(TFT_RST, OUTPUT);
digitalWrite(TFT_RST, 0);
delay(10);
digitalWrite(TFT_RST, 1);
delay(10);
tft.begin();
tft.cp437(true);
tft.setTextWrap(false);
tft.setRotation(TFT_ROTATION);
create_panel();
digitalWrite(LED_PIN, 1); // back-light on.
wdt_disable();
touch_loop();
}
定義およびTFTライブラリの初期化
このあたりは、TFTモジュールを最初に使ってみたときとあまり変わらない。液晶およびタッチパネルに接続するためのSPI信号やスレーブセレクト用ポートの定義と、IrLEDをチカチカするためのPWMポート(PWM_OC1AおよびPWM_OC1B)を定義している。なお、PWM_OC1A (D9)は外部には接続していない。
Adafruit_GFX.hとAdafruit_ILI9341.hをインクルードし、
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC);
によって描画用のインスタンスを得ている。
タッチパネル制御と読み取り
以前はタッチパネル関係の機能を独立したクラスとしていたが、必要な部分だけをメインモジュール内にベタッと書くことにした。内容については、上にあげたリンク先を参照。
赤外線出力関係
NECフォーマットとAEHAフォーマットに対応している。なお、今回対応した家電用リモコンに合わせて、このリモコンでのAEHAフォーマットの基準T時間は450μ秒とした。赤外線リモコンのフォーマットについては、こちらの記事を参照。
pwm_out();
ATMega328PのTC1 ( Timer Counter 1) のFast PWMモードによってデューティ比1:3 (25%オン)の38KHzでIrLEDをチカチカさせる。TC1のFast PWMについてはちょっと前に作ったリモコンと同じにした。
ソースのコメントとして、
// 26.3us ÷ 0.125 = 210 : 周期 // 26.3us × 0.25 ÷ 0.125 = 52.6 : H期間
と書いてあるがこの意味は、
- 38KHz信号の周期は26.3μ秒。
- 8MHzクロック(プリスケーラ無し)の周期は0.125μ秒。
- したがって、1周期は26.3us ÷ 0.125 = 210クロック。
- そのうち、オンになるのは25%なので、26.3us × 0.25 ÷ 0.125 = 52.6クロック。
ということで、これらのパラメータをOCR1A/Bの各レジスタにセットしている(カウントアップは0から開始するので、-1)。
前にも書いたのだけど、LEDが実際に点灯している時間が短ければそれだけ省電力になる。25%オンの信号でも対象の家電は問題なく動作している。
PWM出力を開始し、 delayMicroseconds(ir_format_T * t_count); によって基準時間 × t_count の間、PWM_OC1Bポートに波形を出力して終わる。PWM_OC1Bポートは前回載せた回路図のトランジスタ(2SC1815)のベースに接続しているので、結果としてIrLEDがチカチカする。
1ビットの情報を送信するときのt_countは常に1だが、リーダーと呼ばれる部分では、フォーマットごとに異なる長さを与える必要がある。
データフレーム送信部分
ソースコードの send_bit(); send_byte(); send_leader(); send_trailer(); send_frame(); により、家電に対するリモコンの信号フォーマットに合わせてコマンドデータ列(データフレーム)を送信している。
赤外線リモコンの1バイト(8ビット)のデータは、1ビットずつLSBから順に pwm_out(); とその後の無信号期間の組合せによって送出される。けっこうノンビリした通信であることが分かる。
リーダー部分の点滅時間、無信号時間の各パラメータは、フォーマットによって異なっているので分けている。send_trailer() 内の無信号時間はAEHAフォーマットにのみ必要だが、NECフォーマットでは余計なだけで問題は生じないようだった。
send_frame(); 関数が複数バイトからなるデータ列を送信する部分になっており、対象家電に応じて内容やバイト数をパラメータとして指定する。今回対象にしたエアコンは複数のデータフレームを必要とするが、この関数を複数回呼び出すことで対応している。
void send_ir_data(int8_t id)
引数として、タッチされ選択されたとみなすボタンのIDを与えると、rcPanel.h が保持している「現在の操作パネル」におけるボタンIDに対応するデータフレーム情報を取り出し、送信を行う。
データフレーム情報には、データフレーム数、赤外線フォーマット、データフレームのバイト数、そしてデータフレームを構成するバイト列が含まれる。今回のエアコンの場合第1フレームと第2フレームとでバイト数も異なるので、このような実装になっている。情報を返す各関数についてはrcPanel.h の項で。なお、データフレーム数が 0 個の場合は何もしない。
digitalWrite(LED_PIN, 0 or 1); により、データフレームの取得から送信のあいだ、TFTモジュールのバックライトをオフにし、消費電流を抑えるとともに、操作に対する視覚的なフィードバックを行っている。ボタンをタッチしたつもりなのに対象の家電が反応しないとき、ホントに動いてんのかな?とか感じがちなので。
毎度毎度malloc() するより適当な大きさのバッファを持つ方がよさそうな気もするが、メインモジュールを対応する家電ごとの詳細情報(データフレームの数やバイト数)から独立させるためにこのようにした。
操作関係とメインループ
TFT液晶上に描画した矩形や円形の領域(ボタン)のタッチを検出し、一定時間その領域内へのタッチが続いた場合は、そのボタンが選択されたとみなしてアクションを起こす。ここでのアクションとは、赤外線を信号を送信したり操作パネル上の表示を変更したりすること。上に載せたソースコードの155行目以降が操作制御を行っている部分になる。
領域内がタッチされた瞬間にアクションを起こしたのでは誤操作だらけになってしまう。どの程度タッチし続ければアクションを起こすのかは、前回載せた動画で確認できると思う。
操作制御は、void touch_loop() という無限ループをもつ関数とその周辺の関数や変数で行っている。Arduino のスケッチには void loop(); という無限ループする関数があらかじめ用意されているのだけど、ループ内で continue; と書きたかったので for(;;) {} を使った無限ループを作ることにした。
この無限ループは、最初においた delay(LOOP_DELAY); により、 LOOP_DELAY ミリ秒に一度回るようにしており、タッチパネルからの読出しやその後のタッチされた領域の評価もそのタイミングで行っている。キーボードでいえば一定時間ごとにマトリックススキャンしているようなもの。
操作関係の変数と定義
SRAM領域を節約するため、変数のバイト数をケチって宣言している。
- static bool action_done
メインループでボタンの選択を検出した場合、そのボタンによる操作パネルの更新の実行や、赤外線信号を送出するかどうかを問い合わせるために、操作パネル側の panel_action(); を呼び出す。この変数はその戻り値を保持する。
action_doneの値により、リピート動作するかどうかを決める。 - static int8_t last_touched ;
現在タッチが続いているボタンIDを保持する。ボタンIDは1以上の整数(int8_t)であり、0の場合はタッチが始まっていない状態ということになる。 - static int sleep_counter ;
この変数は、メインループが回っているとき常にデクリメントされ続ける。ecoモードのとき、この変数の値が1未満になるとマイコン、液晶をスリープさせバックライトを消灯する。
初期値としてスリープ猶予カウント (SLEEP_COUNT) をもつ。スリープからの復帰時、タッチの検出時、アクションの実行時に初期値がセットされる。 - static int8_t touch_counter ;
同じボタンがタッチされ続けているときにインクリメントされ、アクションを起こすべきカウント値(TOUCH_THRES)以上になったかどうかの判定に使うカウンター。
リピート動作するボタンがタッチされ続けているとき、二回目以降のアクションを起こすためには、さらに大きなカウント値(REPEAT_THRES)に達する必要がある。
- static bool eco ;
メニューパネルにおいてセットするecoモードフラグ。このフラグがtrueでsleep_counter が1未満になると、全体をスリープさせる。 - #define LOOP_DELAY 30
メインループを回す間隔時間(msec単位)。
以下の3つの定義は、sleep_counter, touch_counter で用いる定数になるが、時間(msec単位)を LOOP_DELAY で割ることでループの回数としている。 - #define SLEEP_COUNT (3000 / LOOP_DELAY)
スリープ猶予カウント。ecoモードのときスリープに入るまでのカウント値。最後のタッチ操作から3000msec後、つまりメインループが100回まわるとスリープすることになる。 - #define TOUCH_THRES (300 / LOOP_DELAY)
選択遅延カウント。ボタン領域内をタッチし続けたとき、そのボタンが選択されてアクションが起きるまでのカウント値。300msecで選択としている。 - #define REPEAT_THRES (600 / LOOP_DELAY)
リピート遅延カウント。リピート動作が有効なボタンでは、初回のアクションから600msec後に次のアクションを起こすことになる。
このあたりの数字は、使用感に応じてけっこう変更している。しかしながら、カウント値を変更するための操作をプログラムで作り込むほどではないかなといったところ。
void touch_loop()
setup() から呼ばれるメインループ。以下を実行する。
先頭の delay(LOOP_DELAY); により、LOOP_DELAY (msec)ごとにループを一度回す。
ecoモードならばTFTモジュールをスリープさせ、バックライトを消したあとマイコンをスリープ (SLEEP_MODE_PWR_DOWN) させる。タッチパネルを一度タッチすることで生じる割込みにより、スリープから復帰し、TFTモジュールの回復とバックライトの点灯を行う。
なお、tft_sleep(), tft_wakeup() というメソッドは、前に書いたようにAdafruit_ili9341.h に勝手に追加した。
tsc2046_read_pos(x, y); により、タッチパネルからタッチ位置の読み出しを行う。押圧が規定値より小さければ(あるいはタッチされていなければ)、ループの先頭に戻る。
タッチされた位置がボタン領域にあるかどうかをrcPanel.h にある button_touched(x, y); を呼んで問い合わせる。いずれかのボタン領域内ならばボタンIDが得られるし、領域外なら 0 が戻る。
ボタンIDが得られた場合、同一のボタンがタッチ中かどうか(あるいは、はじめてタッチされたか)を評価する。最初のタッチならば rcPanel.hの draw_button(id, true); を呼んで、タッチ中と分かるように描画し直してタッチ継続時間をもつ変数 touch_counter を初期化する。なお、操作パネルの中でタッチ中となるボタンは1つだけとしている。
それまでタッチされていたボタンから外れた(別のボタンがタッチされた、ボタン領域外がタッチされた、何もタッチされていない)場合は、clear_touched(); を呼ぶことで、ボタンを初期の外観に描画し直すと共に関係の変数を初期化する。
そして同じボタンのタッチが続き touch_counter の値が選択遅延カウントのTOUCH_THRES 以上になった時点で、そのボタンが選択された、あるいは、選択され続けていると判断する。選択され続けている場合は、repeat(id); によってリピート処理すべきかどうかを問い合わせる。(リピートではない)最初の選択時や、touch_counterがリピート遅延カウント ( REPEAT_THRES ) に達したならば、rcPanel.h内の panel_action(id) を呼んでパネル表示を更新するタイミングを与える。
panel_action(id) が trueを返した場合、対応する赤外線データの送出を行うために send_ir_data(id); を呼び出す。ボタンによっては、データフレーム数が0個の場合もあるので、trueを返したからといって実際にデータが送信されるとは限らない。
最後に各カウンター変数に初期値を与えて、ループの先頭に戻る。
なお、ecoモードフラグをセット/リセットする void set_eco_mode(bool flag); は、rcPanel.h の panel_action(); 内から呼ばれる。当然ながら、メニューパネルの表示中に eco ボタンが選択されたときに限られる。
セットアップ
void setup(); では以下を行っている。
- ATmega328P内部のアナログ入力ポートやアナログコンパレータの動作禁止。
アナログ入力は使わないので。これらにより、100μA弱の電流を節約。 - 各I/Oポートの入出力方向や初期値の設定
特に急ぐ処理でもないので、ポートを直接操作するのではなく、ふつうに初期化している。 - TFTモジュールの明示的なリセット
TFTモジュールのRSTピンに10msecの間Lレベルを与え、初期化猶予時間として更に10msec遅延させている。 - Adafruit_ILI9341クラスのインスタンス (tft) を使った初期設定。
tft.cp437(true); については前に書いたとおりで CP437の0xB0に対応する Light Shadeキャラクタを使うために必要。
tft.setTextWrap(true); は文字の描画座標が右端を超えたとき、次の行にはみ出ないように。
tft.setRotation(); は四隅のうちのどこを座標原点(0, 0)とするかを指定する。 - 操作パネルの描画とバックライトの点灯。
rcPanel.h内の create_panel(); を呼ぶことでメニューパネルを表示している。 - ウォッチドッグ・タイマーの動作禁止。
wdt_disable(); を呼ぶ。スケッチが安定するまでは、これは書かない方がよいだろう。今回の実装では、テスター読みで約10μA削減の効果があった。
きょうのまとめ
今回はメインモジュールだけになってしまった。rcPanel.h 内の関数を呼ぶ部分も多いので、これだけでは何やってんだか分からないかもしれない。
プログラムの説明に写真や図を入れるのは面倒なのでずらずらと書き連ねてしまったが、まだまだ続きます。
そういえば、Arduino IDEでビルドを行うと、メッセージ領域のサイズ情報は以下のようになっていた。
最大30720バイトのフラッシュメモリのうち、スケッチが21962バイト(71%)を使っています。 最大2048バイトのRAMのうち、グローバル変数が674バイト(32%)を使っていて、ローカル変数で1374バイト使うことができます。
今後残り2つのファイルの説明を書いていくうちに修正を加えるかもしれないので確定値ではないが、これくらいで収まっている、ということです。