M5Stackで観測した通信データを表示するモニターを作成

# はじめに
お仕事用に、とある通信データをコマンドごとにデータパースして表示するモニター機能をM5Stackで作りました。

全部のコードは載せないですが、複数のデータの表示機能を作る上で、いくつか工夫したことを自分の備忘録も兼ねて記事にしました。

![61467247_871677453165046_8468061746764972032_n.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302503/434b8c5e-d30a-b793-750d-5acc55ef86dd.jpeg)
画面はダミーデータを表示しています。

# やりたかったこと
センサーなどいろいろ通信するデータの各コマンドごとの最新値を表示するデバイスがほしかったです。

イメージはこちらを参照してもらえると湧くかと思います。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/M5Stack?src=hash&amp;ref_src=twsrc%5Etfw">#M5Stack</a> で、とある通信データをコマンドごとにメニュー分けて表示するモニター作りました。<br><br>（趣味で作った）お仕事用のやつなので完全公開しないけど、ダミーデータ2画面分でイメージを共有。 <a href="https://t.co/cgX7o6q8oO">pic.twitter.com/cgX7o6q8oO</a></p>&mdash; Katsu Shun (@katsushun89) <a href="https://twitter.com/katsushun89/status/1133388659267448832?ref_src=twsrc%5Etfw">2019年5月28日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


コマンドごとに1画面用意して、そのコマンドで通信しているデータを画面上で表示します。
表示する値は最新値のみ表示すれば良いものとします。

# 通信データの仮定
仮に、コマンド:1Byteと、データ:最大20Byteの通信プロトコルとします。
コマンドごとに使用するデータの構造が変わります。
例えば、こんな感じです。

コマンド：0x01
データ構造：test_format_1_t

```test_pyload.h
typedef struct {
    uint8_t data_1;
    uint8_t data_2;
    uint16_t data_3;
    uint16_t data_4;
    uint8_t reserve[14];
} test_format_1_t;
```

コマンド：0x02
データ構造：test_format_2_t

```test_pyload.h
typedef struct {
    uint8_t data_1[10];
    uint16_t data_2[4];
    uint8_t reserve[2];
} test_format_2_t;
```

各コマンドごとのデータが扱えるようにunionも用意しておきます。

```test_payload.h
typedef union {
    uint8_t byte[20];
    test_format_1_t test_format_1;
    test_format_2_t test_format_2;
} data_payload_t;
```


# 通信データのセット
各コマンドの通信データの最新値をセットするために、std::mapを使いました。（他の辞書的に検索できるやつでもいいです。）
参考：[std::mapまとめ](https://qiita.com/_EnumHack/items/f462042ec99a31881a81)

key:コマンド
value:受信データ
とします。
これでコマンドだけ指定すればデータにアクセスできます。

```
std::map<uint8_t, data_payload_t> recv_msgs;
```



通信データのセットはこのようにします。

```
void setRecvData(uint8_t cmd_id, data_payload_t recv_data)
{
    recv_msgs[cmd_id] = recv_data;
}
```

これで、わざわざ受信するコマンドごとに変数を確保しないでもデータを保持できます。
同じコマンドであれば値が上書きされます。

データの取り出しは、このようにしました。（ここはもうちょっとよくできそうな気がします。現状は少し手間です。）

```
enum {
  CMD_ID_TEST_1 = 0x01,
  CMD_ID_TEST_2 = 0x02,
};

test_format_1_t getTestFormat1(void)
{
    return recv_msgs[CMD_ID_TEST_1].test_format_1;
}
```


# 表示MENUの構成

通信データを表示するMENUは上部にTITLE、左側にデータ名、右側にデータ値を表示します。
通信コマンドごとにMENU表示はAボタン押しでトグルするようにしました。

チラつきを出来る抑えたいので毎回画面全体を表示しないようにしました。
具体的には、
- MENU更新時(Aボタン押し)：左側のデータ名(MENU)を再描画
- 常時（通信データ監視中）：右側の各データ値を再描画
としています。

![monitor_screen.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/302503/922d17b6-b026-46bc-c7af-88b016bd35fa.jpeg)


### TITLEとMENUの表示設定
最初の1行目はTITLE（通信データのコマンド名など）を表示としています。
2行目以降の左側は各データ名を表示するので、`String`の配列を渡して描画する関数を用意しました。
ここで肝なのは、あとでデータ値を右側に書くのでデータ名の最大の横幅を求めておきます。
それをあとでデータ値の描画位置に使います。

```Screen.cpp
void Screen::drawEachMenu(String menu[], uint32_t menu_size, uint8_t line_cnt)
{
    int16_t max_menu_width = 0;

    for(uint8_t i = 0; i < menu_size; i++){
        M5.Lcd.drawString(menu[i], 0, line_cnt * line_height_); line_cnt++;
        int16_t text_width = M5.Lcd.textWidth(menu[i]);
        if(text_width >= max_menu_width){
            max_menu_width = text_width; //データ名の最大幅を求めておく
        }
    }
    data_value_offset_ = max_menu_width + menu_data_space_; //データ値の描画オフセットX

}

void Screen::drawTitleMenu(String title, String menu[], uint32_t menu_size)
{
    resetScreen(); //全画面クリア

    uint8_t line_cnt = 0;
    setTitleFormat(); //Title用の文字・背景色の設定
    M5.Lcd.drawString(title, 0, 1 + line_cnt * line_height_); line_cnt++;

    setTextFormat(); //Title以外のMENUなどの文字・背景色の設定
    drawEachMenu(menu, menu_size, line_cnt);
}

void Screen::setTextFormat(void)
{
    M5.Lcd.setTextFont(text_font_);
    M5.Lcd.setTextSize(text_size_);
    M5.Lcd.setTextColor(text_color_, bg_color_);
}

void Screen::setTitleFormat(void)
{
    setTextFormat();

    int32_t x = 0;
    int32_t y = 0;
    int32_t w = 320 - x;
    int32_t h = line_height_ - 2;
    M5.Lcd.fillRect(x, y, w, h, title_bg_color_); //fill title space(1st line)

    M5.Lcd.setTextColor(title_text_color_, title_bg_color_);
}

void Screen::resetScreen(void)
{
    M5.Lcd.fillScreen(bg_color_);
}
```

`line_height_`は1行あたりのY方向のサイズです。だいたいこのときは文字の高さは18だったので、`line_height_ = 20`としました。

これで1画面ごとのMENUの設定は下記の関数で行います。

```Screen.cpp
void Screen::drawMenuTest1(void)
{
    String title = "TEST MENU 1";
    String menu[] = {
        "STR DATA",
        "BOOL DATA",
        "FLOAT DATA",
        "INT DATA",
    };
    drawTitleMenu(title, menu, sizeof(menu)/sizeof(menu[0]));
}

void Screen::drawMenuTest2(void)
{
    String title = "TEST MENU 2";
    String menu[] = {
        "AAA [V]",
        "BBB [A]",
        "CCC [%]",
    };
    drawTitleMenu(title, menu, sizeof(menu)/sizeof(menu[0]));
}
```


### データ値の表示設定
データ値は受信した値ごとに表示する内容を設定します。

MENU(データ名)のときに設定した`data_value_offset_` をx方向の描画開始位置にして、各データ値を書いていきます。

```Screen.cpp
void Screen::drawEachData(String data[], uint32_t data_size, uint8_t line_cnt)
{
    int16_t text_width = 0;
    int32_t x = 0;
    int32_t y = 0; 
    int32_t w = 0;
    int32_t h = 0;

    for(uint8_t i = 0; i < data_size; i++){
        M5.Lcd.drawString(data[i], data_value_offset_, line_cnt * line_height_);
        text_width = M5.Lcd.textWidth(data[i]);
        x = data_value_offset_+ text_width;
        y = line_cnt * line_height_;
        w = 320 - x;
        h = line_height_;
        M5.Lcd.fillRect(x, y, w, h, bg_color_); //fill space after data text
        line_cnt++;
    }
}
```

またここで`M5.Lcd.textWidth()`や`fillRect()`を使っていますが、これはデータ値の文字列サイズが値によって変わるため、後半の領域を背景色で塗りつぶすためです。
これをやらないとデータ値が`100` -> `10` と桁が減った際に最後の文字が残ってしまうことになります。


上記の`drawEachData()`を使って、いくつかパターンごとに分けた場合のサンプルを書きます。

```Screen.cpp
void Screen::drawDataTest1(void)
{
    uint8_t line_cnt = 1;
    test_format_1_t test_1 = getTestFormat1();

    String data_1_str;
    switch(test_1.data_1){
    case 0:
        data_1_str = "A";
        break;
    case 1:
        data_1_str = "AA";
        break;
    case 2:
        data_1_str = "AAA";
        break;
    }
    bool is_data_2 = (test_1.data_2 == 1 ? true : false);    
    String data_2_str = (is_data_2 ? "TRUE" : "FALSE");

    String data_3_str = String(test_1.data_3);
    String data_4_str = String(test_1.data_4);
    
    String data[] = {
        data_1_str,
        data_2_str,
        data_3_str,
        data_4_str,
    };
    drawEachData(data, sizeof(data)/sizeof(data[0]), line_cnt);
}

void Screen::drawDataTest2(void)
{
    uint8_t line_cnt = 1;
    test_format_2_t test_2 = getTestFormat2();

    String data_1_str = String(test_2.data_1);
    String data_2_str = String(test_2.data_2);
    String data_3_str = String(test_2.data_3);
    
    String data[] = {
        data_1_str,
        data_2_str,
        data_3_str,
    };
    drawEachData(data, sizeof(data)/sizeof(data[0]), line_cnt);
}
```

#さいごに
これでコマンドごとの観測データを表示するのに、draw関数で各項目の指定するXY座標をいちいち考えずにデータを描画することができました。

MENU(データ名)側はタイトルとデータ名の配列だけ渡す形に出来たので良いですが、データ値の方は文字列に変換する場合はちょっと手間なままです。（生値そのままなら簡単ですが、ただそこはどうしようもなさそう。）
ただ、`drawDataTest2()`の例のようにStringにするだけでfloatとint勝手に変換してくれるのでそこは結構便利でした。（まあいまどきそのぐらいは普通ですよね。というかPythonとかもっと簡単に書ける気がする。）