タッチパネルを使ってお絵かきツールを作ってみた

# 1. 今回のやりたいこと
タッチパネルを使用し、お絵かきツールを作りたいと思います。
イメージを以下に記載します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/05786ce1-0dda-1f53-4087-1bd77c27660d.png)

# 2. 環境
* __使用ボード__：nucleo l4r5zi
Cortex-M4搭載、2MBのFlash、640KのSRAM、USB OTG
* __開発環境__：Atollic TrueSTUDIO® for STM32, Built on Eclipse Neon.1a.(Version: 9.3.0 )
* __OS__：KOZOS
[12ステップで作る 組込みOS自作入門](http://kozos.jp/books/makeos/)で紹介されているOSになります。ソースコードも展開されています。
* __タッチパネル__ : [MSP2807](https://akizukidenshi.com/catalog/g/gM-16265/)

# 3. タッチパネルについて
使用するタッチパネルについて説明します。

### 3.1 MSP2807
今回使用するタッチパネルはMSP2807になります。
* 表
![タッチパネル_実物.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/31f4f72a-9cda-73a6-eb59-235ea93bbbd5.jpeg)
* 裏
![タッチパネル_実物_裏.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/788971c6-501a-e35f-9893-a92e7bb648d6.jpeg)


* ピンの説明

|PIN |説明  |
|---|---|
|VCC  |5V/3.3V   |
|GND  |GND |
|CS  |液晶コントローラのチップセレクト  |
|RESET  |リセットピン（Lowでリセット）  |
|DC/RS  |液晶コントローラのコマンド/データ選択ピン（highでコマンド,Lowでデータ）  |
|SDI(MOSI)  |液晶コントローラの入力  |
|SCK  |液晶コントローラのクロック  |
|LED  |バックライト制御  |
|SDO(MISO)  |液晶コントローラの出力  |
|T_CLK  |タッチパネルコントローラのクロック  |
|T_CS  |タッチパネルコントローラのチップセレクト  |
|T_DIN  |タッチパネルコントローラの入力  |
|T_DO  |タッチパネルコントローラの出力  |
|T_IRQ  |タッチパネルコントローラの割り込み（タッチするとLowになる） |


MSP2807は、タッチパネル搭載の2.8インチTFT液晶モジュールです。詳細な仕様については、[2.8inch SPI Module ILI9341 SKU:MSP2807](https://akizukidenshi.com/catalog/g/gM-16265/)を参照お願いいたします。

液晶コントローラとタッチパネルコントローラが搭載されており、それぞれのIFはSPIとなっています。また、上記裏の図の通り、SDカードスロットが搭載されていますが、今回は使用しません。

いろいろ調査したところ、おそらくですが、液晶コントローラはili9341、タッチパネルコントローラはTSC2046となっています。それぞれの制御方法についても色々調べましたので説明したいと思います。

#### 3.1.1 液晶コントローラ ~ ili9341 ~

ILII9341のマニュアルについては[a-Si TFT LCD Single Chip Driver 240RGBx320 Resolution and 262K color](https://cdn-shop.adafruit.com/datasheets/ILI9341.pdf)を参照お願いします。

ILI9341のIFは4線式SPIとなっています。1ピクセル当たりのbit数は16bit(R:5bit, G:6bit, B:5bit)と18bit(R:6bit, G:6bit, B:6bit)を選択できますが、今回は16bitとします。マイコンからILI9341へは以下のように送信します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/8bbf395e-4aeb-f816-b297-411276fdb5d1.png)

液晶コントローラの具体的な制御については、[TFT液晶 ILI9341r](http://try3dcg.world.coocan.jp/note/spi/ILI9341.html)を参考にさせていただきました。

MSP2807の解像度は320×24で、1ピクセル当たり16bitと設定しているため、全体のサイズとしては153,600byteになります。初めは、割込みで1byteずつ送信をしていたのですが、すべて送信しきるのに1.7秒ほどかかっていました。データの送信間（SPIの送信割り込みで1byteの送信を行ってから、次の送信割り込みが発生するまで）の時間がかなりのオーバーヘッドとなっており、SPIのクロックを早めても、送信しきるまでの時間は縮まりませんでした。このため、今回はDMAを使用することとしました。


#### 3.1.2 タッチパネルコントローラ ~ TSC2046 ~

TSC2046のマニュアルについては[低電圧I/O タッチスクリーン・コントローラ データシート](https://www.ti.com/jp/lit/ds/symlink/tsc2046.pdf?ts=1703760504825&ref_url=https%253A%252F%252Fwww.ti.com%252Fproduct%252Fja-jp%252FTSC2046)を参照お願いします。

TSC2046もili9341と同様、IFは4線式SPIとなっています。
具体的な制御については以下のサイトを参考にさせていただきました。

[SPI：TSC2046 (タッチスクリーン)](http://try3dcg.world.coocan.jp/note/spi/TSC2046.html)
[78K0R/Kx3 サンプル・プログラム タッチスクリーン編](https://www.renesas.com/jp/ja/document/apn/723181)

私のほうでも、色々調査したので、備忘録という意味でも書き残したいと思います。

まず、TSC2046はSPI通信においてスレーブとして動作します。このため、マスタ側からコマンドを送信することでTSC2046を制御します。

TSC2046のコマンドのフォーマットは以下になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/d89211fd-3147-f0f9-c75b-6793e8389987.png)

コマンドは8bitで、S、A2-A0、MODE、SER/DFR、PD1-PD0で構成されています。SER/DFRbitでシングルエンドか差動かを選択できますが、[3.1 MSP2807]のピンの説明にもあるように出力ピンがT_DOの1本のシングルエンドに固定されているため、1固定になります。このSER/DFRの値によって、A2-A0ビットの意味合いが変わってきます。SER/DFRがシングルエンドのときのA2-A0の仕様は以下になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/cebdedb2-1bc9-3996-399a-d7ab8b4e7578.png)

A2-A0でタッチ時のx座標を取得するのか、y座標を取得するのかを決定します。A2-A0を001とすることでy座標の取得、101にすることでx座標の取得ができます。z座標もあるようですが、今回は使用しません。

次に、MODEビットですが、x座標とy座標の分解能を決めることができます。今回は別にどちらでもいいですが、12ビットとしたいため、0固定とします。

最後に、PD1-PD0bitの説明になります。仕様は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/9579a09e-f576-d3a9-f8f5-33daeb3c0aac.png)

PENIRQは、T_IRQに該当します。PENIRQをEnableにすることで、タッチ時にT_IRQがLowになります。おそらくPENIRQがDisableになるような初期値になっているため、初期化時にEnbaleにする必要があります。

TSC2046にはADCが搭載されており、これを使用してタッチした際のアナログ値をデジタル値に変換します。このため、x、y座標を取得するためにはADCをオンにする必要があります。

上記をまとめまして、今回使用するコマンドを以下に示します。

|コマンド(hex) |説明  |
|---|---|
|0x80  |ADCをOFF、PENIRQをEnable|
|0xD1  |ADCをONにし、x座標を取得 |
|0x91  |ADCをONにし、y座標を取得  |

初期化時に0x80をTSC2046に送信し、まずPENIRQを有効にします。タッチを検出（PENIRQがLow）したら、0xD1、0x91を送信し、x、y座標を取得します。x、y座標送信時のタイミングチャートを以下に示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/0a7628c7-9d69-a27b-8edd-71b30732e69a.png)

ここで注意しなければならないのが、TSC2046からの受信データの扱い方です。今回は、座標の分解能は12bitなので、タイミングチャートの通りにデータが送信されてくるのですが、マイコン側のデータレジスタのサイズによってはデータをシフトする必要があります。私が使用するマイコンでは、SPIのデータレジスタのサイズが8bitのため、1回目は12bitのうち上位8bit、2回目は残りの4bitがSPIのデータレジスタのMSB側に詰められてセットされます。このため、2回目のデータを3bit右シフトしてから、1回目のデータとつなげる必要があります。

### 3.2 使用ボードとの接続図

使用しているnucleo l4r5ziとMSP2807の接続図を以下に示します。
いろんなサイトでは、使用するSPIのチャネルは1つだけで、CSは別々、その他のMOSI、MISO、SCKを共有して制御していましたが、今回はSPIのチャネルは2つ使用し、それぞれのチャネルで制御を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/4fb34158-b219-81dc-b475-ebf070d05c7a.png)

# 4. ソフトウェア構成図

調べたところ、MSP2807のライブラリがあるそうなのですが今回は使用しません。すべて自作します。

ソフトウェア構成図を以下に示します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/f1e936e5-a7bf-9d5c-1f39-1f22fd6c8083.png)

* __アプリ__
タッチスクリーン制御からのx、y座標をもとに線を描画する位置を決定します。

* __タッチスクリーン制御__
MSP2807制御が提供するIFを用いて、タッチ状態を取得しタッチしたこと、シングルクリックの判定やタッチ時のx、y座標を取得し平均化します。

* __MSP2807制御__
MSP2807を制御するソフトになります。用意する主なIFとしては、座標取得、タッチ状態取得、表示の3つになります。

* __SPIドライバ__
MSP2807と通信するためのSPIドライバになります。

* __DMAドライバ__
DMAのドライバになります。描画データの送信に使用します。


# 5. 実装
アプリとタッチスクリーン制御について簡単に説明したいと思います。
具体的な実装については、[Nucleo-L4R5ZI_System](https://github.com/Yas-Mi/Nucleo-L4R5ZI_System)を参照お願いいたします。

タッチスクリーン制御、アプリの順番で説明します。
### 5.1 タッチスクリーン制御

タッチスクリーン制御では、
* __タッチ状態判定__：タッチ状態を取得し、タッチされたことやシングルクリックの判定
* __x、y座標の平均化__：x、y座標を取得し平均化しアプリに通知

を行います。

#### 5.1.1 タッチ状態判定

30ms周期でT_IRQを監視し、3回連続T_IRQがLowになった場合にタッチされたと判定します。3回のうち、1回でもT_IRQがhighになった場合は離されたと判定します。

以下のタッチ状態を定義し、各状態に遷移したタイミングでアプリへ通知します。
* タッチ開始（TOUCH_START）
* タッチ中（TOUCHING）
* タッチ中から離した（RELEASE）
* シングルクリック（SINGLE_CLICK）

具体的なタイミングチャートを以下に示します。
* TOUCH_START、TOUCHING、RELEASE
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/c71cd0c7-f1d4-f98e-0577-7a235573ce64.png)

* SINGLE_CLICK
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/5b367d3e-03b1-33ab-7b47-7659eee66054.png)

#### 5.1.2 x、y座標の平均化

x、y座標の取得は「5.1.1 タッチ状態判定」で述べたTOUCHING状態の時に行います。同じ個所をタッチしても値が一定にならないようで、平均化する必要があるそうです。今回は、2回の平均をとることとします。2回の平均値を算出したタイミングでアプリへ通知するため、タッチしてから最速で270ms後（TOUCH_STARTまで90ms、TOUCHING2回で180ms）に線が秒されることになります。

#### 5.1.3 実装

T_IRQの値を取得しタッチ状態を判定するコードを以下に示します。
```C
// タッチの状態を取得
static void ts_msg_check_touch_state(uint32_t par)
{
	TOUCH_SCREEN_CTL *this = &ts_ctl;
	int32_t ret;
	uint16_t x, y;
	uint16_t lcd_x, lcd_y;
	uint8_t i;
	uint16_t sum_x, sum_y, ave_x, ave_y;
	
	// タッチ状態を取得
	this->touch_state_bmp |= (msp2807_get_touch_state() << this->check_touch_state_cnt++);
	
	// チェック回数に達していない場合は終了
	if (this->check_touch_state_cnt < CHECK_TOUCH_STATE_CNT) {
		return;
	}
	
	// チェック回数をクリア
	this->check_touch_state_cnt = 0;
	
	// タッチ確定
	if (this->touch_state_bmp == TS_TOUCHED) {
		if (this->click_state == TOUCH_NONE) {
			// クリック状態更新
			this->click_state = TOUCH_START;
			// タッチ開始コールバック
			do_callback(TS_CALLBACK_TYPE_TOUCH_START, 0, 0);
		}
		
		// シングルクリック判定
		if (this->touch_cnt++ < SINGLE_CLICK_CNT) {
			goto CHECK_STATE_END;
		}
		
		// 一定時間タッチすれば以降の処理に進む
		
		// クリック状態更新
		this->click_state = TOUCHING;
		
		// 座標を取得
		ret = msp2807_get_touch_pos(&x, &y);
		if (ret != E_OK) {
			console_str_send("msp2807_get_touch_pos error\n");
			goto CHECK_STATE_END;
			
		}
		
		// 覚えておく
		this->touch_x_val[this->get_touch_val_cnt] = x;
		this->touch_y_val[this->get_touch_val_cnt] = y;
		
		// 指定回数分取得した？
		if (++this->get_touch_val_cnt >= AVERAGE_CNT) {
			// 平均値を計算
			sum_x = 0;
			sum_y = 0;
			for (i = 0; i < AVERAGE_CNT; i++) {
				sum_x += this->touch_x_val[i];
				sum_y += this->touch_y_val[i];
			}
			ave_x = sum_x/AVERAGE_CNT;
			ave_y = sum_y/AVERAGE_CNT;
			
			// タッチパネルから取得した座標をLCDの座標に変換
			conv_touch2lcd(ave_x, ave_y, &lcd_x, &lcd_y);
			
			// タッチ中コールバック
			do_callback(TS_CALLBACK_TYPE_TOUCHING, lcd_x, lcd_y);
			
			// タッチ値取得回数をクリア
			this->get_touch_val_cnt = 0;
			
		}
		
	// タッチされていない	
	} else {
		// シングルクリック確定
		if ((this->click_state == TOUCH_START) && 		// 以前の状態がタッチ開始
			(this->touch_cnt <= SINGLE_CLICK_CNT)) {	// 連続タッチ数が一定以下
			// シングルクリックコールバック
			do_callback(TS_CALLBACK_TYPE_SINGLE_CLICK, lcd_x, lcd_y);
			
		// タッチ中からの離し
		} else if (this->click_state == TOUCHING) {
			// タッチ中からの離しコールバック
			do_callback(TS_CALLBACK_TYPE_RELEASE, lcd_x, lcd_y);
			
		} else {
			;
		}
		
		// 連続タッチ数をクリア
		this->touch_cnt = 0;
		
		// クリック状態更新
		this->click_state = TOUCH_NONE;
		
		// タッチ値取得回数をクリア
		this->get_touch_val_cnt = 0;
		
	}
	
CHECK_STATE_END:
	// タッチ状態をクリア
	this->touch_state_bmp = 0;
}
```

do_callback()の第1引数がタッチ状態を表してます。このdo_callback()でアプリへコールバックをあげます。


### 5.2 アプリ
アプリの線を描画するコードを以下に示します。
以下のdraw_line()は、タッチスクリーン制御からTOUCHINGのコールバックが通知されたタイミング（do_callback(TS_CALLBACK_TYPE_TOUCHING, lcd_x, lcd_y)が呼ばれたタイミング）で実行されます。


```C
// 描画
static void draw_line(uint16_t x, uint16_t y)
{
	TEST2_CTL *this = &test2_ctl;
	uint16_t xn, yn;
	uint16_t start_x, start_y;
	uint16_t end_x, end_y;
	uint32_t idx[9];
	uint8_t i, j, k;
	float a;
	int16_t b;
	
	// 初回は一転のみ描画
	if (this->first_flag == FALSE) {
		start_x =  x;
		end_x =  x;
		this->first_flag = TRUE;
		
	} else {
		// 小さいほうを開始位置、大きいほうを終了位置にする
		if (this->pre_x > x) {
			start_x =  x;
			end_x =  this->pre_x;
		} else {
			start_x = this->pre_x;
			end_x =  x;
		}
	}
	
	// 一次関数の傾きと切片を求める (y = ax + b)
	a = (float)((float)(y - this->pre_y)/(float)(x - this->pre_x));
	b = (int16_t)((float)y- (float)x * a);
	
	// 2点間の描画
	for (xn = start_x; xn < end_x; xn++) {
		// xn, yn を算出
		yn = (int16_t)(a * (float)xn + (float)b);	
		// 上下左右斜めの座標を算出
		idx[0] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[1] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[2] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		idx[3] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[4] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[5] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		idx[6] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[7] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[8] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		
		// 書き込み
		for (i = 0; i < 9; i++) {
			// 黒書き込み
			this->disp_data[idx[i]] = 0x0000;
		}
	}
	
	// 描画
	ts_mng_write(this->disp_data);
	
	// 前回値更新
	this->pre_x = x;
	this->pre_y = y;
}
```
描画のやり方としては、1つ前のx、y座標と現在のx、y座標の間の座標を算出し、それら座標を塗りつぶすことで線を描いていきます。

一つ前のx、y座標と現在のx、y座標の間の座標は1次関数を用いて算出します。具体的には以下のように算出しています。
```C
	// 一次関数の傾きと切片を求める (y = ax + b)
	a = (float)((float)(y - this->pre_y)/(float)(x - this->pre_x));
	b = (int16_t)((float)y- (float)x * a);
```
中学生の時に習った一次関数がここで役に立つとは思いませんでした。上記のx, yが今回の座標でthis->pre_x, this->pre_yが前回の座標になります。

前回と今回のx座標を比較し、小さいx座標を始点にして、大きいx座標までのy座標を以下のように算出します。1つの点だけだと小さすぎて見えなかったため、算出したx, y座標を中心にして上下斜め左右の点も算出しそこに対しても塗りつぶすようにしました。

```C
	// 2点間の描画
	for (xn = start_x; xn < end_x; xn++) {
		// xn, yn を算出
		yn = (int16_t)(a * (float)xn + (float)b);	
		// 上下左右斜めの座標を算出
		idx[0] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[1] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[2] = ((MSP2807_DISPLAY_HEIGHT - (yn - 1))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		idx[3] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[4] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[5] = ((MSP2807_DISPLAY_HEIGHT - (yn + 0))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		idx[6] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn - 1);
		idx[7] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn + 0);
		idx[8] = ((MSP2807_DISPLAY_HEIGHT - (yn + 1))*MSP2807_DISPLAY_WIDTH) + (xn + 1);
		
		// 書き込み
		for (i = 0; i < 9; i++) {
			// 黒書き込み
			this->disp_data[idx[i]] = 0x0000;
		}
	}
```

# 6. 動作確認
実際にお絵描きした動画を載せます。
![ビデオ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2782242/7470221f-1636-27c0-085d-daadad265f2b.gif)

少し遅れて線が描画されていますが、いい感じに描画できています。これまでにお伝えしていませんでしたが、シングルクリック判定時に白紙に戻すようにしています。最後にシングルクリックをしていますが、ちゃんと白紙に戻っています。

# 7. 参考サイトまとめ
参考にさせていただいたサイトを以下にまとめておきます。
1. MSP2807の仕様 : [2.8inch SPI Module ILI9341 SKU:MSP2807](https://akizukidenshi.com/catalog/g/gM-16265/)
2. ILI9341のマニュアル :  [a-Si TFT LCD Single Chip Driver 240RGBx320 Resolution and 262K color](https://cdn-shop.adafruit.com/datasheets/ILI9341.pdf)
3. ili9341の制御 :  [TFT液晶 ILI9341r](http://try3dcg.world.coocan.jp/note/spi/ILI9341.html)
4. TSC2046のマニュアル : [低電圧I/O タッチスクリーン・コントローラ データシート](https://www.ti.com/jp/lit/ds/symlink/tsc2046.pdf?ts=1703760504825&ref_url=https%253A%252F%252Fwww.ti.com%252Fproduct%252Fja-jp%252FTSC2046)
5. タッチスクリーンの制御1 : [SPI：TSC2046 (タッチスクリーン)](http://try3dcg.world.coocan.jp/note/spi/TSC2046.html)
6. タッチスクリーンの制御2 : [78K0R/Kx3 サンプル・プログラム タッチスクリーン編](https://www.renesas.com/jp/ja/document/apn/723181)

# 8. 最後に
今回は、初めてタッチパネルを触りました。
やはり思うのは、音声や表示の制御において、DMAは必須だということです。ただ、DMAを使用しても描画にある程度時間はかかってしまうため、今後は部分的に表示を更新するようにしたいと考えています。

タッチした際のx, y座標の取得については、意外と制御が簡単だと感じました。最初はタッチパネル側がSPIのマスタ側になると想定していたので、SPIのスレーブとして動作するドライバを作らないとなあと思っていました。結局、タッチパネルはスレーブとして動作するということだったので、スレーブとして動作するドライバを作らなくて済みました。（スレーブとして動作するドライバは作ったことがなかったので、これが良かったのが悪かったのかわかりませんが、、、）

最後になりますが、何かご意見、不明点がございましたらコメントいただけると幸いです。