2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

前回の記事で、ESP32-C3 + CANトランシーバ で OBD2 から情報取得する側をゲートウェイ化しました。

これにより、すべてリモート側で制御するようになったため、OBD2コネクタに組み込んだ 機器(ESP32-C3 + CANトランシーバ)は クルマに付けっ放なしでよくなりました。

今回はリモート側の制作です。
GUIにしたかったので、M5Stack Tab5(以下、M5Tab5) を使いました。

5インチ 1280×720 タッチスクリーンSDカードスロットを備えているため、GUI操作をタッチで行え、ゲートウェイから取得した情報をSDカードにロギングできて便利です。

開発環境はArduino-IDE
記事の最後にプログラムコード(スケッチ)を載せましたが、すべてAIに作ってもらいました。私は要件を伝え、実機テスト・実車テストした結果をフィードバックする役に徹し(コードを読んで指摘はしましたが)、バグの分析・対策などは、すべてAIに任せました。
徐々に機能追加するアジャイル的な開発のため(および、M5Tab5は、ESP32-P4 + ESP32-C6 という構成で があり、試行錯誤したところもあり)、20回を超えるやり取りの末にできたコードです。

これまでも部分的にAIに助けてもらうことはありましたが、今回初めて 0からお願いしました。
ノウハウ的なところは、(AIが)コードの先頭にすべてコメントとして記録してくれているので、興味がある方はご一読ください。

M5Tab5 開発環境

今回の環境は次のとおり

  • Arduino IDE v2.3.10
  • Arduino Core for ESP32 v3.3.11
  • M5Unified v0.2.19
  • M5GFX v0.2.26
  • lvgl v9.5.0
  • M5Stack社製ボード(v3.2.6)指定は使用せず、Espressif製ボードの M5Tab5 を選択
    (「ツール→ボード→esp32→M5Tab5」を選択。「ツール→ボード→M5Stack→M5Tab5」では、BLE関係でコンパイルエラー)

M5Tab5 GUI

画面は2つ。『PID選択画面』と『モニター画面』

1. PID選択画面

shot_0015.jpeg

  • モニター(ロギング)したいPIDを選択する(チェックボックスをチェックする)。1つ以上のチェックが必要
  • SDカードにロギングする場合は、『Save log to SD』をチェックする。SDカードを挿していない場合はチェックできないため、SDカードを挿して『Mount SD』を押してからチェックする(M5Tab5の仕様によりSDカードの挿入を自動認識できない)
  • ロギング中でも『Eject SD』を押すと、クローズ処理を行い安全にSDカードを抜くことができる。再びSDカードを挿した場合は『Mount SD』を押す
  • 『Start Monitoring』ボタンを押すと、『モニター画面』に遷移。モニター、ロギングを開始する
  • 画面下段中央『Screenshot』ボタンを押すと、画面のスクショをSDカードに出力する。(上の画像はスクショ保存中のため、ボタン名が『Saving...』となっている)
  • 『Display off』を押すと、画面をオフにする。画面をタップすると再び表示する

2. モニター画面

shot_0017.jpeg

  • 1秒ごとに選択したPID情報をゲートウェイから取得し、タイル状に表示する。ゲートウェイ未接続の場合は"--"を、タイムアウトの場合は"N/A"を表示
  • (ロギング)『Save log to SD』が選択されている場合は、画面に『LOGGING』を表示してSDカードにも出力する(フォーマットは後述)
  • 画面上段中央『Screenshot』ボタンを押すと、画面のスクショをSDカードに出力する。(上の画像はスクショ保存中のため、ボタン名が『Saving...』となっている)
  • 『Display off』を押すと、画面をオフにするがロギングは継続。画面をタップすると再び表示する
  • 『Back』を押すと、ロギングを停止(ログファイルをクローズ)して『PID選択画面』に戻る

3. ロギング

『Save log to SD』が選択されている場合は、次の仕様でSDカードに出力します。

  • ファイル名:log_0001.csv(自動的に連番を振る)
  • ファイル形式:カンマ区切りCSVファイル(1行目:ヘッダー行)
  • ヘッダー行:"elapsed_sec"に続き、選択されたPIDの"項目名[単位]"がカンマ区切りで並ぶ
  • 2行以降:経過秒に続き、選択されたPIDの"情報"がカンマ区切りで並ぶ

可読性は悪いですが、Excel等での編集のしやすさを優先しました。

ファイル例(log_0005.csv)
elapsed_sec,RPM[rpm],Speed[km/h],Load[%],Throttle[%],PedalD[%],PedalE[%],Boost[kPa],RailP[MPa],EGT_S1[],EGT_S2[],EGT_S3[],MAF[g/s],WaterTemp[],AmbTemp[],Volt[V],NOx[ppm],SCR_Active[0or1],DPF_Regen[0or1],DPF_Active[0or1],Lambda1[-]
17.623,
18.644,,
19.666,,,
20.691,782,0,14.9,10.6,14.1,14.1,97.0,31.84,114,149,64,12.33,86,30,12.08,,0,0,0,1.0006
21.714,782,0,17.6,10.6,14.1,14.1,99.4,31.02,114,149,65,12.30,86,30,12.08,,0,0,0,1.0006
22.736,781,0,17.6,10.6,14.1,14.1,102.8,30.18,113,150,66,11.08,86,30,12.10,,0,0,0,1.0006
23.763,780,0,21.6,10.6,14.1,14.1,102.7,30.18,112,150,67,7.91,86,30,12.24,,0,0,0,1.0006
24.781,778,0,23.9,10.6,14.1,14.1,101.0,30.60,111,151,68,12.38,86,30,12.58,,0,0,0,1.0006
25.802,784,0,25.5,10.6,14.1,14.1,103.3,30.60,111,152,69,12.52,86,30,12.84,,0,0,0,1.0006
26.824,780,0,38.4,10.6,14.1,14.1,104.5,30.60,110,152,70,12.61,86,30,13.18,,0,0,0,1.0006
27.845,748,0,47.1,10.6,14.1,14.1,105.3,45.12,110,153,71,12.66,86,30,14.44,,0,0,0,1.0006
28.871,780,3,42.7,10.6,14.1,14.1,106.6,41.40,111,154,72,11.05,86,30,15.06,,0,0,0,1.0006
29.889,778,3,49.4,10.6,14.1,14.1,104.6,36.00,111,154,73,8.66,86,30,14.98,,0,0,0,1.0006
30.911,778,1,51.8,10.6,14.1,14.1,103.1,36.82,111,155,73,8.91,86,30,14.70,,0,0,0,1.0006
31.931,795,1,45.9,10.6,14.1,14.1,102.7,33.92,112,156,74,8.80,86,30,14.52,,0,0,0,1.0006
32.952,770,3,58.8,10.6,22.0,22.0,102.6,43.06,112,156,75,9.47,86,30,14.46,,0,0,0,1.0006
33.974,780,4,58.4,10.6,22.0,22.0,103.8,45.96,113,157,75,10.24,86,30,14.44,,0,0,0,1.0006
34.996,792,5,55.3,10.6,23.9,23.5,104.0,45.56,114,157,76,9.66,86,30,14.44,,0,0,0,1.0006
36.016,904,6,71.4,10.6,29.8,30.2,105.3,59.26,115,158,76,14.41,86,30,14.48,,0,0,0,1.0006
37.039,1100,9,67.5,10.6,32.2,32.5,110.9,68.82,118,159,77,18.88,86,30,14.46,,0,0,0,1.0006
38.049,1656,14,85.5,10.6,33.7,34.1,128.3,109.52,121,160,79,33.47,86,30,14.46,,0,0,0,1.0006
39.059,1726,19,45.9,10.6,22.0,20.0,145.1,63.84,127,160,81,18.52,86,30,14.44,,0,0,0,1.0006
40.081,1496,19,0.0,10.6,14.1,14.1,111.9,36.40,126,161,82,13.05,86,30,14.88,,0,0,0,1.0006
41.102,1431,19,0.0,10.6,14.1,14.1,104.4,35.16,124,162,83,12.63,86,30,14.90,,0,0,0,1.0006
42.124,1436,19,0.0,10.6,14.1,14.1,102.4,36.00,122,162,84,13.05,86,30,14.84,,0,0,0,6.2651
43.147,1493,19,0.0,10.6,14.1,14.1,102.0,36.00,121,163,84,13.41,86,30,14.82,,0,0,0,7.9953
44.169,1534,20,0.0,10.6,14.1,14.1,102.0,37.66,120,163,84,13.91,86,30,14.78,,0,0,0,7.9953
45.182,1302,17,0.0,10.6,14.1,14.1,102.8,32.66,118,163,84,11.19,86,30,14.82,,0,0,0,7.9953
  :     :     :
187.649,0,0,0.0,85.1,14.1,14.1,46.0,25.62,155,168,109,0.00,89,30,12.88,,0,0,0,2.2184
188.666,0,0,0.0,85.1,14.1,14.1,64.3,23.52,154,168,109,0.00,89,30,12.82,,0,0,0,2.2073

RPM:0[rpm], Speed:0[km/h] は、アイドリングストップ状態。

プログラムコード(スケッチ)(1599行)

tab5_odb_monitor_1.ino
/*
  ================================================================
  M5Stack Tab5 用 OBD2ゲートウェイ・モニター (GUI版) v1
  ================================================================
  役割:これまで Python(REPL) で行っていたBLEクライアント処理を
        Tab5上でGUI化したもの。ゲートウェイ側(ESP32-C3, AsyncCAN-C3)
        のコードは変更不要。

  画面構成:
    1. PID選択画面:チェックボックスで監視したいPIDを選ぶ
    2. モニター画面:選択したPIDを1秒間隔で一括要求し、
       届いた応答を随時デコードしてタイル表示

  必要ライブラリ(Arduino IDE ライブラリマネージャ):
    - M5Unified (0.2.19。2025/10のTab5画面ドライバ変更対応版を使うこと)
    - M5GFX     (0.2.26)
    - lvgl (9.5.0)
    - BLEDevice.h 系(Arduino-ESP32コア(3.3.11)同梱の標準BLEライブラリ。
      NimBLE-Arduinoは不使用 ―― 下記5番を参照)

  ボード設定:
    ツール→ボード→esp32(Espressif公式パッケージ)→ M5Tab5 を選択。
    m5stack社版パッケージではBLEが使えないため不可(下記6番)。

  ハードウェア上の注意:
    Tab5はESP32-P4(メインSoC、無線なし) + ESP32-C6(Wi-Fi/BLE)の
    2チップ構成。BLEはESP-Hosted経由でC6が処理する。

  実機検証で判明した必須事項(Tab5 V2 / ST7123・MIPI-DSI固有):
    1. 独自にM5GFXインスタンスを作ってbegin()しないこと。必ず
       M5.Display(M5Unifiedが管理する唯一のインスタンス)を使う。
       二重初期化するとDSIの状態が壊れ、画面が固定色のまま
       更新されなくなる(クラッシュはしない分、原因特定しづらい)。
    2. lv_init()の直後に lv_tick_set_cb(millis) が必須。これが無いと
       LVGL内部の時間経過が進まず、タッチ読み取りも各種タイマーも
       一切発火しない(画面の初回描画だけは表示されるため気づきにくい)。
    3. タッチコントローラ(GT911)は、書き込み後のソフトリセットだけでは
       再初期化に失敗することがある。挙動がおかしい時は、USBを完全に
       抜き差しする等の完全な電源断→再投入を試すこと。
    4. 画面の向きは setRotation(3) が実機で正しい向きだった
       (setRotation(1)ではなかった。パネル改版により変わりうる)。
    5. NimBLE-Arduinoは非対応。ESP32-P4には無線チップが内蔵されて
       おらず、BLEはESP-Hosted経由でESP32-C6が処理する構成のため、
       NimBLE-Arduinoの対応チップ一覧(ESP32/C3/S3/C6/H2/C2/C5)に
       P4は含まれていない。代わりにArduino-ESP32コア同梱の標準
       BLEライブラリ(BLEDevice.h、ゲートウェイ側と同系統)を使うこと。
    6. m5stack社版ボードパッケージ(3.2.6, IDF5.4ベース)は、BLEヘッダが
       #if SOC_BLE_SUPPORTED で丸ごと無効化されており、P4のBLEホスト
       対応が組み込まれていない。Espressif公式の esp32 パッケージ配下の
       M5Tab5 定義を使うこと。
    7. 【重要・メモリ】BLE初期化(esp-hosted/NimBLEトランスポート層)は
       起動直後のまとまった内部RAMを必要とする。LVGLの描画バッファ
       (DMA領域)を先に大きく確保すると、メモリの絶対量は足りていても
       断片化により後続のBLE初期化が "malloc failed" / "rc=17" で
       失敗する。対策として、
         (a) gateway.begin()(BLE初期化)を、LVGLバッファ確保より
             必ず先に呼ぶこと。
         (b) それでもBLEが内部RAMを150〜160KB程度消費するため、
             LVGL描画バッファは40行のような大きい行数ではなく、
             10行程度の小さめのサイズに収めること
             (実機ではhor_res×10行で動作確認済み)。
         (c) heap_caps_malloc() の戻り値が nullptr でないか必ず
             確認すること。確保失敗時は画面が「クラッシュせず、
             ただ真っ黒のまま」になり、原因特定が非常に困難になる。
         (d) さらに、gateway.begin()(BLE初期化)の"後"に
             M5.Display.width()/height() を呼ぶと 0 が返ってくる
             現象が実機で確認された。解像度の取得(および
             setRotation)は必ずBLE初期化より前に行い、値を変数へ
             キャッシュしておくこと。
    8. PID選択状態は Preferences ライブラリ(NVS/フラッシュ)で保存し、
       次回起動時に自動復元される。当初 RTC_DATA_ATTR での実装を
       試みたが、Tab5(ESP32-P4)ではUSB給電を維持したままEN(リセット)
       ピンを一瞬Lowにするだけでも、RTCメモリの内容がクリアされる
       ことが実機で確認された(一般的なESP32とは異なる挙動)。その
       ため、あらゆるリセット・電源断をまたいで確実に保持される
       Preferences(NVS)方式に変更した。
    9. 【色化け】意図した暗いグレー(0x1E1E1E等、R=G=Bの純粋な灰色)が、
       実機では鮮やかな色(マゼンタ/紫/緑等)として表示される不具合が
       確認された。原因はRGB565の16ビット値のバイトスワップ(上位/下位
       バイトの入れ替え)。M5.Display.setSwapBytes(true) を setup() 内、
       描画開始前に呼ぶことで解消した。
    10. 【SDカード】Tab5のmicroSDスロットは4bit SDMMCインターフェース
       接続。公式ドキュメントのSPIサンプルは動作しないという報告が
       複数あり、SD_MMCライブラリ(CLK=43,CMD=44,D0=39,D1=40,D2=41,
       D3=42)を使うこと。ログ/スクリーンショットはPreferences(NVS)で
       管理する連番でファイル名を採番し、上書きを防いでいる。
    11. 【重要・性能】SDカードへの実書き込み(flush含む)は、安価な
       カードだと内部処理で数十ms〜1秒以上詰まることがある。これを
       メインループ(LVGLタイマー)上で毎秒直接行うと、以前のBLE
       スキャンと同じ理由で画面全体が固まる。そのため、ログ書き込み・
       スクリーンショット保存とも、実際のファイルI/Oはすべて専用
       タスク(sdTaskFunc)に切り出し、メインループ側は
       xQueueSend(timeout=0)でキューに積むだけの非ブロッキング処理に
       している(BLE接続のconnectTaskFuncと同じ設計パターン)。
    12. 【SD安全な取り外し・再マウント】PID選択画面の「Eject SD」/
       「Mount SD」ボタンは、現在のマウント状態(sdAvailable)に応じて
       動作が切り替わるトグルボタン。どちらも同じsdQueue経由(UNMOUNT/
       MOUNTメッセージ)で処理する。キューはFIFOのため、既に積まれて
       いるログ書き込み/クローズ要求が必ず先に処理されてからアン
       マウントされることが保証され、書き込み中のカード抜去による
       破損を防いでいる。カードを挿し直した後は「Mount SD」を押せば
       再認識でき、再起動は不要。
    13. 【ログ形式=CSV】当初はパイプ区切りの人間可読テキスト
       (例:"0x0C RPM: 780 rpm | ...")で保存していたが、Excelでの
       分析には単位・ラベル除去などの前処理が必要で扱いにくいという
       指摘を受け、CSV形式に変更した。
       - 拡張子は.csv、1行目はヘッダー("elapsed_sec,RPM[rpm],...")
       - 各セルは単位を含まない数値のみ(単位はヘッダー名の"[ ]"内に
         記載: 例 "RPM[rpm]")。欠損値は空欄(0やN/A等の文字列にしない)
       - EGT(3センサー)やDPFStat(Regen/Active)のように1PIDが複数列に
         分かれるものは、CsvColumnSpec(PidEntryとは別の軽量な変換専用
         テーブル)で列数・列名を定義している
       - decodeCsvInto()が、decodeInto()(GUI/従来ログ表示用、単位や
         記号付きの人間可読文字列を生成)とは別に、生バイトから直接
         「単位なしの数値文字列」をPidEntry.csvFields[]へ書き込む。
         両者は同じ生ペイロードから独立に計算しており、表示用と
         ログ用でロジックが分岐して食い違わないよう、計算式自体は
         共通(同じ変換式を重複実装している点に注意)。
    14. 【既知の挙動・対応不要と判断】実機ログで、接続確立直後の
       数秒間だけ「1行ごとにCSVの列数が1つずつ増えていく」という
       行がある(原因:PidEntry.csvFieldCountは、そのPIDについて
       一度も応答=成功/タイムアウト問わずを受け取るまでは初期値0の
       まま。応答がPIDごとにバラバラのタイミングで届くため、最初の
       数行だけ列数が徐々に増えて見える)。これを解消する
       initCsvFieldCounts()という関数も用意した(下記に残置)が、
       Excelで開いても実害がないことを確認できたため、setup()から
       は呼んでいない。将来的に他の解析ツールで列数不一致が問題に
       なった場合は、setup()内でこの関数を呼べば解消できる。
    ================================================================
*/

#include <M5Unified.h>
#include <M5GFX.h>
#include <lvgl.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <Preferences.h>
#include <FS.h>
#include <SD_MMC.h>
#include <freertos/queue.h>
#include <BLEClient.h>
#include <BLEAdvertisedDevice.h>

// Arduino IDEの自動プロトタイプ生成が、構造体の実定義より前の位置に
// 関数プロトタイプを挿入してしまうため、この前方宣言が必要
// (ポインタ型の戻り値なら、完全な定義がなくてもこれで足りる)
struct PidEntry;
struct CsvColumnSpec; // ★findCsvSpec()の自動プロトタイプ生成対策で追加

// u16()は本来セクション3(デコード関数)で定義されるが、CSVログ用の
// decodeCsvInto()がそれより前の位置(findPid直後)でこれを使うため、
// ここで前方宣言しておく(実体の定義場所は変更していない)
static uint16_t u16(const uint8_t* d, int i);

// 解像度は setup() で一度だけ取得し、SDカード機能(スクリーンショット)や
// モニター画面のレイアウト計算など、複数箇所で再利用するため
// ファイルの早い位置で宣言しておく
static int32_t g_horRes = 0;
static int32_t g_verRes = 0;

// ================================================================
// 1. ゲートウェイ接続定義(ESP32-C3側と完全一致させること)
// ================================================================
static const char* GATEWAY_NAME = "AsyncCAN-C3";
static const char* SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E";
static const char* CHAR_UUID_RX = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"; // Tab5 -> ESP32
static const char* CHAR_UUID_TX = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"; // ESP32 -> Tab5

static const uint8_t GT_STATUS_SUCCESS   = 0x00;
static const uint8_t GT_STATUS_TIMEOUT   = 0x01;
static const uint8_t GT_STATUS_REJECTED  = 0x02;
static const uint8_t GT_STATUS_SEQ_ERROR = 0x03;

static const char* statusLabel(uint8_t s) {
    switch (s) {
        case GT_STATUS_SUCCESS:   return "OK";
        case GT_STATUS_TIMEOUT:   return "N/A";
        case GT_STATUS_REJECTED:  return "REJECT";
        case GT_STATUS_SEQ_ERROR: return "SEQERR";
        default:                  return "???";
    }
}

// ================================================================
// 2. モニター対象PID定義(前回までの検証結果と同じデコーダーを流用)
// ================================================================
struct PidEntry {
    uint8_t     pid;
    const char* name;
    bool        selected;      // GUIでチェックされているか
    char        valueText[48]; // 直近のデコード結果(GUI/人間可読ログ表示用)
    uint32_t    lastUpdateMs;  // 直近更新時刻(点滅・鮮度表示用)
    lv_obj_t*   checkbox;      // PID選択画面のチェックボックス
    lv_obj_t*   tile;          // モニターのタイル
    lv_obj_t*   tileValueLbl;  // タイル内の値ラベル

    // ★CSVログ用(単位なし・数値のみ)。EGT/DPFStatのように1PIDが
    // 複数列に分かれるものがあるため配列にしている。
    // 既存のpidTable[]初期化リストはこの2項目を明示していないが、
    // C++の集成体初期化により自動的にゼロクリアされるため問題ない。
    char        csvFields[3][16];
    uint8_t     csvFieldCount;
};

static PidEntry pidTable[] = {
    { 0x0C, "RPM",        false, "--", 0, nullptr, nullptr, nullptr },
    { 0x0D, "Speed",      false, "--", 0, nullptr, nullptr, nullptr },
    { 0x04, "Load",       false, "--", 0, nullptr, nullptr, nullptr },
    { 0x11, "Throttle",   false, "--", 0, nullptr, nullptr, nullptr },
    { 0x49, "PedalD",     false, "--", 0, nullptr, nullptr, nullptr },
    { 0x4A, "PedalE",     false, "--", 0, nullptr, nullptr, nullptr },
    { 0x70, "Boost",      false, "--", 0, nullptr, nullptr, nullptr },
    { 0x6D, "RailP",      false, "--", 0, nullptr, nullptr, nullptr },
    { 0x78, "EGT",        false, "--", 0, nullptr, nullptr, nullptr },
    { 0x10, "MAF",        false, "--", 0, nullptr, nullptr, nullptr },
    { 0x05, "WaterTemp",  false, "--", 0, nullptr, nullptr, nullptr },
    { 0x46, "AmbTemp",    false, "--", 0, nullptr, nullptr, nullptr },
    { 0x42, "Volt",       false, "--", 0, nullptr, nullptr, nullptr },
    { 0x83, "NOx",        false, "--", 0, nullptr, nullptr, nullptr },
    { 0x88, "SCR",        false, "--", 0, nullptr, nullptr, nullptr },
    { 0x8B, "DPFStat",    false, "--", 0, nullptr, nullptr, nullptr },
    { 0x8C, "O2Wide",     false, "--", 0, nullptr, nullptr, nullptr },
};
static const size_t PID_COUNT = sizeof(pidTable) / sizeof(pidTable[0]);

static PidEntry* findPid(uint8_t pid) {
    for (size_t i = 0; i < PID_COUNT; i++) {
        if (pidTable[i].pid == pid) return &pidTable[i];
    }
    return nullptr;
}

// ================================================================
// 2-1b. CSVログ用:PIDごとの列仕様テーブル・変換関数
// ================================================================
// SDカード機能(2-3)より前で定義しておく必要がある
// (doOpenLogFileInternal等がこのテーブルを参照するため)。
// ---- CSVログ用:PIDごとの列仕様テーブル ----
// 1つのPIDが複数列に分かれるもの(EGTの3センサー、DPFStatの2項目)があるため、
// 「このPIDは何列で、それぞれのヘッダー名(単位付き)は何か」を定義しておく。
// ヘッダー名は "名前[単位]" 形式
struct CsvColumnSpec {
    uint8_t pid;
    uint8_t fieldCount; // 1〜3
    const char* headers[3];
};

static const CsvColumnSpec CSV_SPECS[] = {
    { 0x0C, 1, { "RPM[rpm]" } },
    { 0x0D, 1, { "Speed[km/h]" } },
    { 0x04, 1, { "Load[%]" } },
    { 0x11, 1, { "Throttle[%]" } },
    { 0x49, 1, { "PedalD[%]" } },
    { 0x4A, 1, { "PedalE[%]" } },
    { 0x70, 1, { "Boost[kPa]" } },
    { 0x6D, 1, { "RailP[MPa]" } },
    { 0x78, 3, { "EGT_S1[°C]", "EGT_S2[°C]", "EGT_S3[°C]" } },
    { 0x10, 1, { "MAF[g/s]" } },
    { 0x05, 1, { "WaterTemp[°C]" } },
    { 0x46, 1, { "AmbTemp[°C]" } },
    { 0x42, 1, { "Volt[V]" } },
    { 0x83, 1, { "NOx[ppm]" } },
    { 0x88, 1, { "SCR_Active[0or1]" } },
    { 0x8B, 2, { "DPF_Regen[0or1]", "DPF_Active[0or1]" } },
    { 0x8C, 1, { "Lambda1[-]" } },
};
static const size_t CSV_SPEC_COUNT = sizeof(CSV_SPECS) / sizeof(CSV_SPECS[0]);

static const CsvColumnSpec* findCsvSpec(uint8_t pid) {
    for (size_t i = 0; i < CSV_SPEC_COUNT; i++) {
        if (CSV_SPECS[i].pid == pid) return &CSV_SPECS[i];
    }
    return nullptr;
}

// 【重要】PidEntry.csvFieldCountは、そのPIDについて一度もゲートウェイから
// 応答(成功/タイムアウト問わず)を受け取っていない間は初期値0のままになる。
// これを放置すると、接続確立直後の数秒間、PIDごとに応答到達タイミングが
// バラつくために「1行ごとにCSVの列数が1つずつ増えていく」という行が
// 出力される(実機ログで確認済み)。
//
// ※この関数はその対策として用意したが、実際にExcelで開いても実害が
// 無いことが確認できたため、setup()からは呼んでいない(未使用)。
// 将来的に別の解析ツールで列数不一致が問題になった場合は、setup()の
// 早い段階でこれを呼べば、最初の行から常に正しい列数(未受信の値は
// 空欄)でCSVを出力できるようになる。
static void initCsvFieldCounts() {
    for (size_t i = 0; i < PID_COUNT; i++) {
        const CsvColumnSpec* spec = findCsvSpec(pidTable[i].pid);
        pidTable[i].csvFieldCount = spec ? spec->fieldCount : 0;
        for (uint8_t c = 0; c < pidTable[i].csvFieldCount; c++) {
            pidTable[i].csvFields[c][0] = '\0'; // 未受信は空欄
        }
    }
}

// 生バイトから、CSV用の「数値のみ・単位なし」の文字列を組み立てる。
// decodeInto()と計算式は同一だが、こちらは人間向けの単位や記号を一切付けない。
// データが無い/非対応の場合は空文字のままにする(CSV上は空欄=欠損として扱われる)。
static void decodeCsvInto(uint8_t pid, const uint8_t* data, uint8_t len, PidEntry* e) {
    const CsvColumnSpec* spec = findCsvSpec(pid);
    if (!spec) { e->csvFieldCount = 0; return; }

    e->csvFieldCount = spec->fieldCount;
    for (uint8_t i = 0; i < spec->fieldCount; i++) e->csvFields[i][0] = '\0';

    if (len == 0) return; // 空欄のまま

    switch (pid) {
        case 0x0C: snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.0f", u16(data,0) / 4.0); break;
        case 0x0D: snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%d", data[0]); break;
        case 0x04: case 0x11: case 0x49: case 0x4A:
            snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.1f", data[0] / 2.55);
            break;
        case 0x10: snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.2f", u16(data,0) / 100.0); break;
        case 0x05: case 0x46: snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%d", data[0] - 40); break;
        case 0x42: snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.2f", u16(data,0) / 1000.0); break;
        case 0x70: {
            uint8_t sup = data[0];
            if ((sup & 0x02) && len >= 5) {
                snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.1f", u16(data,3) / 32.0);
            }
            break;
        }
        case 0x6D: {
            uint8_t sup = data[0];
            if ((sup & 0x02) && len >= 5) {
                snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.2f", (u16(data,3) * 10) / 1000.0);
            }
            break;
        }
        case 0x78: {
            uint8_t sup = data[0];
            for (int s = 0; s < 3 && len >= (size_t)(2 + s * 2); s++) {
                if (sup & (1 << s)) {
                    float t = (u16(data, 1 + s * 2) / 10.0) - 40.0;
                    snprintf(e->csvFields[s], sizeof(e->csvFields[s]), "%.0f", t);
                }
            }
            break;
        }
        case 0x83: {
            uint8_t sup = data[0] & 0x0F;
            if ((sup & 0x01) && len >= 3) {
                uint16_t raw = u16(data, 1);
                if (raw != 0xFFFF) snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%u", raw);
            }
            break;
        }
        case 0x88:
            snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%d", (data[0] & 0x80) ? 1 : 0);
            break;
        case 0x8B: {
            uint8_t st = len >= 2 ? data[1] : 0;
            snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%d", st & 0x01);
            snprintf(e->csvFields[1], sizeof(e->csvFields[1]), "%d", (st & 0x02) ? 1 : 0);
            break;
        }
        case 0x8C: {
            uint8_t sup = data[0];
            if ((sup & 0x10) && len >= 11) {
                float lambda = u16(data, 9) * 0.000122f;
                snprintf(e->csvFields[0], sizeof(e->csvFields[0]), "%.4f", lambda);
            }
            break;
        }
        default:
            break; // CSV未対応PID(生16進表示のもの)は空欄のまま
    }
}


// ================================================================
// 2-2. PID選択状態の永続化(NVS / Preferences ライブラリ使用)
// ================================================================
// 【重要・実機検証結果】当初 RTC_DATA_ATTR (RTCメモリ) で永続化を
// 試みたが、Tab5(ESP32-P4)ではUSB給電を維持したままEN(リセット)ピンを
// 一瞬Lowにするだけの操作でも、RTCメモリの内容がクリアされることが
// 実機で確認された(一般的なESP32ではEN経由のリセットでRTCメモリは
// 保持されるが、Tab5はこの前提が成り立たない)。
//
// そのため、フラッシュ上の不揮発領域(NVS)に書き込む Preferences
// ライブラリに切り替えた。これは電源断はもちろん、あらゆる種類の
// リセットをまたいで確実に保持される。Arduino-ESP32コア同梱のため
// 追加ライブラリのインストールは不要。
//
// pidTable の並び順や個数が将来変わってもズレて誤読しないよう、
// 「PIDの値そのもの」と「選択状態」をペアで保存し、復元時はPID値で
// 突き合わせる方式にしている(配列インデックスへの単純な保存はしない)。
static Preferences pidPrefs;

struct PidSelectRecord {
    uint8_t pid;
    uint8_t selected; // boolだと環境によりパディング差が出るためuint8_tで統一
};

static void savePidSelection() {
    PidSelectRecord records[PID_COUNT];
    for (size_t i = 0; i < PID_COUNT; i++) {
        records[i].pid = pidTable[i].pid;
        records[i].selected = pidTable[i].selected ? 1 : 0;
    }
    pidPrefs.begin("pidcfg", false); // false = 読み書き可能モード
    pidPrefs.putBytes("selmap", records, sizeof(records));
    pidPrefs.end();
}

static void restorePidSelection() {
    pidPrefs.begin("pidcfg", true); // true = 読み取り専用モード
    size_t storedLen = pidPrefs.getBytesLength("selmap");

    if (storedLen == sizeof(PidSelectRecord) * PID_COUNT) {
        PidSelectRecord records[PID_COUNT];
        pidPrefs.getBytes("selmap", records, sizeof(records));
        for (size_t i = 0; i < PID_COUNT; i++) {
            PidEntry* e = findPid(records[i].pid);
            if (e) e->selected = (records[i].selected != 0);
        }
    }
    // storedLenが一致しない場合(初回起動、PID_COUNT変更後など)は
    // 何もしない。全項目 selected=false のデフォルト状態のまま。

    pidPrefs.end();
}

// ================================================================
// 2-3. SDカード機能(ログ保存・スクリーンショット保存)
// ================================================================
// Tab5のmicroSDスロットは、SPIではなく4bit SDMMCインターフェースで
// 接続されている。M5Stack公式ドキュメント(SPI方式のサンプル)は
// 実機では動作しないという報告が複数あり、コミュニティで動作確認
// されている下記のSD_MMCピン割り当てを採用する。
#define SD_MMC_CLK GPIO_NUM_43
#define SD_MMC_CMD GPIO_NUM_44
#define SD_MMC_D0  GPIO_NUM_39
#define SD_MMC_D1  GPIO_NUM_40
#define SD_MMC_D2  GPIO_NUM_41
#define SD_MMC_D3  GPIO_NUM_42

static bool sdAvailable = false;      // マウントに成功したか
static bool saveToSdEnabled = false;  // PID選択画面のチェックボックス状態
static bool loggingActiveForUI = false; // モニター画面の「LOGGING」表示用(楽観的フラグ)
static File logFile;                  // モニター中、開いたままにするログファイル
static volatile bool logFileOpen = false; // ★専用タスクからのみ書き込む。メインループは読むだけ

// 【重要・性能】SDカードへの実書き込み(flush含む)は、安価なカードだと
// 内部のウェアレベリング処理で数十ms〜1秒以上詰まることがある。
// これを毎秒メインループ(LVGLのタイマー)上で直接行うと、以前のBLE
// スキャンと同じ理由でタッチ操作を含む画面全体が固まってしまう。
// そのため、実際のファイルI/Oはすべて専用タスク(sdTaskFunc)で行い、
// メインループ側は「書きたい内容をキューに積むだけ」の軽い処理に
// 徹する設計にしている。
struct SdTaskMsg {
    enum Type : uint8_t { OPEN_LOG, WRITE_LOG, CLOSE_LOG, SCREENSHOT, UNMOUNT, MOUNT } type;
    char line[800]; // WRITE_LOG専用。選択PIDが多い場合でも収まるサイズ
};
static QueueHandle_t sdQueue = nullptr;
static TaskHandle_t  sdTaskHandle = nullptr;

// スクリーンショットの結果をメインループへ伝えるためのフラグ
// (0=結果待ちなし, 1=成功, 2=失敗)。UIラベル更新はメインループ側で行う。
static volatile int8_t g_screenshotResult = 0;

// SDカード取り外し完了をメインループへ伝えるフラグ (0=待ちなし, 1=完了)
static volatile int8_t g_ejectDone = 0;

// SDカード再マウント結果をメインループへ伝えるフラグ (0=待ちなし, 1=成功, 2=失敗)
static volatile int8_t g_mountResult = 0;

// ログ/スクリーンショットのファイル名が重複しないよう、連番をNVSで管理する
static uint32_t nextLogIndex() {
    Preferences p;
    p.begin("filecounters", false);
    uint32_t idx = p.getUInt("logIdx", 0) + 1;
    p.putUInt("logIdx", idx);
    p.end();
    return idx;
}

static uint32_t nextShotIndex() {
    Preferences p;
    p.begin("filecounters", false);
    uint32_t idx = p.getUInt("shotIdx", 0) + 1;
    p.putUInt("shotIdx", idx);
    p.end();
    return idx;
}

// 実際にSD_MMCをマウントする処理本体。
// 起動時(setup、メインスレッドからの1回きりの呼び出し)と、
// 「Mount SD」ボタン押下時(専用タスクからの呼び出し)の両方で使う。
static bool doMountSdCardInternal() {
    SD_MMC.setPins(SD_MMC_CLK, SD_MMC_CMD, SD_MMC_D0, SD_MMC_D1, SD_MMC_D2, SD_MMC_D3);
    // 第2引数(mode1bit)=false: 4bitモード。第3引数=false: マウント失敗時に
    // フォーマットしない(誤って初期化されたカードのデータを守るため)。
    sdAvailable = SD_MMC.begin("/sdcard", false, false, 20000);
    Serial.printf("[SD] mount %s\n", sdAvailable ? "OK" : "FAILED");
    return sdAvailable;
}

static void initSdCard() {
    doMountSdCardInternal();
}

// ---- ここから下:実際のファイルI/Oを行う関数群。
// ---- sdTaskFunc(専用タスク)からのみ呼び出すこと。

static void doOpenLogFileInternal() {
    if (!sdAvailable) return;

    char path[32];
    snprintf(path, sizeof(path), "/log_%04u.csv", (unsigned)nextLogIndex()); // ★拡張子を.csvに変更

    logFile = SD_MMC.open(path, FILE_WRITE, true); // 第3引数true=無ければ作成
    if (!logFile) {
        Serial.printf("[SD] failed to open log file: %s\n", path);
        logFileOpen = false;
        return;
    }

    // ★CSVヘッダー行:Excel等でそのまま数値として扱えるよう、
    // カンマ区切り・「名前[単位]」形式の列名を1回だけ書く。
    // EGT(3列)やDPFStat(2列)のように1PIDが複数列に分かれるものは、
    // CSV_SPECSのfieldCount分だけ列を展開する。
    logFile.print("elapsed_sec");
    for (size_t i = 0; i < PID_COUNT; i++) {
        if (!pidTable[i].selected) continue;
        const CsvColumnSpec* spec = findCsvSpec(pidTable[i].pid);
        if (!spec) continue; // CSV未対応PIDは列を作らない
        for (uint8_t c = 0; c < spec->fieldCount; c++) {
            logFile.print(",");
            logFile.print(spec->headers[c]);
        }
    }
    logFile.println();
    logFile.flush();

    logFileOpen = true;
    Serial.printf("[SD] logging to %s\n", path);
}

static void doCloseLogFileInternal() {
    if (logFileOpen) {
        logFile.close();
        logFileOpen = false;
    }
}

static void doWriteLogRowInternal(const char* line) {
    // ★lineはrequestWriteLogRow()で既にCSV1行分(カンマ区切り)に組み立て済み。
    // ここでは末尾に改行を付けて書き込むだけでよい。
    if (!logFileOpen) return;
    logFile.println(line);
    logFile.flush(); // ★この処理が時々遅くなり得るが、専用タスクなのでUIには影響しない
}

// SDカードを安全に取り外すための処理。
// 「取り外し」要求はキューの末尾に積まれるため、既にキューにある
// OPEN_LOG/WRITE_LOG/CLOSE_LOG は必ずこれより先に処理される
// (FIFOのため)。念のため、開いたままのファイルがあれば明示的に
// 閉じてからアンマウントする。
static void doUnmountSdCardInternal() {
    if (logFileOpen) {
        doCloseLogFileInternal(); // 保険:万一まだ開いていれば先に閉じる
    }
    if (sdAvailable) {
        SD_MMC.end();
        sdAvailable = false;
        Serial.println("[SD] unmounted safely");
    }
}

// モニター画面の表示内容を24bit BMPとしてSDへ保存する。
// M5GFX(LovyanGFX)の readRect() で、現在パネルに描画されている
// 内容をそのまま読み戻せることを利用している。
static bool doSaveScreenshotBMPInternal() {
    if (!sdAvailable) return false;

    int32_t w = g_horRes;
    int32_t h = g_verRes;
    if (w <= 0 || h <= 0) return false;

    uint32_t rowSize = ((uint32_t)w * 3 + 3) & ~3u; // BMPの行は4バイト境界に揃える
    uint32_t imageSize = rowSize * (uint32_t)h;
    uint32_t fileSize = 54 + imageSize;

    char path[32];
    snprintf(path, sizeof(path), "/shot_%04u.bmp", (unsigned)nextShotIndex());

    File f = SD_MMC.open(path, FILE_WRITE, true);
    if (!f) {
        Serial.printf("[SD] failed to open screenshot file: %s\n", path);
        return false;
    }

    uint8_t header[54];
    memset(header, 0, sizeof(header));
    header[0] = 'B'; header[1] = 'M';
    *(uint32_t*)&header[2]  = fileSize;
    *(uint32_t*)&header[10] = 54; // ピクセルデータの開始オフセット
    *(uint32_t*)&header[14] = 40; // DIBヘッダーのサイズ
    *(int32_t*)&header[18]  = w;
    *(int32_t*)&header[22]  = h;  // 正の値=ボトムアップ(BMP標準)
    *(uint16_t*)&header[26] = 1;  // カラープレーン数
    *(uint16_t*)&header[28] = 24; // 1ピクセルあたりビット数
    *(uint32_t*)&header[34] = imageSize;
    f.write(header, sizeof(header));

    uint16_t* rowBuf16 = (uint16_t*)malloc((size_t)w * sizeof(uint16_t));
    uint8_t*  rowOut   = (uint8_t*)malloc(rowSize);
    bool ok = (rowBuf16 != nullptr) && (rowOut != nullptr);

    if (ok) {
        // BMPは画像データを下の行から順に格納する
        // ★注意:この読み出し(readRect)は専用タスクから行っている。
        // メインループ(LVGLのflushコールバック)と同時に実行される可能性が
        // あり、理論上は読み出し中の1〜2行だけ描画中の内容が混ざる
        // (テアリング)可能性はあるが、クラッシュ等の実害はない。
        for (int32_t y = h - 1; y >= 0; y--) {
            M5.Display.readRect(0, y, w, 1, rowBuf16);
            memset(rowOut, 0, rowSize);
            for (int32_t x = 0; x < w; x++) {
                uint16_t px = rowBuf16[x];
                uint8_t r = (px >> 11) & 0x1F; r = (uint8_t)((r << 3) | (r >> 2));
                uint8_t g = (px >> 5)  & 0x3F; g = (uint8_t)((g << 2) | (g >> 4));
                uint8_t b =  px        & 0x1F; b = (uint8_t)((b << 3) | (b >> 2));
                rowOut[x * 3 + 0] = b; // BMPはB,G,Rの並び
                rowOut[x * 3 + 1] = g;
                rowOut[x * 3 + 2] = r;
            }
            f.write(rowOut, rowSize);
        }
    } else {
        Serial.println("[SD] screenshot: malloc failed");
    }

    free(rowBuf16);
    free(rowOut);
    f.close();

    if (ok) Serial.printf("[SD] screenshot saved: %s\n", path);
    return ok;
}

// ---- 専用タスク本体:キューから受け取った内容を順番に処理する ----
static void sdTaskFunc(void* pvParameters) {
    for (;;) {
        SdTaskMsg msg;
        if (xQueueReceive(sdQueue, &msg, portMAX_DELAY) != pdTRUE) continue;

        switch (msg.type) {
            case SdTaskMsg::OPEN_LOG:
                doOpenLogFileInternal();
                break;
            case SdTaskMsg::WRITE_LOG:
                doWriteLogRowInternal(msg.line);
                break;
            case SdTaskMsg::CLOSE_LOG:
                doCloseLogFileInternal();
                break;
            case SdTaskMsg::SCREENSHOT: {
                bool ok = doSaveScreenshotBMPInternal();
                g_screenshotResult = ok ? 1 : 2; // メインループ側が拾って表示を更新する
                break;
            }
            case SdTaskMsg::UNMOUNT:
                doUnmountSdCardInternal();
                g_ejectDone = 1; // メインループ側が拾ってUIを更新する
                break;
            case SdTaskMsg::MOUNT: {
                bool ok = doMountSdCardInternal();
                g_mountResult = ok ? 1 : 2; // メインループ側が拾ってUIを更新する
                break;
            }
        }
    }
}

static void startSdTaskIfNeeded() {
    if (!sdQueue) sdQueue = xQueueCreate(4, sizeof(SdTaskMsg));
    if (!sdTaskHandle) {
        xTaskCreate(sdTaskFunc, "SD_Task", 8192, NULL, 1, &sdTaskHandle);
    }
}

// ---- ここから下:メインループ(GUIスレッド)から呼ぶ、非ブロッキングの窓口 ----
// いずれも「キューに積むだけ」で即座に返る。キューが詰まっていた場合でも
// (timeout=0のため)待たずに諦める=メインループを絶対にブロックしない。

static void requestOpenLogFile() {
    if (!sdQueue) return;
    SdTaskMsg m;
    m.type = SdTaskMsg::OPEN_LOG;
    xQueueSend(sdQueue, &m, 0);
}

static void requestCloseLogFile() {
    if (!sdQueue) return;
    SdTaskMsg m;
    m.type = SdTaskMsg::CLOSE_LOG;
    xQueueSend(sdQueue, &m, 0);
}

static void requestWriteLogRow() {
    if (!sdQueue || !logFileOpen) return;

    SdTaskMsg m;
    m.type = SdTaskMsg::WRITE_LOG;

    // ★CSV形式:先頭は経過秒数(数値)、以降は選択中PIDの数値のみをカンマ区切りで並べる。
    // ヘッダー行(doOpenLogFileInternal)と列の並び・列数が必ず一致するよう、
    // 同じ「pidTableをi順に走査し、selectedのものだけfieldCount分出力する」
    // ロジックにしている。
    size_t pos = (size_t)snprintf(m.line, sizeof(m.line), "%.3f", millis() / 1000.0);
    for (size_t i = 0; i < PID_COUNT && pos < sizeof(m.line); i++) {
        if (!pidTable[i].selected) continue;
        for (uint8_t c = 0; c < pidTable[i].csvFieldCount && pos < sizeof(m.line); c++) {
            pos += (size_t)snprintf(m.line + pos, sizeof(m.line) - pos,
                ",%s", pidTable[i].csvFields[c]); // 欠損時はcsvFields[c]が空文字=空欄になる
        }
    }

    xQueueSend(sdQueue, &m, 0); // ★timeout=0:詰まっていてもメインループは待たず、その回は諦める
}

static void requestScreenshot() {
    if (!sdQueue) return;
    SdTaskMsg m;
    m.type = SdTaskMsg::SCREENSHOT;
    xQueueSend(sdQueue, &m, 0);
}

static void requestEjectSd() {
    if (!sdQueue) return;
    SdTaskMsg m;
    m.type = SdTaskMsg::UNMOUNT;
    // ★他のrequestXxx関数と同様、timeout=0でメインループは絶対にブロックしない。
    // キューはFIFOなので、既に積まれているOPEN_LOG/WRITE_LOG/CLOSE_LOGが
    // 先に処理されることは保証される(万一キューが満杯で送信できなかった
    // 場合は、この回の取り外し要求は諦められる=ボタンを押し直せばよい)。
    xQueueSend(sdQueue, &m, 0);
}

static void requestMountSd() {
    if (!sdQueue) return;
    SdTaskMsg m;
    m.type = SdTaskMsg::MOUNT;
    xQueueSend(sdQueue, &m, 0);
}

// ================================================================
// 3. デコード関数(前回のC実装をそのまま流用・簡略化)
// ================================================================
static uint16_t u16(const uint8_t* d, int i) { return ((uint16_t)d[i] << 8) | d[i + 1]; }

static void decodeInto(uint8_t pid, const uint8_t* data, uint8_t len, char* out, size_t outLen) {
    if (len == 0) { snprintf(out, outLen, "N/A"); return; }

    switch (pid) {
        case 0x0C: snprintf(out, outLen, "%.0f rpm", u16(data,0) / 4.0); return;
        case 0x0D: snprintf(out, outLen, "%d km/h", data[0]); return;
        case 0x04: snprintf(out, outLen, "%.1f %%", data[0] / 2.55); return;
        case 0x11: case 0x49: case 0x4A:
                   snprintf(out, outLen, "%.1f %%", data[0] / 2.55); return;
        case 0x10: snprintf(out, outLen, "%.2f g/s", u16(data,0) / 100.0); return;
        case 0x05: snprintf(out, outLen, "%d°C", data[0] - 40); return;
        case 0x46: snprintf(out, outLen, "%d°C", data[0] - 40); return;
        case 0x42: snprintf(out, outLen, "%.2f V", u16(data,0) / 1000.0); return;

        case 0x70: { // Boost: A=Sup, D-E=SensA
            uint8_t sup = data[0];
            if ((sup & 0x02) && len >= 5) snprintf(out, outLen, "%.1f kPa", u16(data,3) / 32.0);
            else snprintf(out, outLen, "Sup:%02X", sup);
            return;
        }
        case 0x6D: { // Rail Pressure: A=Sup, D-E=RailA (kPa値を1000で割りMPa表記に変換)
            uint8_t sup = data[0];
            if ((sup & 0x02) && len >= 5) {
                float mpa = (u16(data,3) * 10) / 1000.0f;
                snprintf(out, outLen, "%.2f MPa", mpa);
            } else {
                snprintf(out, outLen, "Sup:%02X", sup);
            }
            return;
        }
        case 0x78: { // EGT: A=Sup, B-C/D-E/F-G = S1/S2/S3
            uint8_t sup = data[0];
            char tmp[48] = "";
            for (int s = 0; s < 4 && len >= (size_t)(2 + s * 2); s++) {
                if (sup & (1 << s)) {
                    float t = (u16(data, 1 + s * 2) / 10.0) - 40.0;
                    char part[16];
                    snprintf(part, sizeof(part), "%.0f°C ", s + 1, t);
                    strncat(tmp, part, sizeof(tmp) - strlen(tmp) - 1);
                } else {
                    strcat(tmp, "- ");
                }
            }
            snprintf(out, outLen, "%s", tmp[0] ? tmp : "N/A");
            return;
        }
        case 0x83: { // NOx
            uint8_t sup = data[0] & 0x0F;
            if ((sup & 0x01) && len >= 3) {
                uint16_t raw = u16(data, 1);
                if (raw == 0xFFFF) snprintf(out, outLen, "N/A");
                else snprintf(out, outLen, "%u ppm", raw);
            } else {
                snprintf(out, outLen, "Sup:%02X", sup);
            }
            return;
        }
        case 0x88: { // SCR
            snprintf(out, outLen, "Active:%d", (data[0] & 0x80) ? 1 : 0);
            return;
        }
        case 0x8B: { // DPF status
            uint8_t sup = data[0];
            uint8_t st = len >= 2 ? data[1] : 0;
            snprintf(out, outLen, "Regen:%d %s", st & 0x01, (st & 0x02) ? "Act" : "Pas");
            (void)sup;
            return;
        }
        case 0x8C: { // O2 wide, Lambda B1S1のみ代表表示
            uint8_t sup = data[0];
            if ((sup & 0x10) && len >= 11) {
                float lambda = u16(data, 9) * 0.000122f;
                snprintf(out, outLen, "L1:%.3f", lambda);
            } else {
                snprintf(out, outLen, "Sup:%02X", sup);
            }
            return;
        }
        default: {
            // 未対応PID:生バイトを16進表示
            char tmp[48] = "";
            for (uint8_t i = 0; i < len && i < 8; i++) {
                char b[4];
                snprintf(b, sizeof(b), "%02X", data[i]);
                strncat(tmp, b, sizeof(tmp) - strlen(tmp) - 1);
            }
            snprintf(out, outLen, "%s", tmp);
            return;
        }
    }
}

// ================================================================
// 4. BLEクライアント(Pythonのフレーム再結合ロジックを移植)
// ================================================================
// 【重要】Tab5(ESP32-P4)はNimBLE-Arduino非対応のため、
// Arduino-ESP32コア同梱の標準BLEライブラリ(BLEDevice.h系、
// ゲートウェイ側と同系統)を使用する。
//
// 標準ライブラリの registerForNotify は「関数ポインタ」を要求し、
// メンバ関数やラムダのキャプチャは使えないため、静的コールバック
// から唯一のインスタンス(instance)へ転送する形にしている。
class GatewayClient {
public:
    volatile bool connected = false;   // 別タスク(接続処理)とメインループの両方から参照するためvolatile
    bool monitoring = false;

    GatewayClient() { instance = this; }

    void begin() {
        BLEDevice::init("Tab5-OBD-Client");
        BLEDevice::setMTU(247);
    }

    void connectToGateway() {
        BLEScan* scan = BLEDevice::getScan();
        scan->setActiveScan(true);
        BLEScanResults* results = scan->start(5, false); // 5秒スキャン

        BLEAdvertisedDevice target;
        bool found = false;
        for (int i = 0; i < results->getCount(); i++) {
            BLEAdvertisedDevice d = results->getDevice(i);
            if (d.haveName() && d.getName() == GATEWAY_NAME) {
                target = d;
                found = true;
                break;
            }
        }
        scan->clearResults();
        if (!found) return;

        pClient = BLEDevice::createClient();
        if (!pClient->connect(&target)) return;

        BLERemoteService* svc = pClient->getService(SERVICE_UUID);
        if (!svc) { pClient->disconnect(); return; }

        pRxChar = svc->getCharacteristic(CHAR_UUID_RX);
        BLERemoteCharacteristic* txChar = svc->getCharacteristic(CHAR_UUID_TX);
        if (!pRxChar || !txChar) { pClient->disconnect(); return; }

        if (!txChar->canNotify()) { pClient->disconnect(); return; }
        txChar->registerForNotify(GatewayClient::staticNotifyCallback);

        connected = true;
    }

    // 選択されたPID一覧を一括要求する(応答との対応付けは行わない:非同期受信のみ)
    void requestSelectedPids(uint8_t mode) {
        if (!connected || !pRxChar) return;

        uint8_t packet[2 + PID_COUNT];
        uint8_t count = 0;
        packet[0] = mode;
        for (size_t i = 0; i < PID_COUNT; i++) {
            if (pidTable[i].selected) packet[2 + count++] = pidTable[i].pid;
        }
        if (count == 0) return;
        packet[1] = count;

        pRxChar->writeValue(packet, 2 + count, false); // response=false
    }

private:
    BLEClient* pClient = nullptr;
    BLERemoteCharacteristic* pRxChar = nullptr;

    // 唯一のインスタンスへ転送するための静的ポインタ
    // (registerForNotifyは関数ポインタしか受け付けないため)
    static GatewayClient* instance;

    static void staticNotifyCallback(BLERemoteCharacteristic* c,
                                      uint8_t* data, size_t len, bool isNotify) {
        if (instance) instance->onNotify(data, len);
    }

    // ストリーム再結合バッファ(Pythonの BleNotifyReceiver と同一ロジック)
    uint8_t rxBuffer[512];
    size_t  rxLen = 0;
    size_t  expectedLen = 0;
    bool    headerParsed = false;

    void onNotify(uint8_t* data, size_t len) {
        if (rxLen + len > sizeof(rxBuffer)) {
            // バッファ超過:安全のため破棄してリセット
            rxLen = 0; headerParsed = false; expectedLen = 0;
            return;
        }
        memcpy(rxBuffer + rxLen, data, len);
        rxLen += len;

        while (true) {
            if (!headerParsed) {
                if (rxLen < 5) return;
                uint16_t payloadLen = ((uint16_t)rxBuffer[3] << 8) | rxBuffer[4];
                expectedLen = 5 + payloadLen;
                headerParsed = true;
            }
            if (rxLen < expectedLen) return;

            handlePacket(rxBuffer, expectedLen);

            size_t remain = rxLen - expectedLen;
            memmove(rxBuffer, rxBuffer + expectedLen, remain);
            rxLen = remain;
            headerParsed = false;
            expectedLen = 0;
        }
    }

    void handlePacket(uint8_t* pkt, size_t len) {
        uint8_t status = pkt[0];
        uint8_t mode = pkt[1];
        uint8_t pid = pkt[2];
        uint16_t payloadLen = ((uint16_t)pkt[3] << 8) | pkt[4];
        uint8_t* payload = pkt + 5;
        (void)mode;

        PidEntry* e = findPid(pid);
        if (!e) return;

        if (status == GT_STATUS_SUCCESS) {
            char decoded[40];
            decodeInto(pid, payload, (uint8_t)payloadLen, decoded, sizeof(decoded));
            snprintf(e->valueText, sizeof(e->valueText), "%s", decoded);
            decodeCsvInto(pid, payload, (uint8_t)payloadLen, e); // ★CSVログ用の数値も同時に更新
        } else {
            snprintf(e->valueText, sizeof(e->valueText), "%s", statusLabel(status));
            // ★CSV側も欠損として空欄化する(列数だけは維持し、他行との桁ズレを防ぐ)
            const CsvColumnSpec* spec = findCsvSpec(pid);
            e->csvFieldCount = spec ? spec->fieldCount : 0;
            for (uint8_t i = 0; i < e->csvFieldCount; i++) e->csvFields[i][0] = '\0';
        }
        e->lastUpdateMs = millis();
        // GUI更新はメインループ側のタイマーでまとめて反映(LVGLはスレッドセーフでないため)
    }
};

// クラス内 static メンバの実体定義(C++の作法上、クラス外に1つ必要)
GatewayClient* GatewayClient::instance = nullptr;

static GatewayClient gateway;

// ================================================================
// 4-2. BLE接続専用タスク(メインループ/GUIスレッドをブロックしないため)
// ================================================================
// connectToGateway() は内部で最大5秒のブロッキングスキャンを行うため、
// これをメインループ(lv_timer_handler経由)から直接呼ぶと、その間
// タッチ操作を含む画面全体が固まってしまう。専用タスクに切り出し、
// メインループ側は「今接続を試みているか」だけを見るようにする。
static TaskHandle_t connectTaskHandle = nullptr;
static volatile bool connectInProgress = false;

static void connectTaskFunc(void* pvParameters) {
    for (;;) {
        // 通知が来るまで待機(何もしていない間はCPUを消費しない)
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        gateway.connectToGateway(); // ここで最大5秒ブロックするが、別タスクなので無害
        connectInProgress = false;
    }
}

static void startConnectTaskIfNeeded() {
    if (connectTaskHandle == nullptr) {
        xTaskCreate(connectTaskFunc, "BLE_Connect_Task", 8192, NULL, 1, &connectTaskHandle);
    }
}

// 「今すぐ再接続を試みたい」という要求を、非ブロッキングで発行する
static void requestConnect() {
    if (gateway.connected || connectInProgress) return; // 既に接続済み/試行中なら何もしない
    connectInProgress = true;
    xTaskNotifyGive(connectTaskHandle);
}

// ================================================================
// 5. LVGL / M5GFX まわり
// ================================================================
// 【重要】独自のM5GFXインスタンスは作らない。M5.begin()が初期化した
// M5.Display(唯一のインスタンス)を使うこと。Tab5(ST7123/MIPI-DSI)は
// 二重初期化するとDSIブリッジの状態が壊れ、画面が固定色のまま
// 更新されなくなる不具合を引き起こすことが実機検証済み。
static lv_display_t* lvDisp;
static lv_indev_t*   lvTouch;

static lv_color_t* lvBuf1;
static lv_color_t* lvBuf2;

// バックライト輝度・消灯状態の管理
static const uint8_t BRIGHTNESS_ON  = 128;
static const uint8_t BRIGHTNESS_OFF = 0;
static bool isDisplayOn = true;

static void turnDisplayOn() {
    M5.Display.setBrightness(BRIGHTNESS_ON);
    isDisplayOn = true;
}

static void turnDisplayOff() {
    M5.Display.setBrightness(BRIGHTNESS_OFF);
    isDisplayOn = false;
}

static void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
    uint32_t w = area->x2 - area->x1 + 1;
    uint32_t h = area->y2 - area->y1 + 1;
    M5.Display.pushImage(area->x1, area->y1, w, h, (uint16_t*)px_map);
    lv_display_flush_ready(disp);
}

static void lvglTouchCb(lv_indev_t* indev, lv_indev_data_t* data) {
    auto t = M5.Touch.getDetail();

    if (t.isPressed()) {
        // 消灯中のタップは「復帰専用」として扱い、GUI操作としては無視する
        // (復帰直後の1回目のタップでボタンが誤動作するのを防ぐ)
        if (!isDisplayOn) {
            turnDisplayOn();
            data->state = LV_INDEV_STATE_RELEASED;
            data->point.x = 0;
            data->point.y = 0;
            return;
        }
        data->state = LV_INDEV_STATE_PRESSED;
        data->point.x = t.x;
        data->point.y = t.y;
    } else {
        data->state = LV_INDEV_STATE_RELEASED;
    }
}

static void onDispOffClicked(lv_event_t* e) {
    turnDisplayOff();
}

// スクリーンショット保存後、ボタンに一瞬だけ結果を表示してから元に戻す
static void screenshotFeedbackTimerCb(lv_timer_t* t) {
    lv_obj_t* btn = (lv_obj_t*)lv_timer_get_user_data(t);
    lv_obj_t* lbl = lv_obj_get_child(btn, 0);
    if (lbl) lv_label_set_text(lbl, "Screenshot");
    lv_obj_set_style_bg_color(btn, lv_color_hex(0x2962FF), 0);
    lv_timer_delete(t);
}

// スクリーンショットボタンへの参照(PID選択画面・モニター画面のどちらの
// ボタンが最後に押されたかを記録する)。refreshTimerCbが、タスクからの
// 結果(g_screenshotResult)を見てここに表示を反映する。
static lv_obj_t* g_shotBtn = nullptr;

static void onScreenshotClicked(lv_event_t* e) {
    lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
    g_shotBtn = btn; // ★どちらの画面のボタンが押されたかを覚えておく

    // ★実際のBMP書き込みは専用タスクへ依頼するだけ。ここではブロックしない。
    requestScreenshot();

    lv_obj_t* lbl = lv_obj_get_child(btn, 0);
    if (lbl) lv_label_set_text(lbl, "Saving...");
    lv_obj_set_style_bg_color(btn, lv_color_hex(0x616161), 0); // 処理中は落ち着いた色に
}

// ================================================================
// 6. GUI: 画面1 - PID選択
// ================================================================
static lv_obj_t* screenSelect;
static lv_obj_t* screenDash;

// SDカード取り外し機能で使うUI要素への参照
// (refreshTimerCbから、専用タスクの処理完了を反映するために保持する)
static lv_obj_t* g_sdStatusLbl = nullptr;
static lv_obj_t* g_sdCheckbox  = nullptr;
static lv_obj_t* g_ejectBtn    = nullptr;

static void onCheckboxChanged(lv_event_t* e) {
    lv_obj_t* cb = (lv_obj_t*)lv_event_get_target(e);
    PidEntry* entry = (PidEntry*)lv_event_get_user_data(e);
    entry->selected = lv_obj_has_state(cb, LV_STATE_CHECKED);
    savePidSelection(); // ★選択が変わるたびにRTCメモリへ保存
}

static void onStartMonitoring(lv_event_t* e);

static void buildSelectScreen() {
    screenSelect = lv_obj_create(NULL);
    lv_obj_set_style_text_font(screenSelect, &lv_font_montserrat_22, 0); // 基本フォント
    // --- ダークテーマ ---
    lv_obj_set_style_bg_color(screenSelect, lv_color_hex(0x121212), 0);
    lv_obj_set_style_text_color(screenSelect, lv_color_hex(0xFFFFFF), 0); // 子要素はこれを継承

    lv_obj_t* title = lv_label_create(screenSelect);
    lv_label_set_text(title, "Select the PID to monitor");
    lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);

    // 下部にStart/Offの2ボタンを置くため、リストの高さは控えめに
    lv_obj_t* list = lv_obj_create(screenSelect);
    lv_obj_set_style_bg_color(list, lv_color_hex(0x1A1A1A), 0);
    lv_obj_set_style_border_color(list, lv_color_hex(0x333333), 0);
    lv_obj_set_size(list, lv_pct(90), (int32_t)(g_verRes * 0.70f) - 20); // ★70%相当から20px縮小
    lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 50);
    lv_obj_set_flex_flow(list, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_style_pad_row(list, 15, 0);

    for (size_t i = 0; i < PID_COUNT; i++) {
        char label[32];
        snprintf(label, sizeof(label), "0x%02X %s", pidTable[i].pid, pidTable[i].name);

        lv_obj_t* cb = lv_checkbox_create(list);
        lv_checkbox_set_text(cb, label);
        lv_obj_set_width(cb, lv_pct(45));
        lv_obj_set_style_text_color(cb, lv_color_hex(0xFFFFFF), 0); // ダーク調:文字は白
        // チェックマーク部分は数値と揃えて大きめに(タップしやすさも兼ねる)
        lv_obj_set_style_text_font(cb, &lv_font_montserrat_38, LV_PART_INDICATOR);
        lv_obj_set_style_pad_column(cb, 12, 0);
        if (pidTable[i].selected) {
            // ★RTCメモリから復元済みの選択状態を、チェックマークにも反映
            lv_obj_add_state(cb, LV_STATE_CHECKED);
        }
        lv_obj_add_event_cb(cb, onCheckboxChanged, LV_EVENT_VALUE_CHANGED, &pidTable[i]);
        pidTable[i].checkbox = cb;
    }

    // [SDに保存] チェックボックス + SDカード状態表示
    lv_obj_t* sdRow = lv_obj_create(screenSelect);
    lv_obj_set_size(sdRow, lv_pct(90), 70); // ★list領域を20px縮小した分をこちらに割り当て
    lv_obj_align(sdRow, LV_ALIGN_BOTTOM_MID, 0, -85); // ボタン行の少し上に配置
    lv_obj_set_style_bg_opa(sdRow, LV_OPA_TRANSP, 0); // 背景は透明(親と同化)
    lv_obj_set_style_border_width(sdRow, 0, 0);
    lv_obj_clear_flag(sdRow, LV_OBJ_FLAG_SCROLLABLE); // ★縦スクロールを明示的に無効化(再発防止)
    lv_obj_set_flex_flow(sdRow, LV_FLEX_FLOW_ROW);
    lv_obj_set_flex_align(sdRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);

    lv_obj_t* sdCb = lv_checkbox_create(sdRow);
    lv_checkbox_set_text(sdCb, "Save log to SD");
    lv_obj_set_style_text_color(sdCb, lv_color_hex(0xFFFFFF), 0);
    if (saveToSdEnabled) lv_obj_add_state(sdCb, LV_STATE_CHECKED);
    if (!sdAvailable) lv_obj_add_state(sdCb, LV_STATE_DISABLED); // SD未搭載なら最初から操作不可に
    lv_obj_add_event_cb(sdCb, [](lv_event_t* e) {
        lv_obj_t* cb = (lv_obj_t*)lv_event_get_target(e);
        saveToSdEnabled = lv_obj_has_state(cb, LV_STATE_CHECKED);
    }, LV_EVENT_VALUE_CHANGED, NULL);
    g_sdCheckbox = sdCb; // ★取り外しボタンから状態を操作するため保持

    lv_obj_t* sdStatusLbl = lv_label_create(sdRow);
    lv_label_set_text(sdStatusLbl, sdAvailable ? "  (SD: OK)" : "  (SD: not found)");
    lv_obj_set_style_text_color(sdStatusLbl,
        sdAvailable ? lv_color_hex(0x00C853) : lv_color_hex(0xFF5252), 0);
    g_sdStatusLbl = sdStatusLbl; // ★取り外し完了時にrefreshTimerCbから表示更新するため保持

    // [Eject SD / Mount SD] ボタン:現在の状態(sdAvailable)に応じて
    // 「取り外し」と「(再)マウント」を切り替えるトグルボタン
    lv_obj_t* ejectBtn = lv_button_create(sdRow);
    lv_obj_set_size(ejectBtn, 120, 40);
    lv_obj_set_style_bg_color(ejectBtn, lv_color_hex(0x555555), 0);
    lv_obj_t* ejectLbl = lv_label_create(ejectBtn);
    lv_label_set_text(ejectLbl, sdAvailable ? "Eject SD" : "Mount SD");
    lv_obj_center(ejectLbl);
    lv_obj_add_event_cb(ejectBtn, [](lv_event_t* e) {
        lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
        lv_obj_t* lbl = lv_obj_get_child(btn, 0);

        if (sdAvailable) {
            // --- 現在マウント中 → 取り外しを要求 ---
            requestEjectSd(); // ★非ブロッキング。実際の処理は専用タスクが行う
            if (lbl) lv_label_set_text(lbl, "Ejecting...");
            saveToSdEnabled = false; // 取り外し中はログ保存オプションを強制的にオフ
            if (g_sdCheckbox) {
                lv_obj_clear_state(g_sdCheckbox, LV_STATE_CHECKED);
                lv_obj_add_state(g_sdCheckbox, LV_STATE_DISABLED);
            }
        } else {
            // --- 現在未マウント → (再)マウントを要求 ---
            // カードを挿し直した後、このボタンで再認識させる
            requestMountSd();
            if (lbl) lv_label_set_text(lbl, "Mounting...");
        }
        lv_obj_add_state(btn, LV_STATE_DISABLED); // 処理完了までは連打防止で無効化
    }, LV_EVENT_CLICKED, NULL);
    g_ejectBtn = ejectBtn; // ★処理完了時にrefreshTimerCbから表示更新するため保持

    // [モニター開始] ボタン(左寄せ)
    lv_obj_t* btn = lv_button_create(screenSelect);
    lv_obj_set_size(btn, 200, 65);
    lv_obj_align(btn, LV_ALIGN_BOTTOM_LEFT, 20, -10);
    lv_obj_add_event_cb(btn, onStartMonitoring, LV_EVENT_CLICKED, NULL);
    lv_obj_t* btnLbl = lv_label_create(btn);
    lv_label_set_text(btnLbl, "Start monitoring");
    lv_obj_center(btnLbl);

    // [スクリーンショット] ボタン(中央):この画面自体の見た目を保存したい場合用
    lv_obj_t* selShotBtn = lv_button_create(screenSelect);
    lv_obj_set_size(selShotBtn, 160, 65);
    lv_obj_align(selShotBtn, LV_ALIGN_BOTTOM_MID, 0, -10);
    lv_obj_set_style_bg_color(selShotBtn, lv_color_hex(0x2962FF), 0);
    lv_obj_t* selShotLbl = lv_label_create(selShotBtn);
    lv_label_set_text(selShotLbl, "Screenshot");
    lv_obj_center(selShotLbl);
    lv_obj_add_event_cb(selShotBtn, onScreenshotClicked, LV_EVENT_CLICKED, NULL);

    // [消灯] ボタン(右寄せ)
    lv_obj_t* offBtn = lv_button_create(screenSelect);
    lv_obj_set_size(offBtn, 140, 65);
    lv_obj_align(offBtn, LV_ALIGN_BOTTOM_RIGHT, -20, -10);
    lv_obj_set_style_bg_color(offBtn, lv_color_hex(0x555555), 0);
    lv_obj_t* offLbl = lv_label_create(offBtn);
    lv_label_set_text(offLbl, "Display off");
    lv_obj_center(offLbl);
    lv_obj_add_event_cb(offBtn, onDispOffClicked, LV_EVENT_CLICKED, NULL);
}

// ================================================================
// 7. GUI: 画面2 - モニター
// ================================================================
static lv_obj_t* dashGrid;
static lv_timer_t* pollTimer;
static lv_timer_t* refreshTimer;

static void onBackToSelect(lv_event_t* e) {
    if (pollTimer) { lv_timer_delete(pollTimer); pollTimer = nullptr; }
    gateway.monitoring = false;
    requestCloseLogFile(); // ★開いたままのログファイルを確実に閉じる(専用タスクへ依頼)
    lv_screen_load(screenSelect);
}

static void pollTimerCb(lv_timer_t* t) {
    // 消灯中はゲートウェイへのリクエストも止め、BLE/CAN側の負荷を下げる
    if (!isDisplayOn) return;

    // ★このタイミングで、前回のリクエストに対する応答(1周期前の値)を
    // ログへ書き出してから、次のリクエストを送る
    requestWriteLogRow();

    // 未接続なら非ブロッキングで再接続を要求するだけ(画面は固まらない)。
    // 接続処理自体は別タスク(connectTaskFunc)側で実行される。
    if (!gateway.connected) {
        requestConnect();
        return; // この周期は接続要求のみ。値の要求は繋がってから
    }

    gateway.requestSelectedPids(0x01);
}

static void refreshTimerCb(lv_timer_t* t) {
    // 消灯中は無駄な再描画を避ける
    if (!isDisplayOn) return;

    // ★スクリーンショット結果の反映(専用タスクが設定したフラグをここで拾う)
    if (g_screenshotResult != 0 && g_shotBtn) {
        bool ok = (g_screenshotResult == 1);
        g_screenshotResult = 0; // 消費済みにする

        lv_obj_t* lbl = lv_obj_get_child(g_shotBtn, 0);
        if (lbl) lv_label_set_text(lbl, ok ? "Saved!" : "Failed");
        lv_obj_set_style_bg_color(g_shotBtn, ok ? lv_color_hex(0x00C853) : lv_color_hex(0xD50000), 0);

        lv_timer_create(screenshotFeedbackTimerCb, 800, g_shotBtn); // 800ms後に元の表示へ戻す
    }

    // ★SDカード取り外し完了の反映(専用タスクが設定したフラグをここで拾う)
    if (g_ejectDone != 0) {
        g_ejectDone = 0; // 消費済みにする

        if (g_sdStatusLbl) {
            lv_label_set_text(g_sdStatusLbl, "  (SD: ejected)");
            lv_obj_set_style_text_color(g_sdStatusLbl, lv_color_hex(0x9E9E9E), 0);
        }
        if (g_ejectBtn) {
            lv_obj_t* lbl = lv_obj_get_child(g_ejectBtn, 0);
            if (lbl) lv_label_set_text(lbl, "Mount SD"); // ★次はマウント操作に切り替わる
            lv_obj_clear_state(g_ejectBtn, LV_STATE_DISABLED); // 連打防止の一時無効化を解除
        }
        // g_sdCheckbox は取り外しボタン押下時点で既にDISABLED状態にしてあるので、
        // ここでは変更不要(再マウントが成功したら改めて有効化する)
    }

    // ★SDカード再マウント結果の反映(カードを挿し直して「Mount SD」を押した場合)
    if (g_mountResult != 0) {
        bool ok = (g_mountResult == 1);
        g_mountResult = 0; // 消費済みにする

        if (g_sdStatusLbl) {
            lv_label_set_text(g_sdStatusLbl, ok ? "  (SD: OK)" : "  (SD: not found)");
            lv_obj_set_style_text_color(g_sdStatusLbl,
                ok ? lv_color_hex(0x00C853) : lv_color_hex(0xFF5252), 0);
        }
        if (g_ejectBtn) {
            lv_obj_t* lbl = lv_obj_get_child(g_ejectBtn, 0);
            // 成功したら次は「Eject SD」に、失敗したらもう一度「Mount SD」を試せるように
            if (lbl) lv_label_set_text(lbl, ok ? "Eject SD" : "Mount SD");
            lv_obj_clear_state(g_ejectBtn, LV_STATE_DISABLED);
        }
        if (g_sdCheckbox) {
            if (ok) {
                lv_obj_clear_state(g_sdCheckbox, LV_STATE_DISABLED); // マウント成功時のみログ保存を再度使えるようにする
            }
            // 失敗時はDISABLEDのままにしておく(押し直しでMount SDを再試行してもらう)
        }
    }

    // 受信済みのデコード結果をタイルへ反映(LVGL操作はここ=メインループ側でのみ行う)
    uint32_t now = millis();
    for (size_t i = 0; i < PID_COUNT; i++) {
        PidEntry& e = pidTable[i];
        if (!e.selected || !e.tileValueLbl) continue;

        lv_label_set_text(e.tileValueLbl, e.valueText);

        // 直近1.5秒以内に更新があれば緑、それ以外は灰色(鮮度の視覚化)
        bool fresh = (now - e.lastUpdateMs) < 1500;
        lv_obj_set_style_text_color(e.tileValueLbl,
            fresh ? lv_color_hex(0x00C853) : lv_color_hex(0x9E9E9E), 0);
    }
}

static void buildDashScreen() {
    screenDash = lv_obj_create(NULL);
    lv_obj_set_style_text_font(screenDash, &lv_font_montserrat_22, 0); // 基本フォント
    // --- ダークテーマ ---
    lv_obj_set_style_bg_color(screenDash, lv_color_hex(0x121212), 0);
    lv_obj_set_style_text_color(screenDash, lv_color_hex(0xFFFFFF), 0); // 子要素はこれを継承

    // ボタン行の高さ・位置(隙間調整の計算にも使う)
    const int32_t BTN_TOP = 10;
    const int32_t BTN_HEIGHT = 55;
    const int32_t GRID_GAP = 8;   // ★ボタン行とグリッドの隙間をここで調整(以前は約80px相当)
    const int32_t GRID_TOP = BTN_TOP + BTN_HEIGHT + GRID_GAP;
    const int32_t GRID_BOTTOM_MARGIN = 10;

    // [戻る] ボタン
    lv_obj_t* backBtn = lv_button_create(screenDash);
    lv_obj_set_size(backBtn, 110, BTN_HEIGHT);
    lv_obj_align(backBtn, LV_ALIGN_TOP_LEFT, 10, BTN_TOP);
    lv_obj_add_event_cb(backBtn, onBackToSelect, LV_EVENT_CLICKED, NULL);
    lv_obj_t* backLbl = lv_label_create(backBtn);
    lv_label_set_text(backLbl, "Back");
    lv_obj_center(backLbl);

    // [Logging] 表示:SDへのログ保存中だけ表示する(「戻る」ボタンの右の空きスペース)
    // ★logFileOpen(タスク側が非同期に更新)ではなく、楽観的フラグを見る。
    //   画面構築時点ではタスクがまだopenを処理し終えていない可能性があるため。
    if (loggingActiveForUI) {
        lv_obj_t* logLbl = lv_label_create(screenDash);
        lv_label_set_text(logLbl, "LOGGING");
        lv_obj_set_style_text_color(logLbl, lv_color_hex(0xFF5252), 0);
        lv_obj_align(logLbl, LV_ALIGN_TOP_LEFT, 140, BTN_TOP + 18);
    }

    // [スクリーンショット] ボタン(中央)
    lv_obj_t* shotBtn = lv_button_create(screenDash);
    lv_obj_set_size(shotBtn, 160, BTN_HEIGHT);
    lv_obj_align(shotBtn, LV_ALIGN_TOP_MID, 0, BTN_TOP);
    lv_obj_set_style_bg_color(shotBtn, lv_color_hex(0x2962FF), 0);
    lv_obj_t* shotLbl = lv_label_create(shotBtn);
    lv_label_set_text(shotLbl, "Screenshot");
    lv_obj_center(shotLbl);
    lv_obj_add_event_cb(shotBtn, onScreenshotClicked, LV_EVENT_CLICKED, NULL);
    g_shotBtn = shotBtn; // ★refreshTimerCbから結果表示を反映するために保持

    // [消灯] ボタン
    lv_obj_t* offBtn = lv_button_create(screenDash);
    lv_obj_set_size(offBtn, 140, BTN_HEIGHT);
    lv_obj_align(offBtn, LV_ALIGN_TOP_RIGHT, -10, BTN_TOP);
    lv_obj_set_style_bg_color(offBtn, lv_color_hex(0x555555), 0);
    lv_obj_t* offLbl = lv_label_create(offBtn);
    lv_label_set_text(offLbl, "Display off");
    lv_obj_center(offLbl);
    lv_obj_add_event_cb(offBtn, onDispOffClicked, LV_EVENT_CLICKED, NULL);

    dashGrid = lv_obj_create(screenDash);
    lv_obj_set_style_bg_color(dashGrid, lv_color_hex(0x121212), 0); // 親と同色(境界を目立たせない)
    lv_obj_set_style_border_width(dashGrid, 0, 0);
    // ★ボタン行のすぐ下から配置し、隙間を最小限にする
    //   (以前は BOTTOM_MID + 高さ78% 指定で、上部に大きな空白ができていた)
    lv_obj_set_pos(dashGrid, 0, GRID_TOP);
    lv_obj_set_size(dashGrid, lv_pct(100), g_verRes - GRID_TOP - GRID_BOTTOM_MARGIN);
    lv_obj_set_flex_flow(dashGrid, LV_FLEX_FLOW_ROW_WRAP);
    lv_obj_set_flex_align(dashGrid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
    lv_obj_set_style_pad_gap(dashGrid, 12, 0);

    for (size_t i = 0; i < PID_COUNT; i++) {
        if (!pidTable[i].selected) continue;

        lv_obj_t* tile = lv_obj_create(dashGrid);
        lv_obj_set_size(tile, 220, 130);
        lv_obj_set_style_radius(tile, 12, 0);
        lv_obj_set_style_bg_color(tile, lv_color_hex(0x2A2A2A), 0); // ダーク調のタイル背景
        lv_obj_set_style_border_color(tile, lv_color_hex(0x3A3A3A), 0);
        lv_obj_set_style_pad_all(tile, 10, 0);

        lv_obj_t* nameLbl = lv_label_create(tile);
        char nameBuf[32];
        snprintf(nameBuf, sizeof(nameBuf), "0x%02X %s", pidTable[i].pid, pidTable[i].name);
        lv_label_set_text(nameLbl, nameBuf);
        lv_obj_set_style_text_color(nameLbl, lv_color_hex(0xCCCCCC), 0); // 名前は少し控えめな白
        lv_obj_align(nameLbl, LV_ALIGN_TOP_LEFT, 0, 0);

        lv_obj_t* valLbl = lv_label_create(tile);
        lv_label_set_text(valLbl, "--");
        lv_obj_set_style_text_color(valLbl, lv_color_hex(0xFFFFFF), 0); // 数値は白(鮮度で緑/灰に上書きされる)

        // ★EGT(0x78)やDPFStat(0x8B)のように、1つのPIDで複数の値を
        // 表示するもの(CsvColumnSpecのfieldCountが2以上)は、通常の
        // 大きなフォントだとタイル幅に収まりきらず、横スクロール
        // 表示になって読みにくい(実機スクショで確認済み)。
        // そこで、複数値のPIDだけ一回り小さいフォントにする。
        // 決め打ちのPID番号ではなく、CSVの列数(既存の仕組みを流用)で
        // 判定しているため、今後PIDが増えても自動的に対応できる。
        const CsvColumnSpec* csvSpec = findCsvSpec(pidTable[i].pid);
        bool isMultiValue = csvSpec && csvSpec->fieldCount > 1;
        lv_obj_set_style_text_font(valLbl, isMultiValue ? &lv_font_montserrat_22 : &lv_font_montserrat_38, 0);

        // ★保険:小さいフォントにしてもなお収まりきらない場合に備え、
        // 横スクロール(以前の見た目:中途半端に切れて動く)ではなく、
        // 幅を明示して「…」で省略される表示に変更する。
        lv_obj_set_width(valLbl, 200); // タイル幅220からpadding分を引いた実用幅
        lv_label_set_long_mode(valLbl, LV_LABEL_LONG_MODE_DOTS);
        lv_obj_set_style_text_align(valLbl, LV_TEXT_ALIGN_CENTER, 0);

        lv_obj_align(valLbl, LV_ALIGN_CENTER, 0, 10);

        pidTable[i].tile = tile;
        pidTable[i].tileValueLbl = valLbl;
    }
}

static void onStartMonitoring(lv_event_t* e) {
    bool anySelected = false;
    for (size_t i = 0; i < PID_COUNT; i++) {
        if (pidTable[i].selected) { anySelected = true; break; }
    }
    if (!anySelected) return; // 何も選ばれていなければ何もしない

    // ★非ブロッキングで接続を要求するだけ。画面遷移は待たずに行う。
    // 未接続の間はタイルに "--" が表示され続け、pollTimerCb が
    // 1秒ごとに再接続を要求し続けるので、後からゲートウェイの電源が
    // 入った場合も自動的に繋がって値が表示され始める。
    requestConnect();

    // ★SD保存が有効なら、モニター開始のたびに新しいログファイルを開く
    // (実際のopen処理は専用タスクが非同期で行う。ここでは要求を出すだけ)
    if (saveToSdEnabled && sdAvailable) {
        requestOpenLogFile();
        loggingActiveForUI = true; // 「LOGGING」表示用(実際のopen完了は待たない楽観的フラグ)
    } else {
        loggingActiveForUI = false;
    }

    // ★g_shotBtnが古いモニター画面のボタンを指したままだと、次の行で
    // その画面ごと破棄された瞬間に無効なポインタになってしまう。
    // PID選択画面のボタンを指している場合は消さずそのまま残す。
    if (g_shotBtn && screenDash && lv_obj_get_screen(g_shotBtn) == screenDash) {
        g_shotBtn = nullptr;
    }
    if (screenDash) lv_obj_delete(screenDash);
    buildDashScreen();
    lv_screen_load(screenDash);

    gateway.monitoring = true;
    pollTimer = lv_timer_create(pollTimerCb, 1000, NULL); // 1秒間隔で一括要求(未接続時は再接続要求)
}

// ================================================================
// 8. setup / loop
// ================================================================
void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);

    Serial.begin(115200);
    delay(500);
    Serial.printf("[1] after M5.begin: free heap=%u\n", (unsigned)ESP.getFreeHeap());

    // ★重要:画面の向き設定と解像度取得(width/height)は、必ず
    // BLE初期化(gateway.begin)より前に済ませてキャッシュしておくこと。
    // gateway.begin()の"後"にM5.Display.width()/height()を呼ぶと
    // 0が返ってくる現象が実機で確認された(ESP-Hosted経由のBLE初期化が
    // M5GFX/M5Unifiedの内部状態に何らかの影響を与えていると見られる)。
    // 独自begin()は呼ばない。M5.begin()が既にM5.Displayを初期化済み
    // (別インスタンスでbegin()するとTab5のDSIパネル初期化と競合する)
    M5.Display.setRotation(3); // 実機確認済み:横長を正面から見た正しい向き
    M5.Display.setBrightness(BRIGHTNESS_ON);
    M5.Display.setSwapBytes(true); // ★色化け対策:これが無いと意図した色と異なる色で表示される
    int32_t hor_res = M5.Display.width();
    int32_t ver_res = M5.Display.height();
    g_horRes = hor_res; // ★モニター画面のレイアウト計算で再利用するため保持
    g_verRes = ver_res;
    Serial.printf("[1b] display resolution: hor_res=%d, ver_res=%d\n", (int)hor_res, (int)ver_res);

    // ★重要:BLE初期化(gateway.begin)は、内部RAMがまだ断片化していない
    // 起動直後のうちに済ませる。LVGLの大きなDMAバッファを先に確保すると、
    // メモリの絶対量は足りていても断片化により、その後のBLE/ESP-Hosted
    // 初期化が失敗する("malloc failed" / "rc=17")ことが実機で確認された。
    gateway.begin();
    Serial.printf("[2] after gateway.begin(): free heap=%u\n", (unsigned)ESP.getFreeHeap());

    startConnectTaskIfNeeded(); // ★BLE接続専用タスクを起動

    initSdCard(); // ★SDカードのマウントを試みる(無くても続行はする)
    startSdTaskIfNeeded(); // ★SD書き込み専用タスクを起動(メインループを絶対にブロックしないため)

    lv_init();
    lv_tick_set_cb(millis); // ★必須:これが無いとタイマー/タッチ読み取りが一切動かない

    // hor_res/ver_res は上でキャッシュ済みの値をそのまま使う
    // (ここで改めて M5.Display.width()/height() を呼び直さないこと)

    // BLE初期化後の残メモリ(実機で約175KB)に収まるよう、
    // 描画バッファは10行分に抑える(40行だと確保に失敗し、画面が
    // クラッシュせず"真っ黒のまま"になる、原因特定が難しい不具合になる)
    uint32_t bufPixels = (uint32_t)hor_res * 10;
    size_t bufSizeBytes = bufPixels * sizeof(lv_color_t);
    lvBuf1 = (lv_color_t*)heap_caps_malloc(bufSizeBytes, MALLOC_CAP_DMA);
    lvBuf2 = (lv_color_t*)heap_caps_malloc(bufSizeBytes, MALLOC_CAP_DMA);
    Serial.printf("[3] after LVGL buffers (%u bytes each): free heap=%u, buf1=%p, buf2=%p\n",
        (unsigned)bufSizeBytes, (unsigned)ESP.getFreeHeap(), lvBuf1, lvBuf2);
    if (!lvBuf1 || !lvBuf2) {
        Serial.println("!!! LVGL buffer allocation FAILED !!! バッファサイズをさらに縮小してください");
    }

    lvDisp = lv_display_create(hor_res, ver_res);
    lv_display_set_flush_cb(lvDisp, lvglFlushCb);
    lv_display_set_buffers(lvDisp, lvBuf1, lvBuf2,
        bufSizeBytes, LV_DISPLAY_RENDER_MODE_PARTIAL);

    lvTouch = lv_indev_create();
    lv_indev_set_type(lvTouch, LV_INDEV_TYPE_POINTER);
    lv_indev_set_read_cb(lvTouch, lvglTouchCb);

    restorePidSelection(); // ★GUI構築前に、Preferences(NVS)からPID選択状態を復元
    buildSelectScreen();
    lv_screen_load(screenSelect);

    refreshTimer = lv_timer_create(refreshTimerCb, 200, NULL); // GUI反映は200ms間隔
}

void loop() {
    M5.update();
    lv_timer_handler();
    delay(5);
}

次回は、GPSモジュールと連携して、ログ情報に『経度・緯度・時刻』を追加します。および、UIの細かな改善も。


M5Tab5関連記事


(5)へ続く

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?