先日、次のような記事を投稿しました。M5Stack 社製の電子ペーパー端末を名札として使うために、SD カードに入れた画像を表示するアプリを作った話です。今回はその続きで、同じものを LILYGO の電子ペーパーへ移植します。
移植したのは LILYGO T5 4.7 inch e-Paper(ESP32-S3)です。2024 年 12 月に Shigezone で購入しました。4.7 インチは M5Paper より一回り大きく、名札としてちょうどよさそうです。
実は購入直後に実装着手したのですが、実装方法が分からない・うまくいかないで挫折していますた。今回 M5Paper 向けの改修にあたり、Claude Code に相談したらできるのでは?と思い、約二年間ほど埃に被っていた中から出してきました。
機能と操作は M5Paper 版に揃えています。ただし、M5Unified / M5GFX が使えないので、描画まわりは作り直しになりました。この記事では、その違いを中心に書きます。
作ったものは GitHub に置いてあります。詳細はそちらをご確認ください。
対応機種
| 型番 | 画面 | 操作 |
|---|---|---|
| H716 | 960 × 540 / モノクロ | タッチ・ボタン |
| H578 / H580 | 960 × 540 / モノクロ | ボタンのみ |
同じ製品の中で、タッチパネルの有無だけが違う版が売られています。中身はどれも ESP32-S3-WROOM-1-N16R8(16MB Flash / 8MB PSRAM)で、パネルも同じ ED047TC1 です。私が所有しているのは H716 です。
タッチの有無は起動時に I2C を叩いて判定するようにしました。GT911 が見つかればタッチあり、見つからなければボタンだけで動きます。PlatformIO の env は 1 つだけで、同じバイナリがどの版でも動きます。M5Paper 版のように機種ごとの分岐を用意する必要はありませんでした。
画面が回らない
M5GFX には setRotation() がありますが、LILYGO のドライバ(LilyGo-EPD47)にはありません。epd_draw_grayscale_image() はフレームバッファをそのまま転送するだけです。
パネルは 960 × 540 の横長固定です。縦向きの写真をそのまま出すと、細い帯になってしまいます。そこで、画面ではなく書き込む座標の方を回すことにしました。
// M5GFX ならこれで済む
M5.Display.setRotation(1);
// EPD47 には無いので、論理座標をパネル座標へ変換して書く
void Screen::putGray(int x, int y, uint8_t gray)
{
int px, py;
toPanel(x, y, px, py); // 回転をここで吸収する
epd_draw_pixel(px, py, gray, buffer);
}
ただし、文字だけはこの方法が使えません。ライブラリの writeln() がフレームバッファへ直接書き込むためです。なので、文字を含む画面(一覧や QR)は回転 0 のまま組み、回すのは画像だけにしました。
画像のデコードも自前でやる
M5GFX の drawJpg() / drawPng() にあたるものもありません。JPEGDEC と PNGdec を使って、デコード・縮小・回転・減色を自分で書きました。
デコーダはブロック単位・行単位でしか画素を渡してこないので、「このブロックが受け持つ描画先はどこか」を毎回引く形になります。このとき、描画先から元画像を引く向きで範囲を決めるのがコツでした。逆向きにすると、拡大したときに描画先へ穴が空きます。
減色は 4 × 4 の組織的ディザ(Bayer)にしました。16 階調しかないので、8 bit をそのまま切り捨てると空や肌にはっきりした縞が出ます。誤差拡散も考えましたが、行を跨いで誤差を持ち回る必要があり、渡ってくる順番が保証されないこの作りでは使えませんでした。位置だけで決まる組織的ディザなら、順番に関係なく同じ結果となります。
遅さの正体は「白へ振る回数」
前回の記事で、カラーの電子ペーパーの描画回数に苦戦したと書きました。今回も似た話にぶつかりましたが、原因は少し違いました。
一覧のカーソルを 1 つ動かすたびに、体感できるほど待たされます。範囲は 1 行ぶんしかないのに、なぜ遅いのか。ライブラリの中を追うと、こうなっていました。
void epd_clear_area(Rect_t area)
{
epd_clear_area_cycles(area, 4, 50); // 4 サイクル × (黒 4 + 白 4) = 32 パス
}
つまり、消す処理が 32 パス。そのあとの階調の転送(epd_draw_grayscale_image)は 15 パスなので、消す方が倍以上重いのです。しかも動く 2 行を別々に更新していたので、カーソル 1 つで合計 94 パス走っていました。
やったことは 2 つだけです。
// 動く 2 行はまとめて 1 回で出す(ボタン操作なら隣り合う行なので範囲はほぼ変わらない)
// カーソル移動のときは白へ振る回数を 2 サイクルに落とす
Screen::flushArea(0, top, 960, rows * kRowHeight, 2);
これで 94 パスが 31 パスになり、はっきり速くなりました。部分更新が遅いときは、範囲を狭めるより先にサイクル数を見直したほうがよさそうです。
使えるボタンが 1 つしかない
基板にはボタンが 3 つ付いていますが、アプリが使えるのは 1 つだけでした。
| ボタン | GPIO | 役割 |
|---|---|---|
| RST | – | リセット |
| BOOT | 0 | ダウンロードモード用 |
| ユーザーボタン | 21 | これだけが使える |
BOOT は押してはいけません。IO0 は電子ペーパーの設定用シフトレジスタ(74HCT4094)の STR ラッチと共用で、動作中に押すと表示が乱れます。
そこで、操作は「短押しで次へ、長押しで決定」の 1 つに詰め込んでいます。
タッチが保持されていた
実機で動かして最初にぶつかった不具合です。一覧から [WiFi] を選ぶと QR コードが出るのですが、出た瞬間に一覧へ戻ってしまいます。
原因は GT911 の仕様でした。タッチの状態を、ホストが読み取るまで保持します。QR を出す全面更新は数秒かかり、その間こちらは I2C を読んでいません。更新が終わって最初に読むと、さっき [WiFi] を選んだそのタップがそのまま返ってきます。次の読み取りで「離された」と判定され、開いた画面がその場で閉じる、というわけです。
問題は、時間で待っても防げないことです。保持された状態は時間では消えないので、待ってから読んでも同じものが出てきます。
// 画面を切り替えたあとは、一度「触れていない」を読むまで新しい操作として扱わない
if (down) {
if (waitingForRelease) { return; }
...
} else if (waitingForRelease) {
waitingForRelease = false; // ここで初めてセンサーが空になったと分かる
return;
}
指を置いたまま画面が切り替わった場合にも同じ理屈で効きます。
PNG が表示できなかった
WiFi 経由でスマホから送った画像が cannot show this image になりました。これは PNGdec の PNG_MAX_BUFFERED_PIXELS が原因でした。
既定は (320*4 + 1)*2 = 2562 バイト、つまり幅 320px 相当までしか通りません。1 行がこれを超える PNG は open() の時点で PNG_TOO_BIG を返します。パネルは 960px あり、ブラウザは画面サイズに合わせて縮小して送ってくるので、送られてくる PNG はほぼ確実に範囲外でした。
; 幅 2048px ぶんに広げる。(最大の幅 * 4 + 1) * 2
build_flags = -DPNG_MAX_BUFFERED_PIXELS=16386
流用できたところ
大変だったところばかり書きましたが、そのまま持ってこられた部分もかなりあります。
M5Paper 版では「Arduino や M5 に依存しない計算・判定は #ifdef ARDUINO の外に置く」という方針で書いていました。EXIF の向きの解析、画像寸法の読み取り、表示する向きの決定、選択位置とページングの計算、WiFi の接続情報の組み立て。このあたりはまるごとコピーで動きました。単体テストも一緒に持ってきています。
スマホから画像を送る仕組み(本体をアクセスポイントにして QR を出し、キャプティブポータルでアップロード画面を開く)も、描画の 1 行を差し替えるだけで済みました。
リリースの仕組みも同じです。タグを打つと GitHub Actions が統合バイナリを作り、ESP Web Tools のインストーラページを GitHub Pages へ公開します。ブートローダの位置が ESP32-S3 なので 0x0 になる点だけ直しました。
まとめ
M5GFX が使えないので、なかなか腰が上がりませんでした。実際、描画まわりはほぼ書き直しました。ただ実際に作業をしたのは Claude Code で、私は眺めているだけでした。
一方で、純粋ロジックを切り離しておいたおかげで、アプリの中身そのものは驚くほどそのまま動きました。実務でも責務分離などを意識してますが、この考えは組み込みでも同じですね。
LILYGO T5 4.7 inch e-Paper をお持ちの方がいれば、ぜひご利用ください。