1. 導入・概要
この記事はM5StackTab5をArduino環境で使い倒す(願望)ための情報を共有するものです。
私と同じようにこのパワフルなマシンの様々な機能&大きな画面を使いたいと思っている方向けに情報を共有していければと思っています。
この記事は「Tab5を使い倒す(1_基礎編)」からの続きとなる3_GUI編です。それぞれプロジェクトは独立しているので、どこから始めていただいてもかまいません。
| No | 記事 | コード |
|---|---|---|
| 1 | 基礎編 | Github |
| 2 | ネットワーク編 | Github |
| 3 | GUI編1 / GUI編2-解説 | Github |
EEZ_Template プロジェクト概要
まずはM5Stack Tab5向けEEZ Studio + LVGLアプリケーション開発用テンプレートの詳細な概要を説明します。このテンプレートを使うことで、M5Stack Tab5でEEZ StudioとLVGLを使用したGUIアプリケーションを迅速に開発することを目的としています。
全体アーキテクチャ
このテンプレートのフォルダ構成及びアーキテクチャを下記に示します。ポイントはArduinoIDE用のテンプレートファイル(EEZ_Template.ino)、GUIを出力するためのEEZ Studio用のプロジェクトファイル(LV8wF_Template.eez-project)、LVGL用の設定ファイル(lv_conf.h)です。
フォルダ構成
EEZ StudioプロジェクトファイルとArduinoIDE用ファイルを同じフォルダに置くことで、EEZ Studioから出力されるGUIファイルがsrc/ui/フォルダに出力されます。
これによりEEZ StudioでGUIを更新・出力するだけで、ArduinoIDEプロジェクトからsrc/ui/内の最新ファイルを参照させることができます。
Tab5_GUI/
├── EEZ_Template/ # EEZ テンプレート(開発用ベース)
│ ├── EEZ_Template.ino # メインプログラム
│ ├── LV8wF_Template.eez-project # EEZ Studio プロジェクト
│ ├── lv_conf.h # LVGL 設定ファイル
│ ├── LICENSE # ライセンスファイル
│ ├── README.md # プロジェクト概要
│ └── src/ui/ # EEZ Studio 生成ファイル
アーキテクチャ
ファイル詳細
EEZ_Template.ino (メインプログラム)
役割: プロジェクトのエントリーポイント
主要な機能:
- Arduino標準関数(
setup(),loop()) - M5Unified初期化
- LVGL初期化とコールバック設定
- タッチ入力処理
- メモリ管理(SPIRAM)
- アプリケーションロジック
編集方法:
- EEZ Studioで開く
- UI/Flowを編集
- ビルド&エクスポート
生成されるファイル:
| カテゴリ | ファイル名(自動生成) | 手動追加 | 機能 |
|---|---|---|---|
| 主要 | ui.c / ui.h | UI全体の宣言・定義、画面ID、ウィジェットIDなどの定義/実装 | |
| 主要 | screens.c / screens.h | 各画面の宣言、画面構造体の定義、各画面のレイアウト・ウィジェット配置の実装 | |
| 主要 | styles.c / styles.h | スタイル定義の宣言、カラー、フォント、サイズなどスタイルの実装 | |
| 主要 | actions.h | actions.c | アクション関数の宣言、ボタンクリックなどのアクション処理の実装 |
| 主要 | var.h | var.c | グローバル変数の宣言、変数の定義と初期化 |
| Flow | flows.c / flow.h | Flow宣言・定義とフローチャートロジック | |
| Flow | eez-flow.cpp / eez-flow.h | - | Flowロジックの宣言・実行エンジン |
| リソース | images.c / images.h | イメージデータの宣言・データ | |
| リソース | fonts.c / fonts.h | カスタムフォントの宣言・フォントデータ |
生成されるファイル間の連携イメージ
lv_conf.h (LVGL設定)
役割: LVGLライブラリの動作設定
主要設定:
#define LV_COLOR_DEPTH 16 // 16ビットカラー
#define LV_USE_PERF_MONITOR 0 // パフォーマンスモニタ無効
#define LV_FONT_MONTSERRAT_14 1 // フォント有効化
#define LV_USE_LOG 0 // ログ無効
カスタマイズ:
- フォントの有効化/無効化
- ウィジェットの有効化
- デバッグ機能の切り替え
- メモリ設定
データフロー
起動シーケンス
Tab5の内部動作は下記となります
[電源投入]
│
▼
[setup() 実行開始]
│
├─▶ M5.begin(cfg) // M5Unified初期化
│
├─▶ Serial.begin(115200) // シリアル通信開始
│
├─▶ システム情報表示
│ - LVGL バージョン
│ - 空きヒープ/PSRAM
│
├─▶ initLvglDisplay()
│ ├─▶ lv_init() // LVGL初期化
│ ├─▶ allocateDisplayBuffer() // SPIRAMバッファ確保(1.8MB)
│ └─▶ lv_disp_drv_register() // ディスプレイドライバ登録
│
├─▶ initLvglTouch()
│ └─▶ lv_indev_drv_register() // タッチドライバ登録
│
├─▶ ui_init()
│ ├─▶ eez_flow_init() // EEZ Flow初期化
│ ├─▶ create_screens() // 画面作成
│ └─▶ loadScreen(SCREEN_ID_MAIN) // メイン画面表示
│
├─▶ M5.Display.setBrightness(255) // 明るさ設定
│
└─▶ [setup完了]
│
▼
[loop() 実行開始]
メインループの流れ
ループ部分の処理は下記になります。
loop() (毎フレーム実行)
│
├─▶ M5.update()
│ └─ ボタン、タッチ状態更新
│
├─▶ lv_timer_handler()
│ ├─ UI描画更新
│ ├─ アニメーション処理
│ └─ イベント処理
│
├─▶ ui_tick()
│ ├─ eez_flow_tick() // Flow実行
│ └─ tick_screen() // 現在の画面更新
│
├─▶ updateApplication()
│ └─ アプリケーション固有処理
│ (カスタマイズポイント)
│
└─▶ delay(1) // 1ms待機
│
└─▶ (loop()へ戻る)
画面更新の流れ
画面更新の流れは下記です。LVGL仕様の仕様通りです。
[LVGLが画面更新を要求]
│
▼
lv_disp_flush() コールバック
│
├─ 更新領域 (area) を取得
├─ カラーバッファ (color_p) を取得
│
└─▶ M5.Display.pushImageDMA()
│
├─ DMAで画面転送(非ブロッキング)
├─ 転送中もCPUは他の処理可能
│
└─▶ lv_disp_flush_ready() // LVGL に完了通知
タッチ入力の流れ
画面操作・タッチ入力の流れは下記です。こちらもLVGL仕様の仕様通りです。
[ユーザーがタッチ]
│
▼
M5.update()
│
└─ タッチセンサーから座標読取
│
▼
lv_indev_read() コールバック
│
├─ touch_detail = M5.Touch.getDetail()
│
└─ LVGLにタッチ情報を返す
├─ data->state = PRESSED or RELEASED
├─ data->point.x = X座標
└─ data->point.y = Y座標
│
▼
[LVGLがイベント処理]
│
└─▶ ボタン、スライダーなどが反応
4. メモリアロケータ
使用メモリ:
| 領域 | サイズ | 用途 |
|---|---|---|
| 内部RAM | 512KB | プログラム実行、スタック、ヒープ |
| SPIRAM (PSRAM) | 8MB | 画面バッファ、大きなデータ |
| Flash | 16MB | プログラムコード、定数データ |
画面バッファの確保:
g_color_buf = (lv_color_t *)heap_caps_malloc(
sizeof(lv_color_t) * SCREEN_BUFFER_SIZE, // 1280 x 720 x 2 = 1.8MB
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT // SPIRAMに確保
);
メモリ使用量の確認:
ESP.getFreeHeap() // 内部RAM空き容量
ESP.getFreePsram() // SPIRAM空き容量
メモリマップ
メモリ最適化のPoint
-
大きなバッファはSPIRAMに
uint8_t *buf = (uint8_t *)heap_caps_malloc(size, MALLOC_CAP_SPIRAM); -
不要な機能はlv_conf.hで無効化
#define LV_USE_PERF_MONITOR 0 #define LV_USE_LOG 0 -
フォントは必要なものだけ有効化
#define LV_FONT_MONTSERRAT_14 1 #define LV_FONT_MONTSERRAT_48 0 // 不要なら無効化
カスタマイズ時のポイント
この章ではEEZ Templateをベースに独自のアプリケーションを開発する方法を説明します。
基本的なカスタマイズ
プロジェクト名の変更
-
ファイル名を変更
-
EEZ_Template.ino→YourApp.ino
-
-
プロジェクトフォルダ名を変更
-
EEZ_Template/→YourApp/
-
-
ヘッダーコメントを更新
/******************************************************************************* * あなたのアプリケーション名 * * アプリケーションの説明 ******************************************************************************/
アプリケーション定数の変更
EEZ_Template.inoの定数定義セクションで設定を変更:
// アプリケーション設定
#define APP_UPDATE_INTERVAL_MS 100 // 更新間隔(ミリ秒)
#define DEFAULT_BRIGHTNESS 255 // 初期画面輝度(0-255)
UIの変更
EEZ Studioでの基本操作
1. プロジェクトを開く
File → Open Project → LVGLv8withFlow.eez-project
2. 新しい画面を追加
- Pagesタブを選択
- +ボタンをクリック
- 画面名を入力(例: "Settings")
- レイアウトを編集
3. ウィジェットを追加
左側のウィジェットパネルから選択:
配置方法:
- ウィジェットをドラッグ&ドロップ
- プロパティパネルで設定
- Position (X, Y)
- Size (Width, Height)
- Appearance (色、フォント)
- Behavior (可視性、有効/無効)
4. スタイルのカスタマイズ
Stylesタブ:
- +ボタンで新しいスタイルを作成
- プロパティを設定:
- Background color
- Border color/width
- Text color/font
- Padding/Margin
- ウィジェットに適用
5. Flowでの動作定義
Flowタブ:
-
アクションを追加(ドラッグ&ドロップ)
- Set Text: テキスト設定
- Set Value: 値設定
- Show/Hide: 表示/非表示
- Navigate: 画面遷移
- Delay: 待機
- Loop: ループ処理
-
トリガーを設定
- Button Pressed: ボタン押下時
- Timer: タイマー
- Variable Changed: 変数変更時
-
アクションを接続(線で繋ぐ)
6. エクスポート
生成されたファイルをプロジェクトフォルダにコピー:
ui.c, ui.h, screens.c, screens.h, eez-flow.cpp, eez-flow.h,
actions.h, styles.c, styles.h, images.c, images.h, fonts.h,
structs.h, vars.h
機能の追加
センサー読み取り機能の追加
例: 温湿度センサー(SHT40)
// 1. ライブラリをインクルード
#include <Wire.h>
#include <SHT4x.h>
// 2. グローバル変数
static SHT4x sht40;
static float g_temperature = 0.0;
static float g_humidity = 0.0;
// 3. setup()で初期化
void setup() {
// ... 既存の初期化 ...
// センサー初期化
Wire.begin();
if (!sht40.begin()) {
Serial.println("SHT40 sensor not found!");
} else {
Serial.println("SHT40 sensor initialized");
}
}
// 4. 読み取り関数を追加
void updateSensorData() {
static unsigned long lastReadTime = 0;
const unsigned long READ_INTERVAL = 2000; // 2秒ごと
if (millis() - lastReadTime < READ_INTERVAL) {
return;
}
lastReadTime = millis();
// センサー読み取り
if (sht40.measure()) {
g_temperature = sht40.getTemperature();
g_humidity = sht40.getHumidity();
// UIに表示(EEZ Studioで作成したラベルに表示)
lv_label_set_text_fmt(objects.label_temp, "%.1f°C", g_temperature);
lv_label_set_text_fmt(objects.label_humi, "%.1f%%", g_humidity);
Serial.printf("Temperature: %.1f°C, Humidity: %.1f%%\n",
g_temperature, g_humidity);
}
}
// 5. loop()で呼び出し
void loop() {
M5.update();
lv_timer_handler();
ui_tick();
updateSensorData(); // ← 追加
delay(LVGL_TIMER_DELAY_MS);
}
タイマー機能の追加
例: 1秒ごとに時刻を更新
// グローバル変数
static unsigned long g_startTime = 0;
// setup()で初期化
void setup() {
// ... 既存の初期化 ...
g_startTime = millis();
}
// 時刻更新関数
void updateClock() {
static unsigned long lastUpdateTime = 0;
if (millis() - lastUpdateTime < 1000) { // 1秒ごと
return;
}
lastUpdateTime = millis();
// 経過時間を計算
unsigned long elapsedSeconds = (millis() - g_startTime) / 1000;
int hours = elapsedSeconds / 3600;
int minutes = (elapsedSeconds % 3600) / 60;
int seconds = elapsedSeconds % 60;
// UIに表示
lv_label_set_text_fmt(objects.label_time, "%02d:%02d:%02d",
hours, minutes, seconds);
}
// loop()で呼び出し
void loop() {
// ...
updateClock();
// ...
}
WiFi機能の実装
HTTPリクエストの送信
#ifdef ENABLE_WIFI
#include <HTTPClient.h>
void fetchWeatherData() {
if (!g_wifiConnected) {
Serial.println("WiFi not connected");
return;
}
HTTPClient http;
const char* url = "http://api.example.com/weather";
Serial.printf("Fetching: %s\n", url);
http.begin(url);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
Serial.println("Data received:");
Serial.println(payload);
// データを解析してUIに表示
// (JSONパース処理を追加)
} else {
Serial.printf("HTTP Error: %d\n", httpCode);
}
http.end();
}
#endif
MQTTの実装
#ifdef ENABLE_WIFI
#include <PubSubClient.h>
// グローバル変数
static WiFiClient g_wifiClient;
static PubSubClient g_mqttClient(g_wifiClient);
// MQTT設定
#define MQTT_SERVER "broker.hivemq.com"
#define MQTT_PORT 1883
#define MQTT_TOPIC "m5stack/tab5/data"
// MQTTコールバック
void mqttCallback(char* topic, byte* payload, unsigned int length) {
Serial.printf("📨 MQTT Message [%s]: ", topic);
for (int i = 0; i < length; i++) {
Serial.print((char)payload[i]);
}
Serial.println();
// メッセージをUIに表示
char message[256];
strncpy(message, (char*)payload, min(length, 255));
message[min(length, 255)] = '\0';
lv_label_set_text(objects.label_mqtt, message);
}
// MQTT初期化
void initMQTT() {
g_mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
g_mqttClient.setCallback(mqttCallback);
}
// MQTT接続
bool connectMQTT() {
String clientId = "M5Tab5-" + String(random(0xffff), HEX);
if (g_mqttClient.connect(clientId.c_str())) {
Serial.println("MQTT connected");
g_mqttClient.subscribe(MQTT_TOPIC);
return true;
} else {
Serial.printf("MQTT connection failed, rc=%d\n",
g_mqttClient.state());
return false;
}
}
// MQTT状態チェック
void checkMQTT() {
if (!g_wifiConnected) {
return;
}
if (!g_mqttClient.connected()) {
connectMQTT();
}
g_mqttClient.loop();
}
// setup()で初期化
void setup() {
// ... WiFi初期化後 ...
initMQTT();
connectMQTT();
}
// loop()で呼び出し
void loop() {
// ...
checkMQTT();
// ...
}
#endif
データ通信の実装
JSON解析
#include <ArduinoJson.h>
void parseWeatherJSON(const String& jsonString) {
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, jsonString);
if (error) {
Serial.printf("JSON parse error: %s\n", error.c_str());
return;
}
// データを取得
const char* city = doc["city"];
float temperature = doc["temperature"];
const char* weather = doc["weather"];
// UIに表示
lv_label_set_text(objects.label_city, city);
lv_label_set_text_fmt(objects.label_temp, "%.1f°C", temperature);
lv_label_set_text(objects.label_weather, weather);
Serial.printf("City: %s, Temp: %.1f°C, Weather: %s\n",
city, temperature, weather);
}
SDカード保存
#include <SD.h>
#include <SPI.h>
// SDカード初期化
bool initSD() {
if (!SD.begin()) {
Serial.println("SD card initialization failed");
return false;
}
Serial.println("SD card initialized");
return true;
}
// データ保存
void saveDataToSD(float temperature, float humidity) {
File file = SD.open("/data.csv", FILE_APPEND);
if (!file) {
Serial.println("Failed to open file for writing");
return;
}
// タイムスタンプ + データ
file.printf("%lu,%.2f,%.2f\n", millis(), temperature, humidity);
file.close();
Serial.println("Data saved to SD card");
}
よくあるカスタマイズパターン
パターン1: リスト表示
// リストアイテムの追加
void addListItem(const char* text) {
lv_obj_t* list_btn = lv_list_add_btn(objects.list1, NULL, text);
// ボタンにイベントハンドラを追加
lv_obj_add_event_cb(list_btn, list_item_click_cb,
LV_EVENT_CLICKED, NULL);
}
// リストアイテムクリック時のコールバック
static void list_item_click_cb(lv_event_t* e) {
lv_obj_t* btn = lv_event_get_target(e);
const char* text = lv_list_get_btn_text(objects.list1, btn);
Serial.printf("List item clicked: %s\n", text);
}
パターン2: グラフ表示
// チャート初期化
void initChart() {
// データシリーズを追加
lv_chart_series_t* series = lv_chart_add_series(
objects.chart1,
lv_palette_main(LV_PALETTE_BLUE),
LV_CHART_AXIS_PRIMARY_Y
);
// 範囲設定
lv_chart_set_range(objects.chart1, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
}
// データポイントを追加
void addChartData(int value) {
lv_chart_set_next_value(objects.chart1, series, value);
lv_chart_refresh(objects.chart1);
}
パターン3: 画面遷移
// 画面遷移(EEZ Studioで複数画面を定義した場合)
void navigateToSettings() {
// EEZ Flowで定義した画面IDを使用
lv_scr_load(objects.screen_settings);
}
// ボタンコールバックで画面遷移
static void settings_btn_click_cb(lv_event_t* e) {
navigateToSettings();
}
パターン4: 通知表示
// 通知メッセージボックスの表示
void showNotification(const char* title, const char* message) {
static const char * btns[] = {"OK", ""};
lv_obj_t* mbox = lv_msgbox_create(NULL, title, message, btns, false);
lv_obj_center(mbox);
}
// 使用例
showNotification("Success", "Data saved successfully!");