Lチカで始めるテスト自動化(17)コマンド制御のBLE Keyboard & MouseをM5Stackで製作しiOSアプリをテストスクリプトで操作する

##1. はじめに
[M5Stack CORE2 UiFlowからArduino開発環境への移植 ～ タッチパネル × Faces(エンコーダ) × Bluetoothデバイス制御](https://qiita.com/RAWSEQ/items/31ab5185687f67a3dd94)を拝読しM5StackでBLE(Bluetooth Low Energy)のキーボードやマウスを手軽に作れることが分かりました。そこで、コマンド制御のBLE Keyboard & MouseをM5Stackで製作しiOSアプリをテストスクリプトで操作します。

![bleHID.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/8923a7ea-7300-201f-eabf-14ec4fd36be5.png)

##2. BLE Keyboard&Mouse
[M5Stackでコマンド操作](https://qiita.com/pbjpkas/items/c8c26837a1a5a243f4b6)のコマンドインターフェースプログラムに[Arduino Leonardoで多目的ツールの製作](https://qiita.com/pbjpkas/items/97dbf835b0aab6725e94)のキーボードおよびマウスのコマンドを移植します。BLE Keyboard & Mouseは[blackketter/ESP32-BLE-Combo](https://github.com/blackketter/ESP32-BLE-Combo)のESP32 BLE Combo Keyboard & Mouse libraryを利用させていただきます。

###2.1 ビルド環境
- Arduino IDE 1.8.15(Windows Store 1.8.49.0)
- esp32 by Espressif Systems Ver. 1.0.4

###2.2 ESP32 BLE Combo Keyboard & Mouse library
1. [blackketter/ESP32-BLE-Combo](https://github.com/blackketter/ESP32-BLE-Combo)→緑色の"Code"ボタン→Download ZIP
2. Arduino IDEでスケッチ→ライブラリをインクルード→.ZIP形式のライブラリをインストール...とたどり、ダウンロードしたZIPファイルをインストール

###2.3 コマンド
表1および表2にコマンドを示します。

表1 コマンド一覧

|コマンド|引数1                     |引数2              |機能
|:-------|:-------------------------|:------------------|:---
|help    |なし                      |なし               |コマンドの一覧と簡単な説明を表示する
|?       |なし                      |なし               |コマンドの一覧と簡単な説明を表示する
|ver     |なし                      |なし               |ファイル名とビルド日時を表示する
|typesize|なし                      |なし               |型のサイズをバイト数で表示する
|str     |文字列                    |なし               |文字列を打鍵する(ASCII CODE 0x20-0x7Fのみ)
|key     |押下したいキー(表2)       |なし               |キーを打鍵(press&release)する
|key     |alt                       |押下したいキー(表2)|左Altキーとの同時押しでキーを打鍵する
|key     |ctrl                      |押下したいキー(表2)|左Ctrlキーとの同時押しでキーを打鍵する
|key     |shift                     |押下したいキー(表2)|左Shiftキーとの同時押しでキーを打鍵する
|key     |win                       |押下したいキー(表2)|Windowsロゴキーとの同時押しでキーを打鍵する
|key     |ctrlalt                   |押下したいキー(表2)|左Ctrlキー、左Altキーとの同時押しでキーを打鍵する
|mouse   |sc                        |なし               |マウス左ボタンのシングルクリック
|mouse   |wc                        |なし               |マウス左ボタンのダブルクリック
|mouse   |rc                        |なし               |マウス右ボタンのシングルクリック
|mouse   |p                         |なし               |マウス左ボタンのプレス
|mouse   |r                         |なし               |マウス左ボタンのリリース
|mouse   |scr                       |スクロール量       |マウスホイールの回転
|mouse   |水平方向の移動量          |垂直方向の移動量   |マウスカーソルの移動

表2 keyコマンドの引数

|キー            |引数の与え方
|:---------------|:---------
|ASCII文字(1文字) |そのまま入力します
|Enterキー       |ent
|Spaceキー       |sp
|Tabキー         |tab
|Backspaceキー   |bs
|Deleteキー      |del
|Escapeキー      |esc
|PrintScreenキー |ps
|↑キー          |ua
|↓キー          |da
|←キー          |la
|→キー          |ra
|PAGE UPキー     |pu
|PAGE DOWNキー   |pd

- スペースを含む文字列はstrコマンドとkey spコマンドに分けて入力します。

###2.4 スケッチ

```c:M5Stack_bleHID.ino
/*
 * M5Stack Command Controled BLE Keyboard&Mouse Combo Device 
 * by ka's 2021
 * 
 * This software includes the work that is distributed 
 * in the Apache License 2.0
 * 
 * M5Stackでコマンド操作
 * https://qiita.com/pbjpkas/items/c8c26837a1a5a243f4b6
 * 
 * Arduino Leonardoで多目的ツールの製作
 * https://qiita.com/pbjpkas/items/97dbf835b0aab6725e94
 * 
 * BleCombo.h
 *   ESP32 BLE Combo Keyboard & Mouse library
 *   https://github.com/blackketter/ESP32-BLE-Combo
 */
#include <stdio.h>
#include <string.h>
#include <BleCombo.h>
#include <M5Stack.h>

// wait
#define ANTI_CHATTER      50 //50ms

// keycode
#define KEY_NUL         0x00
#define KEY_PRINTSCREEN 0xCE
#define KEY_SCROLLLOCK  0xCF
#define KEY_PAUSE       0xD0

void setup();
void loop();

// Keyboard
void keyboard_key(int primary_key, int secondary_key, int key_code);

// Mouse
void mouse_single_click(void);
void mouse_double_click(void);

// Command Mode
void cmd_print_help(void);
void cmd_print_ver(void);
int  cmd_str(char *str);
int  cmd_str_to_char(char *str);
int  cmd_key(int primary_key, int secondary_key, char *str);
int  cmd_execute(char *buf);
void cmd_rx_data(void);

// the setup routine runs once when M5Stack starts up
void setup()
{
    // Initialize the M5Stack object
    M5.begin();
    Serial.begin(115200);

    // LCD display
    M5.Lcd.print("M5Stack BLE Combo Device Ver.001");

    // BLE Mouse, Keyboard
    Mouse.begin();
    Keyboard.begin();
}

// the loop routine runs over and over again forever
void loop()
{
    if(Serial.available())
    {
        cmd_rx_data();
    }
}

/***********************************************************************
  Keyboard
 ***********************************************************************/
void keyboard_key(int primary_key, int secondary_key, int key_code)
{
    if(primary_key  ){ Keyboard.press(primary_key  ); }
    if(secondary_key){ Keyboard.press(secondary_key); }
    Keyboard.press(key_code);
    delay(ANTI_CHATTER);
    Keyboard.releaseAll();
    delay(ANTI_CHATTER);
}

/***********************************************************************
  Mouse
 ***********************************************************************/
void mouse_single_click(int button)
{
    Mouse.click(button); 
    delay(ANTI_CHATTER);
}
void mouse_double_click(int button)
{
    mouse_single_click(button);
    mouse_single_click(button);
}

/***********************************************************************
  Command Mode
 ***********************************************************************/
#define CMD_OK          0
#define CMD_INVALID    -1
#define CMD_MALLOC_ERR -2
#define CMD_BUF_LENGTH 64 // 63+1
#define CMD_MAX_LENGTH 64 // 63+1
#define ARG_MAX_LENGTH 64 // 63+1

void cmd_print_help(void)
{
    Serial.print("Available Command:\n");
    Serial.print("help, ?    : Print Help Messages\n");
    Serial.print("ver        : Print Version Information\n");
    Serial.print("typesize   : print type's size\n");
}

void cmd_print_ver(void)
{
    Serial.print(__FILE__);
    Serial.print(" ");
    Serial.print(__DATE__);
    Serial.print(" ");
    Serial.print(__TIME__);
    Serial.print("\n");
}

int cmd_str(char *str)
{
    int i;
    int len;
    len = strlen(str);

    for(i=0; i<len; i++)
    {
        if(str[i]<0x20)
        {
            return CMD_INVALID;
        }
    }

    for(i=0; i<len; i++){ keyboard_key(KEY_NUL, KEY_NUL, str[i]); }
    return CMD_OK;
}

int cmd_str_to_char(char *str)
{
    int keycode;
    char buf[2];

    if     (strcmp(str, "ent")==0){ keycode = KEY_RETURN; }
    else if(strcmp(str, "sp" )==0){ keycode = 0x20; }
    else if(strcmp(str, "tab")==0){ keycode = KEY_TAB; }
    else if(strcmp(str, "bs" )==0){ keycode = KEY_BACKSPACE; }
    else if(strcmp(str, "del")==0){ keycode = KEY_DELETE; }
    else if(strcmp(str, "esc")==0){ keycode = KEY_ESC; }
    else if(strcmp(str, "ps" )==0){ keycode = KEY_PRINTSCREEN; }
    else if(strcmp(str, "ua" )==0){ keycode = KEY_UP_ARROW; }
    else if(strcmp(str, "da" )==0){ keycode = KEY_DOWN_ARROW; }
    else if(strcmp(str, "la" )==0){ keycode = KEY_LEFT_ARROW; }
    else if(strcmp(str, "ra" )==0){ keycode = KEY_RIGHT_ARROW; }
    else if(strcmp(str, "pu" )==0){ keycode = KEY_PAGE_UP; }
    else if(strcmp(str, "pd" )==0){ keycode = KEY_PAGE_DOWN; }
    else
    {
        if(strlen(str)>1) { return CMD_INVALID; }
        sscanf(str, "%c", buf);
        keycode = buf[0];
        if((keycode<0x20) || (keycode>0x7f)){ return CMD_INVALID; }
    }
    return keycode;
}

int cmd_key(int primary_key, int secondary_key, char *str)
{
    int keycode;
    keycode = cmd_str_to_char(str);
    if(keycode<0)
    {
        return CMD_INVALID;
    }
    keyboard_key(primary_key, secondary_key, keycode);
    return CMD_OK;
}

int cmd_execute(char *buf)
{
    int i, x, y;
    unsigned int ux;
    int return_val = CMD_OK;
    char cmd[CMD_MAX_LENGTH];
    char arg1[ARG_MAX_LENGTH];
    char arg2[ARG_MAX_LENGTH];

    strcpy(cmd, "");
    strcpy(arg1, "");
    strcpy(arg2, "");
    sscanf(buf, "%s %s %s", &cmd, &arg1, &arg2);

    if     (strcmp(cmd, "help")==0){ cmd_print_help(); }
    else if(strcmp(cmd, "?"   )==0){ cmd_print_help(); }
    else if(strcmp(cmd, "ver" )==0){ cmd_print_ver();  }

    else if(strcmp(cmd, "typesize")==0)
    {
        Serial.print(  "char      : "); Serial.print(sizeof(char));
        Serial.print("\nshort     : "); Serial.print(sizeof(short));
        Serial.print("\nint       : "); Serial.print(sizeof(int));
        Serial.print("\nlong      : "); Serial.print(sizeof(long));
        Serial.print("\nlong long : "); Serial.print(sizeof(long long));
        Serial.print("\n");
    }

    else if(strcmp(cmd, "str")==0){ return_val = cmd_str(&arg1[0]); }
    else if(strcmp(cmd, "key")==0)
    {
        if     (strcmp(arg1, "alt"    )==0){ return_val = cmd_key(KEY_LEFT_ALT,   KEY_NUL,      &arg2[0]); }
        else if(strcmp(arg1, "ctrl"   )==0){ return_val = cmd_key(KEY_LEFT_CTRL,  KEY_NUL,      &arg2[0]); }
        else if(strcmp(arg1, "shift"  )==0){ return_val = cmd_key(KEY_LEFT_SHIFT, KEY_NUL,      &arg2[0]); }
        else if(strcmp(arg1, "win"    )==0){ return_val = cmd_key(KEY_LEFT_GUI,   KEY_NUL,      &arg2[0]); }
        else if(strcmp(arg1, "ctrlalt")==0){ return_val = cmd_key(KEY_LEFT_CTRL,  KEY_LEFT_ALT, &arg2[0]); }
        else if(strlen(arg1) != 0         ){ return_val = cmd_key(KEY_NUL,        KEY_NUL,      &arg1[0]); }
        else
        {
            return CMD_INVALID;
        }
    }
    else if(strcmp(cmd, "mouse")==0)
    {
        if     (strcmp(arg1, "sc" )==0){ mouse_single_click(MOUSE_LEFT ); }
        else if(strcmp(arg1, "wc" )==0){ mouse_double_click(MOUSE_LEFT ); }
        else if(strcmp(arg1, "rc" )==0){ mouse_single_click(MOUSE_RIGHT); }
        else if(strcmp(arg1, "p"  )==0){ if(!Mouse.isPressed(MOUSE_LEFT)){ Mouse.press  (MOUSE_LEFT); } }
        else if(strcmp(arg1, "r"  )==0){ if( Mouse.isPressed(MOUSE_LEFT)){ Mouse.release(MOUSE_LEFT); } }
        else if(strcmp(arg1, "scr")==0)
        {
            x = atoi(arg2);
            if(!x){ return CMD_INVALID; }
            for(i=0; i<abs(x); i++)
            {
                if(x>=0){ Mouse.move(0,0, 1); }
                else    { Mouse.move(0,0,-1); }
                delay(25);
            }
        }
        else
        {
            x = atoi(arg1);
            y = atoi(arg2);
            for(i=0; i<abs(x); i++)
            {
                if(x>=0){ Mouse.move( 1,0); }
                else    { Mouse.move(-1,0); }
                delay(5);
            }
            for(i=0; i<abs(y); i++)
            {
                if(y>=0){ Mouse.move(0, 1); }
                else    { Mouse.move(0,-1); }
                delay(5);
            }
        }
    }

    else
    {
        return CMD_INVALID;
    }

    return return_val;

}

void cmd_rx_data(void)
{
    static int i=0;
    static char buf[CMD_BUF_LENGTH];
    int return_val = CMD_OK;

    if(Serial.available())
    {
        buf[i] = Serial.read();
        Serial.print(buf[i]); //echo-back

        if ( (buf[i] == 0x08) or (buf[i] == 0x7f) ) //BackSpace, Delete
        {
            buf[i] = '\0';
            if(i) i--;
        }
        else if( (buf[i] == '\r') or (buf[i] == '\n') )
        {
            buf[i] = '\0';
            return_val = cmd_execute(&buf[0]);
            for(i=0; i<CMD_BUF_LENGTH; i++) buf[i] = '\0';
            i=0;

            if(return_val == CMD_INVALID)
            {
                Serial.print("?\n");
            }
            else
            {
                Serial.print("OK\n");
            }
        }
        else
        {
            i++;
            if(i>=CMD_BUF_LENGTH)
            {
                Serial.print("### CMD BUFFER FULL, CLEAR. ###\n");
                for(i=0; i<CMD_BUF_LENGTH; i++) buf[i] = '\0';
                i=0;
            }
        }
    }
}
```

##3. iOSアプリの操作

###3.1 動作デモ
iPad(iPad OS 14.6)のSafariでQiitaのトップページをあらかじめ開いておきます。
また、自動大文字入力の設定をオフにして入力した文字の一文字目が自動で大文字になるのを抑止します[^1]。

1. 検索窓にマウスポインタを移動する
1. 検索窓でマウスを左クリックする
1. "user:pbjpkas"およびEnterキーを入力しページを遷移させる
1. マウスポインタを元の場所へ移動する

![動作デモ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/3d7c0bb7-faf4-882d-52a9-d0d8cc6b14bf.gif)

###3.2 テストランナー
[Lチカで始めるテスト自動化(14)sleepの時間をランダムに設定する](https://qiita.com/pbjpkas/items/4f023917f31f6096eb6b)に掲載の[test-runner.py](https://qiita.com/pbjpkas/items/4f023917f31f6096eb6b#%E4%BB%98%E9%8C%B2a-test-runnerpy%E3%81%AE%E3%82%BD%E3%83%BC%E3%82%B9)をテストランナーとして利用しWindows 10のPython 3.6.9で実行します。

```cmd:
C:\hoge>python test-runner.py blecombo
```

###3.3 テストスクリプト
キーボードやマウスをコマンド操作するスクリプトは下記4行でワンセットです。

```csv:例
send,mouse 200 -150  ←M5Stackに"mouse 200 -150"というコマンドを送る
rcvd                 ←M5Stackのコマンドのエコーバックを読み捨てる
sleep,4              ←マウスポインタの移動が終わるまで待つ
rcvd                 ←M5Stackのコマンドの実行結果(OK or ?)を読み捨てる
```

テストスクリプト全体を以下に示します。

```csv:blecombo.csv
#open UART
open_uart,COM3,0
sleep,4
rcvd
#
send,mouse 200 -150
rcvd
sleep,4
rcvd
#
send,mouse sc
rcvd
sleep,2
rcvd
#
send,str user:pbjpkas
rcvd
sleep,4
rcvd
send,key ent
rcvd
sleep,4
rcvd
#
send,mouse -200 150
rcvd
sleep,4
rcvd
```

テスト実行結果を以下に示します。

```csv:blecombo_result.csv
2021/06/16 00:10:44,#open UART,OK
2021/06/16 00:10:44,open_uart,COM3,0,OK
2021/06/16 00:10:44,sleep,4,OK
2021/06/16 00:10:48,rcvd,,OK
2021/06/16 00:10:49,#,OK
2021/06/16 00:10:49,send,mouse 200 -150,OK
2021/06/16 00:10:49,rcvd,mouse 200 -150,OK
2021/06/16 00:10:49,sleep,4,OK
2021/06/16 00:10:53,rcvd,OK,OK
2021/06/16 00:10:53,#,OK
2021/06/16 00:10:53,send,mouse sc,OK
2021/06/16 00:10:53,rcvd,mouse sc,OK
2021/06/16 00:10:53,sleep,2,OK
2021/06/16 00:10:55,rcvd,OK,OK
2021/06/16 00:10:55,#,OK
2021/06/16 00:10:55,send,str user:pbjpkas,OK
2021/06/16 00:10:55,rcvd,str user:pbjpkas,OK
2021/06/16 00:10:55,sleep,4,OK
2021/06/16 00:10:59,rcvd,OK,OK
2021/06/16 00:10:59,send,key ent,OK
2021/06/16 00:10:59,rcvd,key ent,OK
2021/06/16 00:10:59,sleep,4,OK
2021/06/16 00:11:03,rcvd,OK,OK
2021/06/16 00:11:03,#,OK
2021/06/16 00:11:03,send,mouse -200 150,OK
2021/06/16 00:11:03,rcvd,mouse -200 150,OK
2021/06/16 00:11:03,sleep,4,OK
2021/06/16 00:11:07,rcvd,OK,OK
```

###3.4 known issues
- テストスクリプトが終了してUARTをクローズする際にM5Stackがリセットします。
- マウスポインタを滑らかに動かすようなチューニングは特に行っていません。

##4. おわりに
- コマンド制御のBLE Keyboard & MouseをM5Stackで製作しiOSアプリをテストスクリプトで操作できました。
- 2019年9月に[品質技術を知る、学ぶ、作る](https://speakerdeck.com/pbjpkas/pin-zhi-ji-shu-wozhi-ru-xue-bu-zuo-ru)でBluetoothのキーボードを作ったときはC/C++で1000行くらいかかっていたのが今はライブラリをインストールしてBleCombo.hをインクルードすればサクッと作れるようになっていて、とってもありがたいです。

##付録. Lチカで始めるテスト自動化・記事一覧
電子書籍化したものを技術書典で[頒布](https://techbookfest.org/product/5661312486998016)しています。

1. [Lチカで始めるテスト自動化](https://qiita.com/pbjpkas/items/fd4bd2689deda2d793aa)
2. [Lチカで始めるテスト自動化(2)テストスクリプトの保守性向上](https://qiita.com/pbjpkas/items/337bce392281ee5f0526)
3. [Lチカで始めるテスト自動化(3)オシロスコープの組込み](https://qiita.com/pbjpkas/items/b80b7e199a5aed4688a4)
4. [Lチカで始めるテスト自動化(4)テストスクリプトの保守性向上(2)](https://qiita.com/pbjpkas/items/a02c4128baf5a3f2a46c)
5. [Lチカで始めるテスト自動化(5)WebカメラおよびOCRの組込み](https://qiita.com/pbjpkas/items/9af9e6441e1e20c5909e)
6. [Lチカで始めるテスト自動化(6)AI(機械学習)を用いたPass/Fail判定](https://qiita.com/pbjpkas/items/405d55f809da1221b867)
7. [Lチカで始めるテスト自動化(7)タイムスタンプの保存](https://qiita.com/pbjpkas/items/8e3c4330f4a81d30bd53)
8. [Lチカで始めるテスト自動化(8)HDMIビデオキャプチャデバイスの組込み](https://qiita.com/pbjpkas/items/58f84c347e44fb9d5635)
9. [Lチカで始めるテスト自動化(9)6DoFロボットアームの組込み](https://qiita.com/pbjpkas/items/7a4cb4c59886b1fd2cf9)
10. [Lチカで始めるテスト自動化(10)6DoFロボットアームの制御スクリプトの保守性向上](https://qiita.com/pbjpkas/items/c011d7ad6f98bc1458d5)
11. [Lチカで始めるテスト自動化(11)ロボットアームのコントローラ製作](https://qiita.com/pbjpkas/items/fa6d2956ea3dd18b3584)
12. [Lチカで始めるテスト自動化(12)書籍化の作業メモ](https://qiita.com/pbjpkas/items/e76a763de7f496d5430c)
13. [Lチカで始めるテスト自動化(13)外部プログラムの呼出し](https://qiita.com/pbjpkas/items/717b2aba39798d8cab1d)
14. [Lチカで始めるテスト自動化(14)sleepの時間をランダムに設定する](https://qiita.com/pbjpkas/items/4f023917f31f6096eb6b)
15. [Lチカで始めるテスト自動化(15)Raspberry Pi Zero WHでテストランナーを動かして秋月のIoT学習HATキットに進捗を表示する](https://qiita.com/pbjpkas/items/a2e7551ae10f7dce2505)
16. [Lチカで始めるテスト自動化(16)秋月のIoT学習HATキットにBME280を接続してテスト実行環境の温度・湿度・気圧を取得する](https://qiita.com/pbjpkas/items/0a48ca71a2de0042e188)

[^1]: [iPadのアルファベット自動大文字入力をオフにする](https://weekly.ascii.jp/elem/000/001/810/1810145/)