xiao ESP32 S3と3.5インチ ST7796S LCDで遊ぶ

ESP32でパーティーパロットを動かそう

※ソースコードはこちらからお借りしました: LovyanGFXでPartyParrotを動かそう(M5Stack) #M5stack - Qiita

xiao ESP32S イイね

2~3年に1回くらい電子工作をしたくなるタイミングがあり、またその熱が来たので最近のESP32事情を調べたところ、type-c接続可能な開発ボードの中で、特に Seeed Studio XIAO ESP32S3 — スイッチサイエンス がとても小さく面白そうだったので、これを使ってLCDに画像表示して遊んでみた。

ESP32シリーズは発売から既に10年?くらいは経っていると思うが、初期からWiFi/BT/SPI等が揃っている上にDeep Sleepで電力消費も小さくできるというほぼ全部入りなチップで、ラズパイほどリッチなメモリやCPUが必要なければこれでええやんな代表格である。

むしろ最近のラズパイは高性能化に伴って値段も高くなってしまった上に、それより高性能なミニPCがラズパイ+αの値段で買えるようになったことから、少なくとも個人用途ではあまり使い道が少なくなってしまったような気がする。

LCD3.5インチ ST7796S

LCDに関しては国内で買うよりもaliexpressが色々揃っている上に安いので、暇なときに何となく調べることが多い。 今回は以下のディスプレイが3.5インチの手頃な大きさで使いやすそう+セールで安い+ある程度売れていてレビューも高め、と条件が揃っていたので購入した。

ただ買った後に思ったよりネットにESP32との組み合わせでの稼働実績が落ちてなかったので、到着後に画像を出すまで少し苦労した。

Tenstar Robo-シリアルポートモジュール,PCBアダプター,LCDディスプレイ,2.4 “, 2.8”, 3.5 “, 240x320, 320x480,spi,tft,5v,3.3v PCBアダプター,st7789v,st7796s - AliExpress 502

TFカードアダプタ(マイクロSDスロット)

Amazon.co.jp: Youmile 5個TFマイクロSDカードモジュールメモリシールドモジュールマイクロSDストレージ拡張ボードデュポンワイヤ付きArduino ARM AVR用ピン付きミニマイクロSD TFカード : パソコン・周辺機器

※広告リンクです

マイクロSDが使えれば何でもいいはず。プルアップ抵抗が入っている方が楽。

作ったもの

実態配線図(たぶん合ってるはずですが間違ってたらすみません

resized-nNixyNfyGCVmPkIq.jpg

物自体は単なるデジタルフォトフレーム。(マイクロSDに入れた画像をボタンを押すごとに1枚ずつ順に表示)

とはいえパーティーパロットが面白かったので、ボタンを押して画像出したあとしばらくしたら画面半分はパーティーパロットがまた踊る。

ソースコードは以下。自分で作ったと呼べる部分はあまりなく、ネット上でパネルの設定を調べたりする方に時間がかかった(ググってもChatGPTに聞いてもあまり有効な情報がなく手探り感があった)

GitHub - lovyan03/LovyanGFX: SPI LCD graphics library for ESP32 (ESP-IDF/ArduinoESP32) / ESP8266 (ArduinoESP8266) / SAMD51(Seeed ArduinoSAMD51) という描画ライブラリがあり、こちらもとても便利だったのでもうちょっと使い込んでみたい。

#include <SD.h>
#include <LovyanGFX.hpp>

#define SDCARD_SPI SPI

#define TF_CS 3
#define DC 1
#define MOSI 9
#define MISO 8
#define SCK 7
#define LCD_CS 5
#define LCD_RST 4
#define LCD_BK 2
#define T_SW 6

// ESP32でLovyanGFXを独自設定で利用する場合の設定例
/// 独自の設定を行うクラスを、LGFX_Deviceから派生して作成します。
class LGFX : public lgfx::LGFX_Device {
  lgfx::Panel_ST7796      _panel_instance;

  // パネルを接続するバスの種類にあったインスタンスを用意します。
  lgfx::Bus_SPI _bus_instance;  // SPIバスのインスタンス

  // バックライト制御が可能な場合はインスタンスを用意します。(必要なければ削除)
  lgfx::Light_PWM     _light_instance;

public:

  // コンストラクタを作成し、ここで各種設定を行います。
  // クラス名を変更した場合はコンストラクタも同じ名前を指定してください。
  LGFX(void) {
    {                                     // バス制御の設定を行います。
      auto cfg = _bus_instance.config();  // バス設定用の構造体を取得します。

      // SPIバスの設定
      cfg.spi_host = SPI2_HOST;  // 使用するSPIを選択  ESP32-S2,C3 : SPI2_HOST or SPI3_HOST / ESP32 : VSPI_HOST or HSPI_HOST
      //cfg.spi_host = VSPI_HOST;  // 使用するSPIを選択  ESP32-S2,C3 : SPI2_HOST or SPI3_HOST / ESP32 : VSPI_HOST or HSPI_HOST

      // ※ ESP-IDFバージョンアップに伴い、VSPI_HOST , HSPI_HOSTの記述は非推奨になるため、エラーが出る場合は代わりにSPI2_HOST , SPI3_HOSTを使用してください。
      cfg.spi_mode = 0;                   // SPI通信モードを設定 (0 ~ 3) CSのないディスプレはSPIモード3にしてください。
      cfg.freq_write = 40000000;          // 送信時のSPIクロック (最大80MHz, 80MHzを整数で割った値に丸められます)
      cfg.freq_read = 20000000;           // 受信時のSPIクロック

      cfg.spi_3wire = true;               // 受信をMOSIピンで行う場合はtrueを設定
      cfg.use_lock = true;                // トランザクションロックを使用する場合はtrueを設定
      cfg.dma_channel = SPI_DMA_CH_AUTO;  // 使用するDMAチャンネルを設定 (0=DMA不使用 / 1=1ch / 2=ch / SPI_DMA_CH_AUTO=自動設定)
      //cfg.dma_channel = 0;  // 使用するDMAチャンネルを設定 (0=DMA不使用 / 1=1ch / 2=ch / SPI_DMA_CH_AUTO=自動設定)
      // ※ ESP-IDFバージョンアップに伴い、DMAチャンネルはSPI_DMA_CH_AUTO(自動設定)が推奨になりました。1ch,2chの指定は非推奨になります。
      cfg.pin_sclk = SCK;  // SPIのSCLKピン番号を設定
      cfg.pin_mosi = MOSI;  // SPIのMOSIピン番号を設定
      cfg.pin_miso = MISO;  // SPIのMISOピン番号を設定 (-1 = disable)
      cfg.pin_dc = DC;     // SPIのD/Cピン番号を設定  (-1 = disable)
                          // SDカードと共通のSPIバスを使う場合、MISOは省略せず必ず設定してください。
      _bus_instance.config(cfg);               // 設定値をバスに反映します。
      _panel_instance.setBus(&_bus_instance);  // バスをパネルにセットします。
    }

    {                                       // 表示パネル制御の設定を行います。
      auto cfg = _panel_instance.config();  // 表示パネル設定用の構造体を取得します。

      cfg.pin_cs = LCD_CS;    // CSが接続されているピン番号   (-1 = disable)
      cfg.pin_rst = LCD_RST;   // RSTが接続されているピン番号  (-1 = disable)
      cfg.pin_busy = -1;  // BUSYが接続されているピン番号 (-1 = disable)

      // ※ 以下の設定値はパネル毎に一般的な初期値が設定されていますので、不明な項目はコメントアウトして試してみてください。

      cfg.panel_width = 320;     // 実際に表示可能な幅
      cfg.panel_height = 480;    // 実際に表示可能な高さ
      cfg.offset_x = 0;          // パネルのX方向オフセット量
      cfg.offset_y = 0;          // パネルのY方向オフセット量
      cfg.offset_rotation = 0;   // 回転方向の値のオフセット 0~7 (4~7は上下反転)
      cfg.dummy_read_pixel = 8;  // ピクセル読出し前のダミーリードのビット数
      cfg.dummy_read_bits = 1;   // ピクセル以外のデータ読出し前のダミーリードのビット数
      cfg.readable = true;       // データ読出しが可能な場合 trueに設定
      cfg.invert = false;         // パネルの明暗が反転してしまう場合 trueに設定
      cfg.rgb_order = false;     // パネルの赤と青が入れ替わってしまう場合 trueに設定
      cfg.dlen_16bit = false;    // 16bitパラレルやSPIでデータ長を16bit単位で送信するパネルの場合 trueに設定
      cfg.bus_shared = true;    // SDカードとバスを共有している場合 trueに設定(drawJpgFile等でバス制御を行います)

      _panel_instance.config(cfg);
    }

  
    { // バックライト制御の設定を行います。(必要なければ削除)
      auto cfg = _light_instance.config();    // バックライト設定用の構造体を取得します。

      cfg.pin_bl = LCD_BK;              // バックライトが接続されているピン番号
      cfg.invert = false;           // バックライトの輝度を反転させる場合 true
      cfg.freq   = 44100;           // バックライトのPWM周波数
      cfg.pwm_channel = 0;          // 使用するPWMのチャンネル番号

      _light_instance.config(cfg);
      _panel_instance.setLight(&_light_instance);  // バックライトをパネルにセットします。
    }

    setPanel(&_panel_instance);  // 使用するパネルをセットします。
  }
};

// 準備したクラスのインスタンスを作成します。
LGFX lcd;
static LGFX_Sprite sprite(&lcd);

extern const uint16_t imgWidth;
extern const uint16_t imgHeight;
extern const unsigned short img[];

// https://qiita.com/coppercele/items/8d5a2a43a504de3035dc から拝借してます
void drawSpriteParrot(int pdx, int pdy, uint8_t r, uint8_t g, uint8_t b) {
	sprite.clear();
	sprite.setColor(lcd.color332(255, 255, 255));
	sprite.drawBezier(225 + pdx, 70 + pdy, 200 + pdx, 80 + pdy, 225 + pdx, 150 + pdy);
	sprite.drawBezier(225 + pdx, 70 + pdy, 250 + pdx, 80 + pdy, 225 + pdx, 150 + pdy);
	sprite.drawBezier(40, 240, 160 + pdx, 210 + pdy, 110 + pdx, 40 + pdy, 220 + pdx, 40 + pdy);
	sprite.drawBezier(220 + pdx, 40 + pdy, 280 + pdx, 40 + pdy, 300 + pdx, 120 + pdy);
	sprite.drawBezier(300 + pdx, 120 + pdy, 310 + pdx, 180 + pdy, 270 + pdx, 200 + pdy, 290, 240);
	sprite.floodFill(120, 220, lcd.color332(r, g, b)); // オウムの内側を塗りつぶす
	sprite.setColor(lcd.color332(0, 0, 0));
        // 塗りつぶした後目を黒で描く
	sprite.fillEllipse(260 + pdx, 80 + pdy, 10, 15);
	sprite.fillEllipse(190 + pdx, 80 + pdy, 10, 15);
	sprite.pushSprite(0, 0);
}

// 最大ファイル数とファイル名の長さを指定
const int MAX_FILES = 20;      // 最大ファイル数
const int MAX_NAME_LEN = 32;   // ファイル名の最大長さ

// ファイル名を格納する配列
char fileNames[MAX_FILES][MAX_NAME_LEN];
// JPEGファイルをリスト化する関数
int listJpegFiles(const char* path) {
  File root = SD.open(path);
  if (!root || !root.isDirectory()) {
    Serial.println("Failed to open directory!");
    return 0;
  }

  int count = 0;
  while (true) {
    File entry = root.openNextFile();
    if (!entry) {
      break; // ディレクトリ内の全てのファイルを探索
    }

    // ファイル名を取得
    String fileName = entry.name();
    entry.close();

    // 拡張子が.jpgまたは.jpegか確認
    if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
      if (count < MAX_FILES) {
        String tmpfname = String(path) + fileName;
        tmpfname.toCharArray(fileNames[count], MAX_NAME_LEN);
        count++;
      } else {
        Serial.println("File limit reached!");
        break;
      }
    }
  }
  root.close();
  return count; // 発見したファイル数を返す
}

int currentImageNum = 0;
int fileCount = 0;

void setup(void) {
  Serial.begin(115200);

  lcd.init();

  lcd.setRotation(2);
  lcd.fillScreen(TFT_BLACK);

  lcd.setBrightness(200);
  sprite.setPsram(true);
	sprite.setColorDepth(16);
	sprite.createSprite(320, 240);
  lcd.clear();
  
  // TFカード初期化
  if(!SD.begin(TF_CS)) {
    Serial.println("Failed to init TF card");
    while(true);
  }
  Serial.println("Success to init TF card");
  fileCount = listJpegFiles("/");
  Serial.printf("Found %d JPEG files\n", fileCount);

  // ファイル名を出力
  for (int i = 0; i < fileCount; i++) {
    Serial.println(fileNames[i]);
  }
  lcd.drawJpgFile(SD, fileNames[0], 0,0);

  // typec横のLED
  pinMode(LED_BUILTIN, OUTPUT);

  // タクトスイッチ
  pinMode(T_SW, INPUT);

  delay(1000);
}

float rad = 0;
float delta = 0.05;

uint8_t r, g, b;

int dx = 0, dy = 0;
int sw_count=0;

void loop(void) {

	dx = cos(rad * 3.14) * 60 - 60;
	dy = sin(rad * 3.14) * -20;
	delay(1);
	rad += delta;
	if (2 < rad) {
		rad = 0;
	}
  r = abs(sin(rad * 3.14)) * 255;
	g = abs(sin((rad + 0.6) * 3.14)) * 255;
	b = abs(sin((rad + 1.2) * 3.14)) * 255;
  drawSpriteParrot(dx, dy, r,g,b);

  if(digitalRead(T_SW)==HIGH){
    currentImageNum++;
    if(currentImageNum >= fileCount) {
      currentImageNum = 0;
    }
    lcd.drawJpgFile(SD, fileNames[currentImageNum]);

    lcd.setCursor(0,451);
    lcd.setTextColor(TFT_WHITE, TFT_BLACK);
    lcd.setTextSize(2);
    lcd.printf("ImageNum:%d, %d times party...\n", currentImageNum, sw_count);
    sw_count++;
    delay(500);
  }
}

感想

もうちょい変えたいところ

  • 32GBのSDHCカードを読み込めない?ので対応方法調べたい
  • 画像をSD取り外してPCで書き込むの面倒なのでwebサーバで画像受け取って書き込むようにしたい(が、処理速度的に厳しいか…?)
  • pngにも対応するようにする
  • タクトスイッチをもう1つつけて、ただの画像表示モードとパーティーパロットモードを切り替えたい。あえて物理スイッチでね!
  • Spriteの使い方はまだわかっていないので理解しておきたい(パーティーパロットはコピペしただけ)
  • ケースに入れて1つのパッケージにしたい