M5StackをESP-IDFでいじる

びえびえ's Personal webpage  > IoT >  M5StackをESP-IDFでいじる
0 Comments

動機

M5Stack BASICを少し前から持っていますが、世の中のほとんどのサンプルがArduino IDEベースのものなのが気に入らずあんまり使ってきませんでした。私自身がRTOSに慣れているせいか、すべてをloop関数に閉じ込めて書くやり方が気に入らないというのが一番の理由かもしれません。そこで、FreeRTOSを基礎にしているESP-IDFを使っていじることができればArduinoベースのコードからおさらばできて良いのではないかと思って、あれこれ調べた次第です。

画面のコントロール

M5Stack BASICには320 x 240 TFTカラーディスプレイが搭載されています。センサなどから取得したデータの可視化やログの出力はこのディスプレイを使って表示させたいのですが、ESP-IDF開発環境の標準コンポーネントにはこのディスプレイを操作するドライバは入っていないので、別途M5GFXライブラリをインストールして利用します。

M5GFXライブラリのインストール

ESP-IDFをインストールした後(インストール先をC:\Espressifディレクトリとします)、M5GFXライブラリをcomponentsディレクトリ配下で展開します。

cd  c:\Espressif\frameworks\esp-idf-v4.4.1\components
git clone https://github.com/m5stack/M5GFX.git

一応これだけでM5GFXライブラリが常に取り込まれるようになります。

プログラム上でよくハマるポイント

ESP-IDF開発環境側の制約等により、M5GFXを上手く利用するのにはいくつか留意点があります。私がちょっと躓いたところを列挙しておきます。

エントリポイントはC言語で書かれたライブラリからリンクできる必要がある

ESP-IDF開発環境では、FreeRTOSの起動エントリポイントがapp_main関数になっているのですが、M5GFXを利用する際にはM5GFX自体がC++で書かれているため、各タスクもC++で書く必要があります。ここで何気なしに

void app_main()
{
// 初期化コード。この中で新規タスクを生成する
}

なんて書いてしまうと、ESP-IDFのリンカがapp_mainを見つけられずエラーとなってしまいます(CとC++でデフォルトの名前空間に差異があるためです)。この問題を回避するために

extern "C" void app_main()
{
}

と宣言しておく必要があります。

SPIFFSに置いた画像ファイルを表示させる方法が異なるかも?

M5GFXのM5GFXクラスやM5CanvasクラスにはSPIFFS上に置いた画像ファイルを読み込んで表示するdrawJpgFileメソッドやdrawPngFileメソッドがあり、インターネットの海に置かれている情報もこれらを使って表示させているケースが多いのですが、これを有効化して使うためには(ファイルがSPIFFS上もしくはSDカード上に置かれていることが前提となっているため)SPIFFS.hやSD.hを先にインクルードしておく必要があります。ところが、ESP-IDF環境を素で使ってると当然のことながらというかSPIFFSやSDライブラリは入っていないので(and入れようとするとArduinoベースから逃れるという当初目的を果たせないことにつながるので)、自由に使うことができません・・・。

しょうがないので、SPIFFSに関してはESP-IDF環境でマウントと初期化、ファイル読み込みができるようにしておいて、表示自体はdrawJpgやdrawPngメソッドといった、char配列に画像データが入った状態で表示をさせるメソッドを呼ぶ、という形にして実現させました。

void initialize()
{
   /* SPIFFS設定 (Flash上にファイルシステムを作る)*/
    esp_vfs_spiffs_conf_t fsconf = {
      .base_path = "/spiffs",
      .partition_label = NULL, 
      .max_files = 5,
      .format_if_mount_failed = true
    };
    esp_err_t ret;

    /* SPIFFSの初期化 */
    ret = esp_vfs_spiffs_register(&fsconf);
    if (ret != ESP_OK) {
        if (ret == ESP_FAIL) {
            ESP_LOGE(APP_TAG, "Failed to mount or format filesystem");
        } else if (ret == ESP_ERR_NOT_FOUND) {
            ESP_LOGE(APP_TAG, "Failed to find SPIFFS partition");
        } else {
            ESP_LOGE(APP_TAG, "Failed to initialize SPIFFS (%s)", esp_err_to_name(ret));
        }
        esp_restart();
    }

    // ここまで初期化できれば"/spiffs"ディレクトリ配下のパス名でファイル指定すればfread等ができるようになる
    ESP_LOGI(APP_TAG, "SPIFFS initialized.");
}

というように初期化しておいて、ファイル読み込みをする場面で以下のようにstatやfreadを使ってchar配列に素の画像データを持たせてdrawJpgメソッドで描画する、という流れですね。

    const char *logo_file = "start_logo.jpg";
    struct stat st;
    // ロゴファイルが存在すれば描画する
    if (stat(logo_file, &st) == 0) 
    {
        uint8_t* buf = new uint8_t[st.st_size];
        FILE *f = fopen(logo_file, "r");
        fread(buf, sizeof(char), st.st_size, f);
        logo_canvas.drawJpg(buf, ~0u,
                        0, 0, 
                        display.width(), (display.height() - 3) / 3,
                        0, 0, 0, 0,
                        datum_t::top_left);

        delete[] buf;
    }

キャンバスを上手に使う

M5GFXライブラリでLCDディスプレイに描画させると問題となるのが「ちらつき」でして。これはまぁ画面クリア→描画という一連の流れがそれなり時間のかかる処理なので人間の目にはちらついているように見えてしまう、という問題ですね。これを回避させるためにはスプライト(実際に画面に描画するデータを書いておくメモリ領域)に描画しておいて、それを描画したいタイミングでpushしてLCDに一度に描画してしまうやり方をとるのが普通です。それは良いのですが、サンプルコード等では描画したいものが文字列や画像1種類だけという場面が多くて、複数の要素を一度に貼り付けたい、というときどうやったら良いか一瞬迷ってしまいます。色々試した結果ようやくM5Canvasクラスを複数用いてスプライトに書き込むすべが分かりました。それを使ってLinux等でありがちな、ロゴを表示させつつ下に文字列ログを書いていくものを作りました。

ポイントは、「M5GFXクラスを親にしたM5Canvasクラスのオブジェクトを生成できるのと同様に、M5Canvasクラスを親にしたM5Canvasクラスのオブジェクトも生成できる」ということですね。雰囲気としては以下のようにオブジェクトを持っておいて、それぞれ親に向かってスプライト書き込みをするという形です。

文字列描画のM5Canvasオブジェクトについては、文字列のスクロールができるように設定をしておくと下から上に向かって文字列が流れていくようにできます。この辺はサンプルコードでも同じ処理をしているものがありますので、そのまま流用しています。pushSpriteをするx,y座標は自分の思い描いているレイアウトに沿う位置に表示がされるように変更します。お約束で左上が(0,0)なので、上側にロゴ画像が、その下に文字列描画が来るようにしました。

M5GFX display;
M5Canvas canvas(&display);
M5Canvas logo_canvas(&canvas);
M5Canvas log_canvas(&canvas);

static constexpr char text0[] = "Log1";
static constexpr char text1[] = "Log2";
static constexpr char text2[] = "Log3";
static constexpr char text3[] = "Log4";

static constexpr const char* text[] = {text0, text1, text2, text3};

void initialize()
{
    display.begin();
    if (display.isEPD()) 
    {
        display.setEpdMode(epd_mode_t::epd_fastest);
        display.invertDisplay(true);
        display.clear(TFT_BLACK);
    }
    if (display.width() < display.height())
    {
        display.setRotation(display.getRotation() ^ 1);
    }
    canvas.createSprite(display.width(), display.height());

    // ロゴ描画キャンバスの初期化。こちらは16bpp表示で、画面3分の1を使う設定とした。
    logo_canvas.setColorDepth(16);
    logo_canvas.createSprite(display.width(), display.height() / 3);

    // 文字列描画キャンバスの初期化。setTextSizeとsetTextScrollでテキストの大きさとスクロールについて設定をする。キャンバスの大きさは画面の3分の2とした。
    log_canvas.setColorDepth(1);
    log_canvas.createSprite(display.width(), display.height() * 2 / 3);
    log_canvas.setTextSize((float)canvas.width() / 160);
    log_canvas.setTextScroll(true);
}
// メインタスク本体
void mainLoop(void *args)
{
    uint32_t io_num;
    static int count = 0;
    struct stat st;
    const char *logo_file = "/spiffs/app_logo.jpg";
    bool logo_existed = false;

    setupDevice();

    /* ロゴファイルが存在すれば描画する */
    if (stat(logo_file, &st) == 0) 
    {
        uint8_t* buf = new uint8_t[st.st_size];
        FILE *f = fopen(logo_file, "r");
        fread(buf, sizeof(char), st.st_size, f);
        logo_canvas.drawJpg(buf, ~0u,
                        0, 0, 
                        display.width(), (display.height() - 3) / 3,
                        0, 0, 0, 0,
                        datum_t::top_left);
        logo_existed = true;
        delete[] buf;
    }
    
    while(1) {
        if (xQueueReceive(main_queue, &io_num, 1000 / portTICK_RATE_MS)) 
        {
      // ここで受信するメッセージの件は次節で説明します。
            if (io_num == M5STACK_LEFT_BUTTON) 
            {
                log_canvas.printf("Left Button is pressed!\r\n");
            }
            else if (io_num == M5STACK_MIDDLE_BUTTON)
            {
                log_canvas.printf("Middle Button is pressed!\r\n");
            }
            else if (io_num == M5STACK_RIGHT_BUTTON)
            {
                log_canvas.printf("Right Button is pressed!\r\n");
            }
        }
        else 
        {
            log_canvas.printf("%s\r\n", text[count & 0x03]);
            ++count;
        }
        if  (logo_existed == true) 
        {

      // ロゴ表示は画面左上端から行うので、座標は(0,0)
            logo_canvas.pushSprite(0, 0);
        }

        // ログ表示は画面の3分の1下から行うので、y座標をその分だけ下げてスプライト書き込み
        log_canvas.pushSprite(0, display.height() / 3);

        display.startWrite();

        // 画面描画全体は左上端から行うので、(0,0)地点から書き込み
        canvas.pushSprite(0,0);
        display.endWrite();
    }
}

パネル正面のボタン操作

M5Stack BASICにはパネル正面に3つボタンがついてます。これは単純にESP32のGPIOピンと接続されているので、ESP-IDF開発環境からはGPIO制御として扱うことが可能です。各ボタンとGPIO番号の割り当てが直感的でないところはありますが・・・下表のようになっています。

ボタンGPIO番号
左ボタン39
中央ボタン38
右ボタン37
ボタンとGPIOの割り当て

私個人は、ボタン押下の判定に関して割り込みを使いたがる質なので(この辺もまた組み込みソフトウェアエンジニア歴の長さがそうさせてる気もする。ビジーウェイティングを嫌う)、GPIO割り込みをESP-IDFを使って実現させてボタン操作を実現させました。GPIO割り込みのソースコード例はGithub上に公式のサンプルがありますので、そのまま参考にすれば使えるようになります。ボタンを押したとき、各GPIOの信号がHレベルからLレベルに落ちるので、その立ち下がりエッジを検出して割り込みをかけるように初期化時に仕込みます。抜粋すると以下のコードのようになるでしょうか。

#define M5STACK_LEFT_BUTTON   GPIO_NUM_39
#define M5STACK_MIDDLE_BUTTON GPIO_NUM_38
#define M5STACK_RIGHT_BUTTON  GPIO_NUM_37
#define GPIO_BUTTON_INPUT_PIN_SEL ((1ULL << M5STACK_LEFT_BUTTON) | (1ULL << M5STACK_MIDDLE_BUTTON | (1ULL << M5STACK_RIGHT_BUTTON)))

static const unsigned char wav_data[44716]; // ここにWAVデータが格納されているとする

// 割り込みルーチン。どのボタンを押してもここに入ってくるが、その後
// main_queueキューにGPIO番号をメッセージにして送信している。
// メインタスクがメッセージを受け取ってどのボタン押下だったかを判定する。
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t)arg;
    xQueueSendFromISR(main_queue, &gpio_num, NULL);
}

void initialize()
{
    gpio_config_t io_conf = {};

    io_conf.intr_type = GPIO_INTR_NEGEDGE;  // ボタンを押したときGPIOのレベルがH->Lになるので、それをトリガに割り込みをかける
    io_conf.pin_bit_mask = GPIO_BUTTON_INPUT_PIN_SEL;
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = GPIO_PULLUP_DISABLE;
    gpio_config(&io_conf);

    gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
    gpio_isr_handler_add(M5STACK_LEFT_BUTTON, gpio_isr_handler, (void*) M5STACK_LEFT_BUTTON);
    gpio_isr_handler_add(M5STACK_MIDDLE_BUTTON, gpio_isr_handler, (void*) M5STACK_MIDDLE_BUTTON);
    gpio_isr_handler_add(M5STACK_RIGHT_BUTTON, gpio_isr_handler, (void*) M5STACK_RIGHT_BUTTON);
}

// メインタスク本体
void mainLoop(void *args)
{
    initialize();

    while(1) {
        if (xQueueReceive(main_queue, &io_num, portMAX_DELAY)) // 割り込みルーチンからメッセージ来るまで無限待ち
        {
            if (io_num == M5STACK_LEFT_BUTTON) 
            {
                ESP_LOGI("Left Button is pressed!\r\n");
            }
            else if (io_num == M5STACK_MIDDLE_BUTTON)
            {
                ESP_LOGI("Middle Button is pressed!\r\n");
            }
            else if (io_num == M5STACK_RIGHT_BUTTON)
            {
                ESP_LOGI("Right Button is pressed!\r\n");
            }
        }
    }
}

サウンドまわり

内蔵DACを使う場合

サウンドに関しては、M5Stack BASICではESP32内蔵DACの左側入力(GPIO25)だけオペアンプに繋がっていて、内蔵スピーカを鳴らすことができます(音質はよくないので、気休め程度ですけれども)。ESP32内蔵DACを利用する場合、I2Sを使って設定を行うことができます。大体こんな感じです。

#include <cstring>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s.h"
#include "driver/gpio.h"
#include "esp_system.h"
#include "esp_intr_alloc.h"
#include "esp_log.h"
#include "wav.h"
#include "sdkconfig.h"

#define SPEAKER_LOG "SPK"

#define M5STACK_LEFT_BUTTON   GPIO_NUM_39
#define M5STACK_MIDDLE_BUTTON GPIO_NUM_38
#define M5STACK_RIGHT_BUTTON  GPIO_NUM_37

extern QueueHandle_t sound_queue;
static esp_err_t init_speaker()
{
    esp_err_t err = ESP_OK;
    i2s_config_t i2s_config = {};

    i2s_config.mode = (i2s_mode_t)(I2S_MODE_MASTER |I2S_MODE_DAC_BUILT_IN| I2S_MODE_TX);
    i2s_config.sample_rate = 16000;
    i2s_config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
    i2s_config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
    i2s_config.communication_format =  I2S_COMM_FORMAT_STAND_MSB;
    i2s_config.dma_buf_count = 16;
    i2s_config.dma_buf_len = 128;
    i2s_config.use_apll = false;
    i2s_config.tx_desc_auto_clear = true;
    i2s_config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;            //Interrupt level 1

    err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
    if (err == ESP_OK) {
        err = i2s_set_pin(I2S_NUM_0, nullptr);
        err += i2s_set_dac_mode(I2S_DAC_CHANNEL_LEFT_EN);
        err += i2s_zero_dma_buffer(I2S_NUM_0);
    }
    return err;
}

// サウンド用タスク
void soundTaskLoop(void *args)
{
    if (init_speaker() != ESP_OK) {
        ESP_LOGE(SPEAKER_LOG, "couldn't initialize the speaker!");
        esp_restart();
    }
    ESP_LOGI(SPEAKER_LOG, "Speaker initialized.");

    while(1) {
        uint32_t num;
        size_t bytes;
        if (xQueueReceive(sound_queue, &num, portMAX_DELAY))
        {
            if (num == M5STACK_LEFT_BUTTON) {
                esp_err_t ret;

                ESP_LOGI(SPEAKER_LOG, "Left Button is pressed! Play wav data.");
                ret = i2s_write(I2S_NUM_0, wav_data, sizeof(wav_data), &bytes, portMAX_DELAY);
                if (ret != ESP_OK) {
                    ESP_LOGE(SPEAKER_LOG, "i2s_write error.");
                }
                ESP_LOGI(SPEAKER_LOG, "i2s_write ended. The number of writes is %d", bytes);
                i2s_zero_dma_buffer(I2S_NUM_0);
            }
        }
    }
}

SDカードのマウント

SPIモードでSDカードをマウントする場合、各ピンアサインは以下のようになっています。VSPIを使うことになります。

SPI機能ピン番号
MISO19
MOSI23
CLK18
CS4

ESP-IDFベースでも何とかできました

一通りですが、以上のようにM5Stack BasicをESP-IDF開発環境ベースで動作させることができました。Arduinoベースのコードと比べると色々面倒は多いようにも見えますが、その分というかFreeRTOSベースで開発ができ、ESP32が持っている機能はほぼすべて使えるので、M5Stackに目一杯仕事頑張ってもらおうと思ったら有用?かもしれません・・・。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です