0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

前回の記事で、リモート側を M5Stack Tab5(以下、M5Tab5)を使い GUIを実装しました。

今回は、GPS連携と GUIの改善 を行います。

1. GPS連携

↑こちらの記事で使用した GPSモジュールを使用しました。
GPSから、時刻・経度・緯度情報を取得して、ログに追加します。いつ・どこ を走行中のデータなのかが明確となり、ロガーとして完結します。

もう一点、初めてGPSから 時刻情報が取得できたときに、M5Tab5 の RTCに設定します。これにより、(正しい)システム時刻がいつでも取得可能となります。
(前回記事でSDカードにファイルを作成しましたが、ファイル日付が 2050年になっていました。( ´ºωº` )ナンデー)

  • 接続は次の通り

    GPS
    モジュール
    M5Tab5
    (M-BUS)
    5V Ext 5V
    GND GND
    RXD GPIO48
    TXD GPIO35
    1PPS GPIO4

2. GUIの改善

次のとおり追加・変更しました。

2.1 画面共通

  1. IMUを利用し M5Tab5を回転させると、画面表示も回転するようにしました。ただし、Landscapeの2方向のみ。
  2. GPSの 1PPS 信号をトリガーに、GPSから取得した「経度・緯度、日付時刻」を表示します(「PID選択画面」は「日付時刻」のみ)。GPS未接続、または、$GNRMCセンテンス未受信の場合は"GPS: -"を表示

2.2 PID選択画面

gamen-select-1.png

  1. モニター表示順を変更可能としました

    • PIDリストを1列構成に変更し、項目右側の $≡$ をつまんで位置を上下させることで表示順を入れ替えます。右側のボタンは次の機能
      • Undo:直前の移動を取り消す
      • Reset:PIDコード順(初期値)に戻す
      • Save:現在の並びを保存する。「Start Monitoring」でも自動で保存する
      • Recover:最後に「保存」した並びに戻す

    ただし。ログの出力項目の並びには影響しません(ログはPID順で固定)。

  2. 全選択、全解除のボタンを追加しました

    • Select All:すべてのPIDを選択(チェックオン)します
    • Deselect All:すべてのPIDを未選択(チェックオフ)にします

  3. ロギング状態で「モニター画面」から「PID選択画面」に戻った場合も、ロギングを継続するように変更しました(画面左上「LOGGING」点滅)。ロギングを停止するには「Eject SD」するか、「Save log to SD」をオフにします
    shot_0062.jpeg

  4. ロギング状態で、PIDの選択状態を変更して「Start Monitoring」した場合は(ヘッダー行が変更になるため)、現ログファイルをクローズして、新たなログファイルに出力します

2.3 モニター画面

gamen-monitor-1.png

  1. 「PID選択画面」で設定した並び順で表示します
  2. ロギング状態で「Back」した場合に ロギングを継続する変更は、前に述べた通り

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

前回からさらに26回のやり取りを重ねて、今回のコードに仕上がりました。

tab5_odb_monitor_2.ino
/*
  ================================================================
  M5Stack Tab5 用 OBD2ゲートウェイ・モニター (GUI版) v2
  ================================================================
  役割:これまで 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()内でこの関数を呼べば解消できる。
    15. 【GPS連携】秋月GYSFDMAXB GPS受信機キットをUART(NMEA)で接続。
       RXD=GPIO48, TXD=GPIO35, 1PPS=GPIO4。電源はTab5のM-BUS "Ext5V"
       から供給するため、setup()の最初期に M5.Power.setExtOutput(true)
       を呼ぶ必要がある(呼ばないとGPSモジュールに電源が入らない)。
       解析は外部ライブラリを追加せず、$GNRMC(推奨最小限測位情報)
       センテンスのみを自前でパースする方式とした(本プロジェクトの
       依存を増やさない方針、かつRMC1文で緯度・経度・UTC時刻・日付・
       有効フラグが揃うため)。UART読み取り・NMEA解析はBLE/SDと同様に
       専用タスク(gpsTaskFunc)で行い、メインループへはミューテックス
       (gpsMutex)で保護した共有構造体(g_gpsFix)経由で値を渡す。
       時刻はGPSが出力するUTCから+9時間してJST(日本標準時)に変換し、
       表示・CSVログの両方をJSTで統一している(parseRmcLocked()内で
       変換・日付繰り上げ処理を行う)。1PPSは測位ロジックには
       使わず、「GPSモジュールが生きて秒パルスを出せているか」を示す
       簡易インジケータとしてのみ利用している。
       GPS未取得時、画面表示・CSVログともに "-" を表示する
       (CSVの他の欠損値は空欄が慣例だが、GPS列は要件により明示的に
       "-" とした)。
    16. 【モニター画面の「Back」=ロギング停止ではない】当初はBackで
       ロギング(pollTimer・接続・ログファイル)を全停止していたが、
       「PID選択画面を一時的に見に戻るだけでロギングは継続したい」
       という要望に合わせて変更した。
       - onBackToSelect()は画面遷移のみ行い、pollTimer・
         gateway.monitoring・ログファイルのopen状態はそのまま維持する
       - ただし、ロギング中はCSVヘッダー(列構成)が固定されている必要が
         あるため、「裏でロギング中にPID選択を変更→即座に列を変える」
         ことはできない。そこでPID選択の変更は、次にonStartMonitoring()
         (「Start Monitoring」ボタン)が押された瞬間にのみ反映する設計に
         した。具体的には、現在開いているログファイルのPID構成
         (g_loggedPidMask、open時点のスナップショット)と、押された時点
         でのPID選択(computeCurrentPidMask())を比較し、
         - 変化なし: そのまま同じファイルへ書き込み継続(切り替えない)
         - 変化あり: CLOSE_LOGの直後にOPEN_LOGをキューへ積む(sdQueueは
           FIFOなので、閉じてから開く順序が保証される)ことで、新しい
           列構成の別ファイルへ自動的に切り替える
       - 「Save to SD」チェックは、PID選択とは違い即時反映にしている。
         チェックを外した瞬間、開いているログファイルがあれば
         requestCloseLogFile()で即座に閉じる(onCheckboxChanged相当の
         イベントハンドラ内で行う。Start Monitoringまで待たない)。
         これはPID選択画面の「LOGGING」点滅表示と一致させるため
         (チェックを外したのに点滅が消えない、という違和感の解消)。
         onStartMonitoring()側にも同じ判定(saveToSdEnabledが無効なら
         閉じる)を保険として残してあるが、通常はチェック変更時点で
         既に閉じられているため実質的にno-opになる。
       - pollTimerもBack後は動き続けているため、onStartMonitoring()側で
         「まだ存在しない場合だけ作成する」ようにし、二重生成(=二重
         リクエスト・CSV二重行)を防いでいる
       - ロギングを完全に終了させたい場合(1回のドライブの終わりなど)
         は、PID選択画面の「Eject SD」ボタンを押せばよい。
         doUnmountSdCardInternal()は、開いたままのログファイルがあれば
         アンマウント前に必ず閉じる設計に既になっているため、そのまま
         安全な終了ボタンとして機能する。
    17. 【PID表示順のドラッグ&ドロップ並べ替え】PID選択画面の各行右端の
       「≡」ハンドルだけを掴んでドラッグすると、行の表示順を並べ替え
       られる(行全体やチェックボックス部分をドラッグしても反応しない。
       誤操作防止のため意図的にハンドル限定にしている)。
       - g_pidOrder[](表示位置→pidTableインデックスの対応表)を、
         PID選択画面のリスト・モニター画面のタイルグリッドの両方が
         参照することで、「選択画面での並び替え」がそのまま「モニター
         画面のタイル配置」にも反映される
       - ドラッグ処理はフローティング表示なしの簡易方式:指のY座標が
         どの行の中間点を超えたかを見て、掴んでいる行をlv_obj_move_to_
         index()でその位置へ即座に差し替える(onPidRowDragEvent)。
         掴んでいる行は背景色を変えてハイライトし、指がリスト上端/下端
         付近(40px以内)に近づくとPRESSING毎に少しずつ自動スクロールする
       - ドラッグ中はlistの縦スクロールと競合しないよう、一時的に
         list自身のスクロールを無効化し、指を離したら元に戻す
       - 【視認性】ドラッグして指を離した直後、実際に並びが変わった場合は
         その行のハイライトを即座に消さず、約1秒間そのまま残してから
         自動的に消す(highlightRowTemporarily)。Undoボタンでも同様に、
         「このUndoで動いた項目」を約1秒ハイライトし、かつlv_obj_scroll_
         to_view()でその項目が必ずリスト表示内に入るようスクロールする
         (Undoでリストが先頭に戻ってしまい、どれが動いたか分からなく
         なる問題への対応)。「どのPIDが動いたか」は、履歴スタックに
         並び自体だけでなく、その段でドラッグされていたpidTableインデックス
         (g_pidOrderHistoryMovedIdx)も一緒に記録しておくことで特定して
         いる(並びの差分比較だけでは、連動して位置がずれた他の項目と
         区別できないため)。ハイライト用タイマー(g_highlightTimer)は
         常に1本だけを使い回し、対象行がタイマー発火前に(別の操作で
         リストが作り直されて)破棄された場合はLV_EVENT_DELETEで検知して
         安全にタイマーを止める(ダングリングポインタ対策)。
       - リスト右側に並べ替え専用ボタンを縦に6つ配置:
         Undo(履歴スタックを1段ポップして遡る。複数回押せば複数段
         遡れる。上限PID_ORDER_UNDO_MAX(=20)段。1段あたりPID_COUNT(17)
         バイトなので上限を大きくしてもメモリ的には無視できるサイズだが、
         Save/Reset/Recoverでスタックごとクリアされる前提なら20段でも
         十分すぎるはず) / Reset(PID値の昇順に並べ替える。pidTableの
         宣言順とは必ずしも一致しない点に注意) / Save(現在のg_pidOrder
         をNVSへ確定保存) / Recover(NVSに保存済みの内容を読み直し、まだ
         Saveしていない変更を破棄する) / Select All / Deselect All
       - 【重要・Undo履歴のクリア(チェックポイント)】Save・Reset・
         Recover・(Saveし忘れてStart Monitoringした場合の自動保存)は、
         いずれも「これより前には戻れない区切り」として、実行時に
         g_pidOrderHistoryCountを0にリセットする。例えばResetを押すと、
         Reset前の状態はもうUndoできなくなる(Recoverについても同様。
         「破棄」した変更をUndoで復活できてしまうと矛盾するため)。
         ドラッグによる並べ替えだけがUndo履歴に段を積んでいく
         (pushPidOrderHistory)。
       - 【重要・保存タイミング】ドラッグ確定・Undo・Resetは、いずれも
         g_pidOrderをメモリ上でのみ変更し、NVSへは書き込まない。
         「Save」ボタンを押した時だけsavePidOrder()でNVSへ確定保存する。
         これにより「Recover」が「まだ保存していない変更を、最後に保存
         された状態まで一括で取り消す」という実用的な意味を持つ
         (もし毎回即時保存する設計だと、常に現在値=保存値になるため
         Recoverは無意味になってしまう)。この設計の代償として、並べ替え
         た後にSaveを押さないまま電源を切ると、その変更は失われる
         (選択状態selmapの方は従来通りチェック変更のたびに即時保存で、
         この節の影響を受けない)。
         なお、Saveを押し忘れたままモニターを開始しても変更が消えない
         よう、onStartMonitoring()の冒頭でもg_pidOrderDirtyを見て未保存
         なら自動的にsavePidOrder()する(Startを押した時点で「確定」と
         みなす、という設計)。電源断のリスクが残るのは、並べ替えた後に
         Save/Start Monitoringのどちらも押さずに電源を切った場合のみ。
       - 並び順はselmapと同じNVS("pidcfg"名前空間)に、生インデックスで
         はなく「表示順でのPID値」で保存する(savePidOrder/
         restorePidOrder)。PID一覧が将来増減しても、保存済み順序と
         現在のpidTableが1対1で対応しない場合は自動的にデフォルト
         (宣言順)へフォールバックする
       - 【重要】CSVログの列順・ヘッダー順はこの表示順の影響を受けない
         (doOpenLogFileInternal/requestWriteLogRowは、以前と変わらず
         pidTableの宣言順で列を出力する)。そのため、表示順だけを
         並べ替えてもロギング中のファイルを閉じ直す必要はない
         (PID選択(チェックのON/OFF)を変えた場合のファイル切り替え
         判定(16.)とは無関係な別の話)。
    18. 【重要・実車テストで発見された不具合と修正】requestWriteLogRow()が、
       CSVヘッダーを書いた時点のPID選択(g_loggedPidMask、file-open時の
       スナップショット)ではなく、その時点でライブの
       pidTable[i].selectedを見て列数を決めていた。16.の設計により
       Backを押してもロギングが止まらないため、ログファイルを開いた
       ままPID選択(チェックのON/OFF・Select All/Deselect All)を変更する
       と、その瞬間から出力列数がヘッダーとズレていき、実車テストで
       実際に「1行目からCSVの列数が4→5→…→24と徐々に増えていく」壊れた
       CSVが生成される不具合が確認された。requestWriteLogRow()の
       ループ条件を pidTable[i].selected から
       (g_loggedPidMask & (1ULL << i)) に修正済み。doOpenLogFileInternal
       (ヘッダー生成)側は元々、生成直後にg_loggedPidMask =
       computeCurrentPidMask()を同期的に呼んでいるため、ヘッダーと
       スナップショットは常に一致しており修正不要だった。
    19. 【GPS時刻→RTC自動設定】GPSから初めて有効な時刻($GNRMC/$GPRMCの
       ステータス'A')を取得できたタイミングで、その時刻(JST変換済み)を
       Tab5本体のRTCへ一度だけ設定する(trySyncRtcFromGps())。以降は
       GPS側の時刻が更新されてもRTCへは書き戻さない。GPS読み取り
       タスク(gpsTaskFunc)からは直接RTC(I2C)を叩かず、既存のGpsFix/
       gpsMutexの仕組みでJSTの年月日時分秒を数値のまま保持しておき、
       メインループ側(imuRtcTimerCb、150ms周期)から読み出して
       M5.Rtc.setDate()/setTime()を呼ぶ設計にしている(タッチ(GT911)
       やIMU等、他のI2Cデバイスと同じバスへの多重アクセスを避けるため)。
    20. 【IMUによる画面自動回転】本体の回転をIMU(M5.Imu)の加速度センサー
       (重力成分)で検知し、画面をLandscapeの2方向のみで自動的に切り替える
       (updateDisplayRotationFromImu())。通常向きはM5.Display.
       setRotation(3)(実機確認済みの正面向き。冒頭4番参照)、そこから
       本体が約180度回転したらsetRotation(1)にする。Portrait相当の
       回転(0, 2)は使わない。加速度がIMU_ROTATION_THRESHOLD_G(0.6G)を
       超えた向きが、IMU_ROTATION_DEBOUNCE_COUNT(5)回連続で検出できて
       から実際に切り替えることで、持ち替え途中の中途半端な傾きで
       パタパタ切り替わる(チャタリング)のを防いでいる。実機でのIMU軸の
       符号は基板実装により変わり得るため、回転方向が逆な場合は
       IMU_ROTATION_AXIS_SIGNを-1に反転すること。この判定処理も
       imuRtcTimerCb(150ms周期)から呼んでいる。
       - 【実機で判明・起動直後のチャタリング】起動直後の約1.5秒間
         (IMU_ROTATION_STARTUP_GRACE_MS)はIMUのオフセットがまだ安定
         しておらずノイズが大きいため、実機ログでsetRotation(1)/(3)が
         数回連続で切り替わる現象が確認された。対策として、起動直後の
         この期間は判定自体を行わないようにした。
       - 【実機で判明・回転してもLVGL画面が変わらない】M5.Display.
         setRotation()はM5GFX内部の座標変換を切り替えるだけで、既に
         描画済みのピクセルを再送するわけではない。LVGLは「ウィジェット
         の変化で無効化(invalidate)された領域」だけをlvglFlushCb()経由
         で物理パネルへ再送する仕組みのため、setRotation()を呼んだ
         だけではLVGL側は何も変わったと認識せず、次にどこかのラベル
         が更新されるまで画面が古い向きのまま変化しない不具合が実機で
         確認された。そのため、切り替え直後にlv_obj_invalidate
         (lv_screen_active())で現在のアクティブ画面全体を明示的に
         無効化し、次のlv_timer_handler()での全面再描画に任せている。
       - 【実車テストで一時検討し撤去した対策】実車テストの
         スクリーンショットで0x10 MAFの値表示が一時的に乱れて写った
         ことがあり、当初はPID値ラベルの数値更新(refreshTimerCb)と
         IMUによる回転確定(imuRtcTimerCb)のタイミング競合を疑い、
         setRotation()の前後でlv_refr_now(NULL)を挟んで同期的に
         強制フラッシュする対策を入れたことがあった。しかし実際には
         スクリーンショット撮影と値更新のタイミングが重なっただけの
         見た目上の現象で、表示・動作には実害がないことを確認した
         ため撤去済み。lv_refr_now()はメインループの他の処理(OBD
         ポーリング、GPS/BLE処理等)をブロックしうるため、実害のない
         事象への対策としては使わないこと。
    ================================================================
*/

#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 <HardwareSerial.h> // GPS(GYSFDMAXB)とのUART通信用
#include <freertos/queue.h>
#include <freertos/semphr.h> // GPS共有データ保護用ミューテックス
#include <BLEClient.h>
#include <BLEAdvertisedDevice.h>

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

// 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は何列で、それぞれのヘッダー名(単位付き)は何か」を定義しておく。
// ヘッダー名は "名前[単位]" 形式(度記号はCSV/Excelでの文字化けを避けるため
// 半角の"C"を使う)。
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-2c. PID選択画面の並び順(ドラッグ&ドロップで変更可能)
// ================================================================
// g_pidOrder[表示位置] = pidTableのインデックス、という対応表。
// 初期値は宣言順(identity)。PID選択画面のリスト構築(buildSelectScreen)・
// モニター画面のタイル構築(buildDashScreen)の両方で、この順序に沿って
// UIを生成することで、「選択画面での並び替え」が「モニター画面のタイル
// 配置」にもそのまま反映される。
static uint8_t g_pidOrder[PID_COUNT];

// selmapと同じ理由(pidTableの並び順や個数が将来変わってもズレて誤読
// しないよう)で、保存はテーブルの生インデックスではなく「表示順での
// PID値の並び」で行い、復元時はPID値で突き合わせる。
static void savePidOrder() {
    uint8_t pidValues[PID_COUNT];
    for (size_t idx = 0; idx < PID_COUNT; idx++) {
        pidValues[idx] = pidTable[g_pidOrder[idx]].pid;
    }
    pidPrefs.begin("pidcfg", false);
    pidPrefs.putBytes("ordmap", pidValues, sizeof(pidValues));
    pidPrefs.end();
}

static void restorePidOrder() {
    for (size_t idx = 0; idx < PID_COUNT; idx++) g_pidOrder[idx] = (uint8_t)idx; // デフォルトは宣言順

    pidPrefs.begin("pidcfg", true);
    size_t storedLen = pidPrefs.getBytesLength("ordmap");

    if (storedLen == sizeof(uint8_t) * PID_COUNT) {
        uint8_t pidValues[PID_COUNT];
        pidPrefs.getBytes("ordmap", pidValues, sizeof(pidValues));

        // ★保存されていたPID値が、現在のpidTableと過不足なく1対1で
        // 対応する場合のみ採用する。PIDが追加/削除されていて対応が
        // 取れない場合は、上で設定済みのdefault(宣言順)のままにする。
        bool seen[PID_COUNT] = { false };
        uint8_t newOrder[PID_COUNT];
        bool ok = true;
        for (size_t idx = 0; idx < PID_COUNT && ok; idx++) {
            PidEntry* e = findPid(pidValues[idx]);
            if (!e) { ok = false; break; }
            size_t tableIdx = (size_t)(e - pidTable); // ポインタ差分でpidTable内インデックスを復元
            if (tableIdx >= PID_COUNT || seen[tableIdx]) { ok = false; break; }
            seen[tableIdx] = true;
            newOrder[idx] = (uint8_t)tableIdx;
        }
        if (ok) {
            for (size_t idx = 0; idx < PID_COUNT; idx++) g_pidOrder[idx] = newOrder[idx];
        }
    }

    pidPrefs.end();
}

// ================================================================
// 2-2b. GPSモジュール連携(秋月 GYSFDMAXB使用 GPS受信機キット)
// ================================================================
// ★この節は、後段のSDカード機能(2-3)内 requestWriteLogRow() が
// GpsFix/getGpsSnapshot()を参照するため、必ずそれより前で定義する。
//
// 接続:UARTによるNMEAセンテンス受信(9600bps, 8N1)
//   Tab5 RXD2 <- GPS TXD : GPIO48
//   Tab5 TXD2 -> GPS RXD : GPIO35
//   1PPS (GPSから毎秒立ち上がるパルス)         : GPIO4
//   ※1PPSは正式な時刻同期には使わず、「GPSモジュールが生きていて
//     秒パルスを出せているか」を示す簡易インジケータとしてのみ利用する。
//
// 【重要・電源】GPSモジュールへの電源はTab5のM-BUS "Ext5V" から
// 供給する。デフォルトはOFFのため、setup()の早い段階で
// M5.Power.setExtOutput(true) を呼ばないとGPSモジュールに電源が
// 入らない(GPSはコールドスタート時に測位まで時間がかかるため、
// できるだけ早いタイミングで電源投入しておく)。
//
// 【解析方針】外部ライブラリ(TinyGPS++等)は追加せず、本プロジェクトの
// 他機能(BLE/SD)と同様に依存を増やさない方針に合わせ、
// $GNRMC(推奨最小限測位情報)センテンスのみを自前でパースする。
// RMC1文に緯度・経度・UTC時刻・日付・有効/無効フラグがまとまっており、
// 今回の要件(緯度経度+時刻の表示・ログ)には過不足ない。
//
// 【時刻】GPSが出力する時刻はUTC。parseRmcLocked()内で+9時間し、
// JST(日本標準時)に変換した上でg_gpsFix.timeStrへ格納する
// (日付をまたぐ場合の繰り上げも考慮済み。daysInMonth()参照)。
//
// 【スレッド設計】UART読み取り・NMEA解析は専用タスク(gpsTaskFunc)で
// 行う。BLE(connectTaskFunc)やSD(sdTaskFunc)と同じく、メインループ
// (LVGLタイマー)を絶対にブロックしないための設計方針を踏襲している。
// タスク→メインループ間の受け渡しは、緯度経度+時刻を複数フィールド
// まとめて渡す必要があるため、SDの単純なvolatileフラグ方式ではなく、
// ミューテックス(gpsMutex)で保護した共有構造体(g_gpsFix)を使う。
#define GPS_RXD_PIN 48 // Tab5からみて受信(GPSのTXDを受ける)
#define GPS_TXD_PIN 35 // Tab5からみて送信(GPSのRXDへ送る)
#define GPS_PPS_PIN 4  // 1PPS入力

static HardwareSerial GpsSerial(2); // ESP32-P4のUARTポート2を使用

struct GpsFix {
    bool     posValid;    // 緯度経度が一度でも取得できたか
    bool     timeValid;   // 日付時刻が一度でも取得できたか
    double   latitude;    // 度(10進)。北緯+、南緯-
    double   longitude;   // 度(10進)。東経+、西経-
    char     latStr[16];  // 表示・ログ用文字列(未取得時は"-")
    char     lonStr[16];
    char     timeStr[24]; // "YYYY-MM-DD HH:MM:SS"(JST)。未取得時は"-"
    uint32_t lastFixMs;   // 直近有効データ受信時刻(鮮度判定用)

    // ★RTC同期用(trySyncRtcFromGps()参照)。timeStrと同じJST値だが、
    // RTCライブラリへそのまま渡せるよう数値型でも保持しておく
    uint16_t rtcYear;
    uint8_t  rtcMonth;
    uint8_t  rtcDay;
    uint8_t  rtcHour;
    uint8_t  rtcMinute;
    uint8_t  rtcSecond;
};

// ★初期値は全て「未取得」状態("-")にしておく
static GpsFix g_gpsFix = { false, false, 0.0, 0.0, "-", "-", "-", 0, 0, 0, 0, 0, 0, 0 };
static SemaphoreHandle_t gpsMutex = nullptr;

// 1PPSパルスの受信時刻(簡易インジケータ用。ISRからのみ書き込むため
// volatileで十分。厳密な保護は不要な単純カウンタ的用途のため)
static volatile uint32_t g_lastPpsMs = 0;

static void IRAM_ATTR onPpsPulse() {
    g_lastPpsMs = millis();
}

// NMEAの ddmm.mmmm(緯度)/ dddmm.mmmm(経度)形式を10進度に変換する
static double nmeaCoordToDecimal(double raw, char hemisphere) {
    int deg = (int)(raw / 100.0);
    double minutes = raw - (deg * 100.0);
    double decimal = deg + (minutes / 60.0);
    if (hemisphere == 'S' || hemisphere == 'W') decimal = -decimal;
    return decimal;
}

// カンマ区切りのNMEAセンテンスから、index番目(0始まり、先頭の"$GNRMC"を0とする)
// のフィールドを取り出す。フィールドが空欄(連続するカンマ)の場合はout[0]='\0'を返す。
static bool nmeaField(const char* sentence, int index, char* out, size_t outLen) {
    const char* p = sentence;
    for (int cur = 0; cur < index; cur++) {
        p = strchr(p, ',');
        if (!p) return false;
        p++;
    }
    const char* end = strchr(p, ',');
    if (!end) end = strchr(p, '*'); // チェックサム区切り(末尾フィールド用)
    if (!end) end = p + strlen(p);
    size_t len = (size_t)(end - p);
    if (len >= outLen) len = outLen - 1;
    memcpy(out, p, len);
    out[len] = '\0';
    return true;
}

// $GNRMC / $GPRMC を解析し、有効な測位であればg_gpsFixを更新する。
// ★呼び出し側(gpsTaskFunc)がgpsMutexを取得済みの状態で呼ぶこと。

// 指定した年月(1-12)の日数を返す(閏年を考慮)。JST変換時の日付繰り上げ用。
static int daysInMonth(int year, int month) {
    static const int dim[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    if (month == 2) {
        bool leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
        return leap ? 29 : 28;
    }
    if (month < 1 || month > 12) return 31; // 想定外値の保険
    return dim[month - 1];
}

static void parseRmcLocked(const char* line) {
    char status[4] = "";
    if (!nmeaField(line, 2, status, sizeof(status))) return;
    if (status[0] != 'A') return; // 'V'=Void(無効)。'A'=Active(有効)以外は無視する

    char timeF[16] = "", latF[16] = "", nsF[4] = "", lonF[16] = "", ewF[4] = "", dateF[16] = "";
    if (!nmeaField(line, 1, timeF, sizeof(timeF))) return;
    if (!nmeaField(line, 3, latF,  sizeof(latF)))  return;
    if (!nmeaField(line, 4, nsF,   sizeof(nsF)))   return;
    if (!nmeaField(line, 5, lonF,  sizeof(lonF)))  return;
    if (!nmeaField(line, 6, ewF,   sizeof(ewF)))   return;
    if (!nmeaField(line, 9, dateF, sizeof(dateF))) return;
    if (timeF[0] == '\0' || latF[0] == '\0' || lonF[0] == '\0' || dateF[0] == '\0') return;

    g_gpsFix.latitude  = nmeaCoordToDecimal(atof(latF), nsF[0]);
    g_gpsFix.longitude = nmeaCoordToDecimal(atof(lonF), ewF[0]);
    snprintf(g_gpsFix.latStr, sizeof(g_gpsFix.latStr), "%.6f", g_gpsFix.latitude);
    snprintf(g_gpsFix.lonStr, sizeof(g_gpsFix.lonStr), "%.6f", g_gpsFix.longitude);
    g_gpsFix.posValid = true;

    // 時刻: "hhmmss.ss"の先頭6桁 + 日付: "ddmmyy"の6桁 → JST(UTC+9)に変換して
    // "YYYY-MM-DD HH:MM:SS" を組み立てる。GPSが出力する時刻・日付はUTCのため、
    // 時に9を加算し、24時を超えた場合は日付を1日繰り上げる
    // (+9時間なので繰り上がりは最大1日で足りる)。
    if (strlen(timeF) >= 6 && strlen(dateF) >= 6) {
        char hh[3] = { timeF[0], timeF[1], '\0' };
        char mi[3] = { timeF[2], timeF[3], '\0' };
        char ss[3] = { timeF[4], timeF[5], '\0' };
        char dd[3] = { dateF[0], dateF[1], '\0' };
        char mo[3] = { dateF[2], dateF[3], '\0' };
        char yy[3] = { dateF[4], dateF[5], '\0' };

        int year  = 2000 + atoi(yy); // NMEAの年は下2桁のみ(2000年代を想定)
        int month = atoi(mo);
        int day   = atoi(dd);
        int hour  = atoi(hh);

        hour += 9; // UTC -> JST
        if (hour >= 24) {
            hour -= 24;
            day += 1;
            if (day > daysInMonth(year, month)) {
                day = 1;
                month += 1;
                if (month > 12) {
                    month = 1;
                    year += 1;
                }
            }
        }

        snprintf(g_gpsFix.timeStr, sizeof(g_gpsFix.timeStr), "%04d-%02d-%02d %02d:%s:%s",
            year, month, day, hour, mi, ss);
        g_gpsFix.timeValid = true;

        // ★RTC同期用(trySyncRtcFromGps()がメインループ側から参照する)。
        // 上でtimeStrに焼き込んだのと同じJST値を数値でも保持しておく
        g_gpsFix.rtcYear   = (uint16_t)year;
        g_gpsFix.rtcMonth  = (uint8_t)month;
        g_gpsFix.rtcDay    = (uint8_t)day;
        g_gpsFix.rtcHour   = (uint8_t)hour;
        g_gpsFix.rtcMinute = (uint8_t)atoi(mi);
        g_gpsFix.rtcSecond = (uint8_t)atoi(ss);
    }

    g_gpsFix.lastFixMs = millis();
}

// ★デバッグ用:UART経由でNMEAデータが実際に届いているかを
// シリアルモニタで確認できるようにする。1PPS(GPIO4)はGPSモジュールが
// 3D-Fixしているかどうかだけを示す信号で、UART配線やボーレードが
// 正しいかどうかとは無関係のため、切り分けにはこのデバッグ出力が必要。
//
// 【重要・実機で確認された不具合】シリアルモニタを閉じている間、
// gpsTaskFunc内でSerial出力を続けると、送信バッファが埋まって
// 書き込みがブロックし、GPS読み取りタスクごと止まってしまう現象が
// 確認された。if (Serial) によるガードを試みたが改善しなかった
// (Tab5は恐らくネイティブUSB CDCではなくUSB-UARTブリッジ経由の
// 接続であるため、if(Serial)は「初期化済みか」を返すだけで「ホストが
// 実際にポートを開いて読んでいるか」を反映しない)。
// そのため、平常運用時はデフォルトで無効(0)にしておくこと。配線や
// パース処理を調べたい時だけ一時的に1にしてシリアルモニタを常時
// 開いた状態でビルド・書き込みすること。
#define GPS_DEBUG_SERIAL 0

// GPS読み取り専用タスク:UARTから1バイトずつ読み、行(\r\nまたは\nまで)が
// 完成するたびに$GNRMC/$GPRMCかどうかを判定してパースする。
static void gpsTaskFunc(void* pvParameters) {
    static char lineBuf[96];
    size_t linePos = 0;
#if GPS_DEBUG_SERIAL
    uint32_t rxByteTotal = 0;
    uint32_t lastWatchdogMs = millis();
#endif

    for (;;) {
        while (GpsSerial.available()) {
            char c = (char)GpsSerial.read();
#if GPS_DEBUG_SERIAL
            rxByteTotal++;
#endif
            if (c == '\n' || c == '\r') {
                if (linePos > 0) {
                    lineBuf[linePos] = '\0';
#if GPS_DEBUG_SERIAL
                    // ★UARTで受信できた行を、種類を問わず全てそのままシリアルへ出す。
                    // これが1行も出ない場合は、パース処理以前の問題(配線・ボーレート・
                    // GPIO35/48の結線ミスなど)がほぼ確定する。
                    // ★if(Serial)ガード必須:ホスト未接続時のブロッキング防止(上記コメント参照)
                    if (Serial) Serial.printf("[GPS RX] %s\n", lineBuf);
#endif
                    if (strncmp(lineBuf, "$GNRMC", 6) == 0 || strncmp(lineBuf, "$GPRMC", 6) == 0) {
#if GPS_DEBUG_SERIAL
                        // ステータスフィールド('A'=有効/'V'=無効)を確認用に表示
                        if (Serial) {
                            char dbgStatus[4] = "?";
                            nmeaField(lineBuf, 2, dbgStatus, sizeof(dbgStatus));
                            Serial.printf("[GPS RX] RMC detected, status=%s (A=valid, V=void)\n", dbgStatus);
                        }
#endif
                        if (xSemaphoreTake(gpsMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
                            parseRmcLocked(lineBuf);
                            xSemaphoreGive(gpsMutex);
                        }
                    }
                    linePos = 0;
                }
            } else if (linePos < sizeof(lineBuf) - 1) {
                lineBuf[linePos++] = c;
            } else {
                linePos = 0; // 想定外に長い行(文字化け等)は破棄してリセット
            }
        }
#if GPS_DEBUG_SERIAL
        // ★5秒間、UARTから1バイトも受信できていない場合は警告を出す。
        // これが繰り返し表示される場合、配線(GPS TXD->GPIO48, GPS RXD<-GPIO35が
        // クロス接続になっているか)、ボーレート(9600bps固定のはず)、
        // GpsSerial.begin()のピン指定を確認すること。
        uint32_t nowMs = millis();
        if (nowMs - lastWatchdogMs >= 5000) {
            if (Serial) {
                if (rxByteTotal == 0) {
                    Serial.println("[GPS] WARNING: no UART bytes received in the last 5s. "
                                    "配線(TXD/RXDのクロス接続)・ボーレート・GPIOピン設定を確認してください。");
                } else {
                    Serial.printf("[GPS] %u bytes received in the last 5s\n", (unsigned)rxByteTotal);
                }
            }
            rxByteTotal = 0;
            lastWatchdogMs = nowMs;
        }
#endif
        vTaskDelay(pdMS_TO_TICKS(20)); // 取りこぼしが起きない程度の頻度でポーリング
    }
}

// GPSモジュールへの電源投入・UART/1PPS初期化。
// ★M5.Power.setExtOutput(true)は、setup()のできるだけ早い段階
// (M5.begin()の直後)で呼ぶこと。GPSはコールドスタート時に測位まで
// 数十秒かかることがあるため、電源投入は早いほど良い。
static void initGpsModule() {
    M5.Power.setExtOutput(true); // ★M-BUS "Ext5V" をON(GPSモジュールへ給電)
    GpsSerial.begin(9600, SERIAL_8N1, GPS_RXD_PIN, GPS_TXD_PIN);

    pinMode(GPS_PPS_PIN, INPUT);
    attachInterrupt(digitalPinToInterrupt(GPS_PPS_PIN), onPpsPulse, RISING);
}

static void startGpsTaskIfNeeded() {
    if (!gpsMutex) gpsMutex = xSemaphoreCreateMutex();
    xTaskCreate(gpsTaskFunc, "GPS_Task", 4096, NULL, 1, NULL);
}

// メインループ(GUIスレッド)側から、GPSの最新値を安全にコピーして取得する。
// ★ミューテックス取得のタイムアウトは短め(5ms)にしてある。GPS側の
// ロック保持は数マイクロ秒程度のごく短時間のため、通常は即座に取得
// できる。万一取れなくてもUIが一瞬古い値のまま表示される程度で実害は
// ないため、メインループ(LVGL)を長時間ブロックしないことを優先する。
static void getGpsSnapshot(GpsFix* out) {
    if (gpsMutex && xSemaphoreTake(gpsMutex, pdMS_TO_TICKS(5)) == pdTRUE) {
        *out = g_gpsFix;
        xSemaphoreGive(gpsMutex);
    } else {
        *out = g_gpsFix; // ロック取得できなかった場合のフォールバック(ロック無し読み取り)
    }
}

// 直近3秒以内に有効な測位を取得できていれば「新鮮」とみなす
// (モニターのPID鮮度表示(1.5秒)より少し長め。GPSはOBD PIDより
// 更新頻度が低い(通常1Hz)ため、余裕を持たせている)
static bool isGpsFixFresh(const GpsFix& fix) {
    return fix.posValid && (millis() - fix.lastFixMs) < 3000;
}

// ================================================================
// 2-2c. GPS時刻によるRTC自動設定
// ================================================================
// ★要件:GPSから初めて時刻を取得できたタイミングで、その時刻を
// Tab5本体のRTCへ一度だけセットする。以降GPS側の時刻が更新されても
// RTCへは書き戻さない(RTC自体が以後は自走して時を刻むため、毎回
// 書き込む必要はない。むしろ頻繁な書き込みはRTCデバイスへの不要な
// I2Cアクセスになるだけ)。
//
// 【スレッド設計】GpsFix/gpsMutexと同じ方針:RTCデバイス(I2C)への
// 書き込みは、GPS読み取りタスク(gpsTaskFunc)側からは行わない。
// タッチ(GT911)やIMU等、他のI2Cデバイスと同じバスを共有している
// ため、複数タスクから同時にI2Cへアクセスする事態を避け、必ず
// メインループ側(imuRtcTimerCb経由)から呼び出す設計にしている。
static bool g_rtcSyncedFromGps = false; // ★一度セットしたら以降は何もしない

static void trySyncRtcFromGps() {
    if (g_rtcSyncedFromGps) return;
    if (!M5.Rtc.isEnabled()) return; // RTC非搭載/初期化失敗時は何もしない

    GpsFix snap;
    getGpsSnapshot(&snap);
    if (!snap.timeValid) return; // まだGPSから時刻を一度も取得できていない

    m5::rtc_date_t rtcDate;
    rtcDate.year    = snap.rtcYear;
    rtcDate.month   = snap.rtcMonth;
    rtcDate.date    = snap.rtcDay;
    rtcDate.weekDay = 0; // ★曜日は本プロジェクトでは未使用のため0固定(RTC内部曜日カウンタは使わない)

    m5::rtc_time_t rtcTime;
    rtcTime.hours   = snap.rtcHour;
    rtcTime.minutes = snap.rtcMinute;
    rtcTime.seconds = snap.rtcSecond;

    M5.Rtc.setDate(&rtcDate);
    M5.Rtc.setTime(&rtcTime);

    g_rtcSyncedFromGps = true; // ★これ以降は呼ばれても即returnする
    Serial.printf("[RTC] GPS取得時刻(JST)でRTCを設定しました: %04d-%02d-%02d %02d:%02d:%02d\n",
        (int)rtcDate.year, (int)rtcDate.month, (int)rtcDate.date,
        (int)rtcTime.hours, (int)rtcTime.minutes, (int)rtcTime.seconds);
}

// 画面表示用のGPSステータス文字列を組み立てる(モニター画面用。
// 緯度・経度・時刻をすべて表示する)
static void formatGpsStatusText(char* out, size_t outLen) {
    GpsFix snap;
    getGpsSnapshot(&snap);
    bool fresh = isGpsFixFresh(snap);
    bool ppsOk = (millis() - g_lastPpsMs) < 1500; // 直近1.5秒以内にPPSパルスがあったか

    if (fresh) {
        snprintf(out, outLen, "GPS: %s, %s   %s JST%s",
            snap.latStr, snap.lonStr, snap.timeStr, ppsOk ? "  [PPS]" : "");
    } else {
        snprintf(out, outLen, "GPS: -%s", ppsOk ? "  [PPS]" : "");
    }
}

// 画面表示用のGPSステータス文字列(時刻のみ版)を組み立てる。
// PID選択画面用:中央のタイトルと重ならないよう、緯度・経度は含めず
// 時刻のみを短く表示する。
static void formatGpsTimeOnlyText(char* out, size_t outLen) {
    GpsFix snap;
    getGpsSnapshot(&snap);
    bool fresh = isGpsFixFresh(snap);
    bool ppsOk = (millis() - g_lastPpsMs) < 1500;

    if (fresh) {
        snprintf(out, outLen, "GPS: %s JST%s", snap.timeStr, ppsOk ? "  [PPS]" : "");
    } else {
        snprintf(out, outLen, "GPS: -%s", ppsOk ? "  [PPS]" : "");
    }
}

// ================================================================
// 2-2d. IMUによる画面自動回転(Landscapeの2方向のみ、Portraitは使わない)
// ================================================================
// ★要件:本体を約180度回転させて持ち替えたら、画面表示も180度切り替える。
//   通常向き   : M5.Display.setRotation(3)
//   180度回転時 : M5.Display.setRotation(1)
//   Portrait(縦向き)相当の回転(0, 2)は使わない。
//
// 【判定方法】外部ライブラリは追加せず、M5UnifiedのIMU機能(M5.Imu)が
// 返す加速度センサーの重力成分だけで判定する。画面の長辺方向にかかる
// 重力の符号(IMUのX軸を想定)で「今どちら向きに持たれているか」を
// 判定し、それに応じてsetRotation(1)/(3)を切り替える。
// ※実機のIMU軸の向き・符号は、パネル/IMUチップの実装によって
//   setRotation(3)が正面だったという実機検証結果(冒頭コメント4番)と
//   同様に変わり得る。実機で回転方向が逆になっている場合は、下記
//   IMU_ROTATION_AXIS_SIGN を -1 に反転すること。
#define IMU_ROTATION_AXIS_SIGN -1 // ★実機検証の結果、符号が逆だったため-1に確定(1だと1/3が逆になる不具合を確認済み)

static constexpr float    IMU_ROTATION_THRESHOLD_G     = 0.6f; // この値を超えたら「回転している」とみなす
static constexpr int      IMU_ROTATION_DEBOUNCE_COUNT  = 5;    // 同じ向きがこの回数連続したら確定(チャタリング防止)
static constexpr uint32_t IMU_ROTATION_STARTUP_GRACE_MS = 1500; // ★起動直後はIMUのオフセットがまだ安定せず
                                                                  // ノイズで暴れやすいため、この間は判定自体を行わない
                                                                  // (実機ログで起動直後に1↔3が数回連続で切り替わる
                                                                  // 現象が確認されたための対策)

// setup()でM5.Display.setRotation(3)しているため、初期値もそれに合わせる
static uint8_t g_currentDisplayRotation = 3;
static uint8_t g_rotationCandidate      = 3;
static int     g_rotationCandidateCount = 0;

// ★imuRtcTimerCb (150ms周期) から呼ばれる想定。isDisplayOnに関わらず
// 呼んで構わない(setRotation自体は軽量なレジスタ操作で、消灯中に
// 呼んでも実害はなく、画面点灯時にすぐ正しい向きで再開できる)。
static void updateDisplayRotationFromImu() {
    if (!M5.Imu.isEnabled()) return; // IMU非搭載/初期化失敗時は何もしない
    if (millis() < IMU_ROTATION_STARTUP_GRACE_MS) return; // 起動直後の不安定な値では判定しない
    if (!M5.Imu.update()) return;    // 新しいIMUサンプルが取れていなければ何もしない

    auto data = M5.Imu.getImuData();
    float ax = data.accel.x * IMU_ROTATION_AXIS_SIGN;

    // ★閾値を超えていない(≒あまり傾いていない/机上に平置きに近い)間は
    // 直前の判定を維持する。閾値付近でPortrait寄りの中途半端な姿勢に
    // なった時にチラチラ切り替わるのを防ぐため。
    uint8_t desiredRotation = g_currentDisplayRotation;
    if (ax > IMU_ROTATION_THRESHOLD_G) {
        desiredRotation = 1;
    } else if (ax < -IMU_ROTATION_THRESHOLD_G) {
        desiredRotation = 3;
    }

    if (desiredRotation == g_currentDisplayRotation) {
        g_rotationCandidateCount = 0; // 現状維持なのでデバウンスカウンタもクリア
        return;
    }

    // ★瞬間的な傾き・振動だけで即切り替えるとパタパタするため、
    // 同じ向きがIMU_ROTATION_DEBOUNCE_COUNT回連続で検出できてから
    // 実際にsetRotation()する
    if (desiredRotation == g_rotationCandidate) {
        g_rotationCandidateCount++;
    } else {
        g_rotationCandidate = desiredRotation;
        g_rotationCandidateCount = 1;
    }

    if (g_rotationCandidateCount >= IMU_ROTATION_DEBOUNCE_COUNT) {
        M5.Display.setRotation(desiredRotation);
        g_currentDisplayRotation = desiredRotation;
        g_rotationCandidateCount = 0;

        // ★M5.Display.setRotation()はM5GFX内部の座標変換を切り替えるだけで、
        // 既に描画済みのピクセルを勝手に再送してはくれない。LVGLは
        // 「ウィジェットの変化で無効化(invalidate)された領域」だけを
        // lvglFlushCb()経由で物理パネルへ再送する仕組みのため、これを呼ばずに
        // 放置すると、次に何かラベルが更新されるまで画面が古い向きのまま
        // 変化しない(実機で「ログには回転したと出るのに画面は変わらない」
        // 症状として確認された)。切り替え直後に現在のアクティブ画面全体を
        // 明示的に無効化し、次のlv_timer_handler()で全面再描画
        // →lvglFlushCb()経由で新しい向きのままpushImage()し直させる。
        //
        // ★以前ここにlv_refr_now(NULL)を追加し、切り替え前後で保留中の
        // 描画を同期的に強制フラッシュする対策を試したが、実車テストで
        // 見られた「MAF値表示の一時的な乱れ」はスクリーンショット撮影と
        // 値更新のタイミングが重なっただけの見た目上の現象であり、実際の
        // 表示や動作には影響がないことを確認したため撤去した。
        // lv_refr_now()はメインループの他の処理(OBDポーリング、GPS/BLE
        // 処理等)をブロックしうるため、実害のない事象への対策として
        // 使うのは避け、lv_obj_invalidate()による通常の非同期フラッシュに
        // 留めている。
        lv_obj_invalidate(lv_screen_active());

        Serial.printf("[IMU] display rotation changed to %d\n", (int)desiredRotation);
    }
}


// ================================================================
// 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; // ★専用タスクからのみ書き込む。メインループは読むだけ

// ★Backを押してもロギングを止めない設計にしたため、Start Monitoring
// 押下時に「今開いているログファイルのPID構成」と「現在の選択状態」を
// 比較し、変わっていれば新しいファイルに切り替える必要がある。そのための
// スナップショット(ビットマスク)。PID_COUNTが64を超えないことが前提
// (超える場合はstd::bitset等に変更すること。現状17個なので十分余裕あり)。
static uint64_t g_loggedPidMask = 0;

// 現在チェックボックスで選択されているPIDの組み合わせをビットマスク化する
// (doOpenLogFileInternal()でのopen時スナップショット取得と、
// onStartMonitoring()での変更検知の両方で使う)
static uint64_t computeCurrentPidMask() {
    uint64_t mask = 0;
    for (size_t i = 0; i < PID_COUNT && i < 64; i++) {
        if (pidTable[i].selected) mask |= (1ULL << i);
    }
    return mask;
}

// 【重要・性能】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分だけ列を展開する。
    // ★GPS列(時刻・緯度・経度)は要件によりログ項目の先頭に追加する。
    logFile.print("GPS_JST,GPS_Lat[deg],GPS_Lon[deg],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;
    g_loggedPidMask = computeCurrentPidMask(); // ★このファイルの列構成に対応する選択状態を記録しておく
    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形式:先頭はGPS(時刻・緯度・経度/未取得時は"-")、続いて経過秒数(数値)、
    // 以降は選択中PIDの数値のみをカンマ区切りで並べる。
    // ヘッダー行(doOpenLogFileInternal)と列の並び・列数が必ず一致するよう、
    // 同じ「pidTableをi順に走査し、selectedのものだけfieldCount分出力する」
    // ロジックにしている。
    // ★重要:ここで見るべきは「今現在チェックが入っているか」
    // (pidTable[i].selected、ライブの値)ではなく、「このログファイルの
    // ヘッダーを書いた時点で選ばれていたPID」(g_loggedPidMask、file-open
    // 時点のスナップショット)でなければならない。Backを押してもロギングが
    // 止まらない設計にしたため、ファイルを開いたままPID選択(チェックの
    // ON/OFF・Select All/Deselect All等)を変更できてしまう。もしここで
    // ライブのpidTable[i].selectedを見てしまうと、選択を変えた瞬間から
    // 出力される列数がヘッダーとズレて、CSVとして壊れたファイルになる
    // (実際に実車テストでこの不具合が発生し、行ごとに列数が4→5→…→24と
    // 徐々に増えていくCSVが生成されてしまった)。
    GpsFix gpsSnap;
    getGpsSnapshot(&gpsSnap);

    size_t pos = (size_t)snprintf(m.line, sizeof(m.line), "%s,%s,%s,%.3f",
        gpsSnap.timeValid ? gpsSnap.timeStr : "-",
        gpsSnap.posValid  ? gpsSnap.latStr  : "-",
        gpsSnap.posValid  ? gpsSnap.lonStr  : "-",
        millis() / 1000.0);
    for (size_t i = 0; i < PID_COUNT && pos < sizeof(m.line); i++) {
        if (!(g_loggedPidMask & (1ULL << i))) continue; // ★file-open時点の選択状態で判定する
        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];
            // ★以前は"S1:","S2:"等のラベルで位置を明示していたが、タイル幅が
            // 狭く収まらないため撤去し、代わりに非搭載センサーを"-"で示す
            // ことで位置情報(何番目のセンサーか)を保っていた。しかしそれだと、
            // 「後ろの方のセンサーがまとめて非搭載」なだけの車両でも、末尾に
            // 意味のない"-"が付いてしまう。
            // そこで、まず「実際に対応している最後のセンサー番号」を求め、
            // それより後ろは丸ごと省略する。それより手前の歯抜け(例:
            // センサー1と3は搭載、センサー2だけ非搭載)については、引き続き
            // "-"を残して位置情報を保つ(ラベル無しでも対応関係が分かるように
            // するための表現なので、ここは残す)。
            int lastSupported = -1;
            for (int s = 0; s < 4 && len >= (size_t)(2 + s * 2); s++) {
                if (sup & (1 << s)) lastSupported = s;
            }

            char tmp[48] = "";
            for (int s = 0; s <= lastSupported; 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 ", 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;

// GPSステータス表示ラベル(PID選択画面は時刻のみ、モニター画面は
// 緯度・経度・時刻の全て。選択画面で緯度経度まで表示すると中央の
// タイトルと重なってしまうため、選択画面側は時刻のみに絞っている)
static lv_obj_t* g_gpsStatusLblSelect = nullptr;
static lv_obj_t* g_gpsStatusLblDash   = nullptr;

// PID選択画面の「LOGGING」点滅表示。Backを押してもロギングは裏で
// 継続する設計にしたため、選択画面に戻っていても「今もロギング中で
// ある」ことが一目でわかるよう、logFileOpen中は点滅表示する
// (refreshTimerCbが200ms毎にopa(不透明度)を切り替えて点滅させる)。
static lv_obj_t* g_loggingLblSelect = 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);

// PID選択画面の各行右端にある「≡」ハンドルのドラッグ&ドロップ処理。
// ハンドルだけがドラッグ対象(行全体ではない)。ドラッグ中は、指の
// Y座標がどの行の中間点を超えたかを見て、掴んでいる行をその位置へ
// lv_obj_move_to_index()で差し替える(フローティング表示は行わない、
// 「掴んだ行がその場でスロットへ飛ぶ」簡易的な並べ替えUI)。
// user_dataには対象のlist(リストコンテナ)を渡してある。
// PID選択画面の並べ替えリスト本体(Undo/Resetボタンから再構築するために
// グローバルで保持する。buildSelectScreen()で一度だけ作成され、以後は
// rebuildPidListRows()で中身だけ作り直す)
static lv_obj_t* g_pidListObj = nullptr;

// ドラッグ中の行(ハイライト解除の保険用。通常はPRESSED〜RELEASED間のみ有効)
static lv_obj_t* g_draggingRow = nullptr;

// 複数段Undo用の履歴スタック。ドラッグで実際に並びが変わるたびに
// 「変更前の状態」を1段積んでいく。上限を超えたら最も古い履歴を捨てる
// (動的メモリ確保は使わない方針のため固定サイズ配列。1段あたり
// PID_COUNT(17)バイトなので、20段でも340バイトとごく小さく、上限自体は
// 気にする必要がない。Save/Reset/Recoverでスタックごとクリアされる
// ため、実用上これだけあれば十分すぎるはず)。
#define PID_ORDER_UNDO_MAX 20
static uint8_t g_pidOrderHistory[PID_ORDER_UNDO_MAX][PID_COUNT];
// ★各履歴段で「実際にドラッグされていたPID(pidTable内インデックス)」も
// 並行して記録しておく。Undo後にその項目までスクロール・ハイライト
// するために使う(並びの差分だけでは、ドラッグ操作で連動して位置がずれた
// 他の項目と区別がつかないため)。
static uint8_t g_pidOrderHistoryMovedIdx[PID_ORDER_UNDO_MAX];
static uint8_t g_pidOrderHistoryCount = 0; // 現在積まれている段数

static lv_obj_t* g_undoBtn = nullptr; // 履歴が空の間はDISABLED状態にする

// ドラッグ開始時点の並び(一時バッファ。RELEASED時に実際に変化したかの比較に使う)
static uint8_t g_pidOrderDragStart[PID_COUNT];

// 履歴スタックへ1段積む。上限に達していたら最も古い段を1つ捨てて詰める
// (新しい方の履歴を優先して残す)
static void pushPidOrderHistory(const uint8_t* prevOrder, uint8_t movedIdx) {
    if (g_pidOrderHistoryCount >= PID_ORDER_UNDO_MAX) {
        memmove(g_pidOrderHistory[0], g_pidOrderHistory[1],
            sizeof(uint8_t) * PID_COUNT * (PID_ORDER_UNDO_MAX - 1));
        memmove(g_pidOrderHistoryMovedIdx, g_pidOrderHistoryMovedIdx + 1,
            sizeof(uint8_t) * (PID_ORDER_UNDO_MAX - 1));
        g_pidOrderHistoryCount = PID_ORDER_UNDO_MAX - 1;
    }
    memcpy(g_pidOrderHistory[g_pidOrderHistoryCount], prevOrder, sizeof(uint8_t) * PID_COUNT);
    g_pidOrderHistoryMovedIdx[g_pidOrderHistoryCount] = movedIdx;
    g_pidOrderHistoryCount++;
}

// ★現在のPID一覧リストから、指定したpidTableインデックスに対応する行
// (lv_obj_t*)を探す。Undo後のスクロール/ハイライト先を特定するために使う。
static lv_obj_t* findPidRowByIndex(uint8_t pidIdx) {
    if (!g_pidListObj) return nullptr;
    uint32_t cnt = lv_obj_get_child_count(g_pidListObj);
    for (uint32_t idx = 0; idx < cnt; idx++) {
        lv_obj_t* child = lv_obj_get_child(g_pidListObj, idx);
        if ((uint8_t)(uintptr_t)lv_obj_get_user_data(child) == pidIdx) return child;
    }
    return nullptr;
}

// 「一時ハイライト」用の1本だけのタイマー。同時に複数の行をハイライト
// する必要はないため、常に最新の1つだけを管理する(新しいハイライトが
// 始まったら、前のタイマーは問答無用でキャンセルする)。
static lv_timer_t* g_highlightTimer = nullptr;

// ハイライト中の行が、タイマー発火前に(rebuildPidListRows等で)先に
// 破棄されてしまった場合の保険。行のLV_EVENT_DELETEで呼ばれ、
// ダングリングポインタになるはずだったタイマーを止めておく。
static void onHighlightRowDeleted(lv_event_t* e) {
    if (g_highlightTimer) {
        lv_timer_delete(g_highlightTimer);
        g_highlightTimer = nullptr;
    }
}

static void onHighlightTimerExpired(lv_timer_t* t) {
    lv_obj_t* row = (lv_obj_t*)lv_timer_get_user_data(t);
    if (row) {
        lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
        lv_obj_remove_event_cb(row, onHighlightRowDeleted);
    }
    g_highlightTimer = nullptr;
}

// 指定した行の背景をハイライト色にし、約1秒後に自動で元(透明)に戻す。
// Undo確定直後・ドラッグ確定直後の両方から共通で使う。
// ドラッグ中/一時ハイライト表示で使う背景色(通常は透明)
#define PID_ROW_DRAG_HIGHLIGHT_COLOR 0x2D4A6B

static void highlightRowTemporarily(lv_obj_t* row) {
    if (!row) return;
    if (g_highlightTimer) {
        lv_timer_delete(g_highlightTimer); // ★前のハイライトが残っていれば打ち切る
        g_highlightTimer = nullptr;
    }
    lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
    lv_obj_set_style_bg_color(row, lv_color_hex(PID_ROW_DRAG_HIGHLIGHT_COLOR), 0);
    lv_obj_add_event_cb(row, onHighlightRowDeleted, LV_EVENT_DELETE, NULL);
    g_highlightTimer = lv_timer_create(onHighlightTimerExpired, 1000, row);
    lv_timer_set_repeat_count(g_highlightTimer, 1); // ★1回だけ実行するワンショットタイマー
}

// ★保存タイミングをsavePidSelection()(即時保存)とは分離した。
// ドラッグ・Undo・Resetはg_pidOrderをメモリ上でのみ変更し、NVSへは
// 明示的に「Save」ボタンを押すまで反映しない。これにより、
// 「まだ保存していない変更を、最後に保存された状態まで一括で
// 取り消す」という「Recover」ボタンに実用的な意味を持たせている
// (即時保存の設計だと、常に現在値=保存値になるため無意味だった)。
static bool g_pidOrderDirty = false; // true = NVS未保存の変更がある
static lv_obj_t* g_saveOrderBtn = nullptr;
static lv_obj_t* g_recoverOrderBtn = nullptr;

// Undo/Save/Recoverボタンの有効/無効状態を、現在のg_pidOrderHistoryCount・
// g_pidOrderDirtyに合わせて更新する
static void updateOrderButtonStates() {
    if (g_undoBtn) {
        if (g_pidOrderHistoryCount > 0) lv_obj_clear_state(g_undoBtn, LV_STATE_DISABLED);
        else lv_obj_add_state(g_undoBtn, LV_STATE_DISABLED);
    }
    if (g_saveOrderBtn) {
        if (g_pidOrderDirty) lv_obj_clear_state(g_saveOrderBtn, LV_STATE_DISABLED);
        else lv_obj_add_state(g_saveOrderBtn, LV_STATE_DISABLED);
    }
    if (g_recoverOrderBtn) {
        if (g_pidOrderDirty) lv_obj_clear_state(g_recoverOrderBtn, LV_STATE_DISABLED);
        else lv_obj_add_state(g_recoverOrderBtn, LV_STATE_DISABLED);
    }
}

static void onPidRowDragEvent(lv_event_t* e) {
    lv_obj_t* handle = (lv_obj_t*)lv_event_get_target(e);
    lv_obj_t* row = lv_obj_get_parent(handle);
    lv_obj_t* list = g_pidListObj;
    lv_event_code_t code = lv_event_get_code(e);
    if (!list) return;

    if (code == LV_EVENT_PRESSED) {
        // ★ドラッグ中にlist自体がスクロールしてしまうと共存できないため、
        // ドラッグしている間だけ一時的にスクロールを止める(自動スクロールは
        // 下記PRESSING側でlv_obj_scroll_by()により明示的に行う)
        lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE);

        // ★掴んでいる行をハイライト表示(移動対象を視覚的に明確にする)
        lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
        lv_obj_set_style_bg_color(row, lv_color_hex(PID_ROW_DRAG_HIGHLIGHT_COLOR), 0);
        g_draggingRow = row;

        // ★ドラッグ開始時点の並びを一時保存しておく
        // (実際に並びが変わった場合のみ、RELEASED側でpushPidOrderHistory()する)
        memcpy(g_pidOrderDragStart, g_pidOrder, sizeof(g_pidOrderDragStart));
    } else if (code == LV_EVENT_PRESSING) {
        lv_indev_t* indev = lv_indev_get_act();
        if (!indev) return;
        lv_point_t p;
        lv_indev_get_point(indev, &p);

        // ★指がリストの上端/下端付近に近づいたら自動的にスクロールする。
        // PRESSINGはLVGLの入力読み取り周期(既定約30ms)ごとに繰り返し発火
        // するため、専用タイマーを使わずここでスクロール量を少しずつ
        // 積み上げるだけでよい。
        // 【符号について】lv_obj_scroll_by()は「正の値=下方向へスクロール
        // (下の内容を表示)」がLVGLの仕様。実機で向きが逆に感じる場合は
        // AUTOSCROLL_STEPの符号をひっくり返すこと。
        const int32_t EDGE = 40;         // 端からこの距離(px)以内で自動スクロール発動
        const int32_t AUTOSCROLL_STEP = 6; // 1回(約30ms)あたりのスクロール量(px)
        lv_area_t listArea;
        lv_obj_get_coords(list, &listArea);
        if (p.y < listArea.y1 + EDGE) {
            lv_obj_scroll_by(list, 0, AUTOSCROLL_STEP, LV_ANIM_OFF); // 上端に近い→上方向へ
        } else if (p.y > listArea.y2 - EDGE) {
            lv_obj_scroll_by(list, 0, -AUTOSCROLL_STEP, LV_ANIM_OFF); // 下端に近い→下方向へ
        }

        uint32_t cnt = lv_obj_get_child_count(list);
        int32_t targetIdx = (int32_t)cnt - 1; // 全行より下まで指が行った場合は最後尾
        for (uint32_t idx = 0; idx < cnt; idx++) {
            lv_obj_t* child = lv_obj_get_child(list, idx);
            lv_area_t area;
            lv_obj_get_coords(child, &area);
            int32_t mid = (area.y1 + area.y2) / 2;
            if (p.y < mid) { targetIdx = (int32_t)idx; break; }
        }
        uint32_t curIdx = lv_obj_get_index(row);
        if (targetIdx != (int32_t)curIdx) {
            lv_obj_move_to_index(row, targetIdx); // ★これだけで表示上も即座に入れ替わる
        }
    } else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) {
        lv_obj_add_flag(list, LV_OBJ_FLAG_SCROLLABLE); // スクロールを元に戻す
        g_draggingRow = nullptr;

        // ★確定した表示順(=list内の実際の子オブジェクトの並び)から
        // g_pidOrderを作り直す。各行のuser_dataにはpidTable内インデックス
        // を格納してある(行生成部分を参照)。
        // ★NVSへの保存はここでは行わない(Saveボタンを押すまでメモリ上
        // のみで保持する。Recoverボタンで戻せるようにするため)。
        uint32_t cnt = lv_obj_get_child_count(list);
        uint8_t newOrder[PID_COUNT];
        for (uint32_t idx = 0; idx < cnt && idx < PID_COUNT; idx++) {
            lv_obj_t* child = lv_obj_get_child(list, idx);
            newOrder[idx] = (uint8_t)(uintptr_t)lv_obj_get_user_data(child);
        }

        // ★実際に並びが変わった場合のみ履歴に1段積み、未保存扱いにする
        // (ハンドルを押しただけで動かさずに離した場合は何もしない)
        if (memcmp(newOrder, g_pidOrderDragStart, sizeof(newOrder)) != 0) {
            uint8_t movedIdx = (uint8_t)(uintptr_t)lv_obj_get_user_data(row);
            pushPidOrderHistory(g_pidOrderDragStart, movedIdx);
            g_pidOrderDirty = true;
            updateOrderButtonStates();
            highlightRowTemporarily(row); // ★動かした行を約1秒ハイライトしたまま残す
        } else {
            lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); // 何も変わらなければ即座に解除
        }

        memcpy(g_pidOrder, newOrder, sizeof(g_pidOrder));
    }
}

// PID一覧リストの中身(行)を、現在のg_pidOrderに従って作り直す。
// 初回のbuildSelectScreen()と、Undo/Resetボタン押下時の両方から呼ばれる。
// ★list自体は破棄せず、lv_obj_clean()で子(行)だけを一旦全部消してから
// 作り直す(listオブジェクト自体・スクロール位置等はリセットされる)。
static void rebuildPidListRows() {
    lv_obj_t* list = g_pidListObj;
    if (!list) return;
    lv_obj_clean(list);

    for (size_t idx = 0; idx < PID_COUNT; idx++) {
        size_t i = g_pidOrder[idx]; // ★表示順はg_pidOrder経由(ドラッグで変更された順序を反映)

        // 1行分のコンテナ:[チェックボックス(可変幅)][≡ドラッグハンドル(固定幅)]
        lv_obj_t* row = lv_obj_create(list);
        lv_obj_set_size(row, lv_pct(100), LV_SIZE_CONTENT);
        lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
        lv_obj_set_style_border_width(row, 0, 0);
        lv_obj_set_style_pad_all(row, 0, 0);
        // ★行の縦幅は、ここではなく「≡」ハンドル自身のpad_ver(下記)で
        // 決まるようにしている。rowはLV_SIZE_CONTENTなので、一番背が高い
        // 子要素(=当たり判定を広げたハンドル)に合わせて自動的に育つ。
        lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
        lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
        lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        // ★このuint8_tへのキャストで、行(row)自体にpidTable内インデックスを
        // 紐付けておく。ドラッグ確定時(onPidRowDragEvent)に、実際の行の
        // 並び順からg_pidOrderを再構築するために使う。
        lv_obj_set_user_data(row, (void*)(uintptr_t)i);

        char label[32];
        snprintf(label, sizeof(label), "0x%02X %s", pidTable[i].pid, pidTable[i].name);

        lv_obj_t* cb = lv_checkbox_create(row);
        lv_checkbox_set_text(cb, label);
        lv_obj_set_flex_grow(cb, 1); // ハンドル分を除いた残り幅いっぱいに広げる
        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;

        // ★並べ替え用ハンドル(この部分だけを掴んでドラッグする。行全体や
        // チェックボックスと違い、押した瞬間からPRESSED/PRESSING/RELEASED
        // イベントで並べ替え処理を行う。タップしやすいよう左右に余白を持たせる)
        //
        // ★見た目は「≡」(横棒3本)にしたいが、"≡"というUnicode文字も
        // LV_SYMBOL_LIST等のシンボルフォントも、このプロジェクトのフォント
        // ビルドに含まれておらず豆腐(□)表示になることを実機で確認済み
        // (確認できているのはmontserrat_22/38のASCII範囲のみ)。そのため
        // テキストラベルではなく、細長い矩形(lv_obj)を3本並べて自前で
        // 「≡」を描画する。フォント/文字コードに一切依存しないため、
        // どんな環境でも確実に横棒3本として表示される。
        lv_obj_t* handle = lv_obj_create(row);
        lv_obj_remove_style_all(handle); // ★デフォルトの背景色・枠線・角丸等を全て外す
        lv_obj_clear_flag(handle, LV_OBJ_FLAG_SCROLLABLE);
        // ★「≡」自体の当たり判定(バウンディングボックス)を広げるための
        // パディング。LVGLでは、クリック/ドラッグの判定に使われるのは
        // このオブジェクト自身の座標(=パディングを含む外枠)であり、
        // 親(row)側のパディングを増やしても当たり判定は広がらない。
        // ★行の高さ(=リストの表示密度)と、中身のバーの見た目(太さ・間隔)
        // は別物として扱う。バー3本の合計サイズに行の高さを連動させて
        // しまうと、見た目を変えるたびに行の高さ・表示密度まで変わって
        // しまう不具合が実機で確認された(バー高さ3px・間隔5pxだと行が
        // 詰まって11項目分見えてしまい、バー高さ4px・間隔7pxに広げると
        // 今度は逆に10項目目が一部切れてしまった)。そのため、ハンドル
        // 自体の高さは中身(バー)のサイズによらず固定値にする。
        // ★注意:lv_obj_set_size()で高さを直接指定した場合、その値は
        // パディングを含んだ「外枠そのもの」の高さになる(border-box)。
        // 「中身26px+pad_ver14px=合計40px」のつもりで24pxを指定して
        // しまい、実際にはハンドルの外枠自体が24pxしかない扱いになって
        // 縮んでしまい、結果的にチェックボックス側の高さで行の高さが
        // 決まってしまう(実測で行ピッチが46px→36pxまで縮む)不具合が
        // 実機で確認された。padding分を二重に見込まないよう、
        // ここで指定する高さがそのまま外枠の高さになる前提で調整する。
        // ★38pxという値の根拠:元のラベル版ハンドルは明示的なフォント
        // 指定がなく、親screenSelectの基本フォントlv_font_montserrat_22
        // を継承していた。LVGL組み込みMontserratフォントの行高さは
        // 概ね「フォントサイズ+2px」になる規則性があり(例:
        // lv_font_montserrat_20の実際のline_heightは22)、montserrat_22
        // では line_height ≈ 24px。よって
        //   外枠高さ = line_height(24) + pad_ver(上7+下7=14) = 38px
        // となり、実測した以前の行ピッチ(46px)とも一致する。
        lv_obj_set_style_pad_hor(handle, 16, 0);
        lv_obj_set_style_pad_ver(handle, 7, 0);
        // ★中身のバー3本を縦に並べるための最小限のflexコンテナ設定。
        // 高さは中身に追従させず固定(38px=外枠そのものの高さ。根拠は
        // 上のコメント参照)にする。
        lv_obj_set_size(handle, LV_SIZE_CONTENT, 38);
        lv_obj_set_flex_flow(handle, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_flex_align(handle, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        lv_obj_set_style_pad_row(handle, 5, 0); // バー同士の間隔

        // ★「≡」を構成する横棒3本。判定は親(handle)だけに持たせたいので、
        // バー自体はCLICKABLE/SCROLLABLEを持たない見た目だけの飾りにする。
        for (int barIdx = 0; barIdx < 3; barIdx++) {
            lv_obj_t* bar = lv_obj_create(handle);
            lv_obj_remove_style_all(bar);
            lv_obj_clear_flag(bar, LV_OBJ_FLAG_CLICKABLE);
            lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
            lv_obj_set_size(bar, 24, 3);
            lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
            lv_obj_set_style_bg_color(bar, lv_color_hex(0x757575), 0);
            lv_obj_set_style_radius(bar, 1, 0);
        }

        lv_obj_add_flag(handle, LV_OBJ_FLAG_CLICKABLE);
        lv_obj_add_event_cb(handle, onPidRowDragEvent, LV_EVENT_PRESSED, NULL);
        lv_obj_add_event_cb(handle, onPidRowDragEvent, LV_EVENT_PRESSING, NULL);
        lv_obj_add_event_cb(handle, onPidRowDragEvent, LV_EVENT_RELEASED, NULL);
        lv_obj_add_event_cb(handle, onPidRowDragEvent, LV_EVENT_PRESS_LOST, NULL);
    }
}

// 「Undo」ボタン:履歴スタックから1段ポップして、その状態まで1つずつ遡る。
// ★NVSへは保存しない(メモリ上の変更のみ。Saveボタンで明示的に保存する)。
static void onUndoOrderClicked(lv_event_t* e) {
    if (g_pidOrderHistoryCount == 0) return;
    g_pidOrderHistoryCount--;
    memcpy(g_pidOrder, g_pidOrderHistory[g_pidOrderHistoryCount], sizeof(g_pidOrder));
    uint8_t movedIdx = g_pidOrderHistoryMovedIdx[g_pidOrderHistoryCount]; // ★この段でドラッグされていたPID
    g_pidOrderDirty = true;
    rebuildPidListRows();
    updateOrderButtonStates();

    // ★Undoによって動いた項目が必ずリスト内に見えるようスクロールし、
    // 約1秒ハイライトして視認しやすくする
    lv_obj_t* targetRow = findPidRowByIndex(movedIdx);
    if (targetRow) {
        lv_obj_scroll_to_view(targetRow, LV_ANIM_ON);
        highlightRowTemporarily(targetRow);
    }
}

// 「Reset」ボタン:並び順を「PID値の昇順」に並べ替える。
// ★以前は単にg_pidOrder[idx]=idx(=pidTableの宣言順)にしていたが、
// pidTable自体の宣言順は必ずしもPID値の昇順になっていない
// (0x0C→0x0D→0x04→0x11...のように、機能カテゴリ順などで書かれている
// ため)。「PID値順に戻す」という要望通りの動作にするため、
// pidTable[i].pidの値そのもので昇順ソートしたインデックス列を作る。
// ★Reset・Save・Recoverはいずれも「区切り(チェックポイント)」として扱い、
// それより前のUndo履歴は破棄する(Resetを実行する前の状態には、もう
// Undoでは戻れない仕様)。
// ★NVSへは保存しない(メモリ上の変更のみ)。
static void onResetOrderClicked(lv_event_t* e) {
    // ★PID_COUNTは17程度と小さいので、単純選択ソートで十分(O(n^2)でも
    // 実測上は一瞬。std::sortを使わずに書いているのは、他の並べ替え処理
    // 同様、このプロジェクトが標準ライブラリのアルゴリズム系ヘッダーに
    // 依存しない方針で書かれているため)。
    uint8_t sorted[PID_COUNT];
    for (size_t idx = 0; idx < PID_COUNT; idx++) sorted[idx] = (uint8_t)idx;
    for (size_t a = 0; a < PID_COUNT; a++) {
        size_t minIdx = a;
        for (size_t b = a + 1; b < PID_COUNT; b++) {
            if (pidTable[sorted[b]].pid < pidTable[sorted[minIdx]].pid) minIdx = b;
        }
        if (minIdx != a) {
            uint8_t tmp = sorted[a];
            sorted[a] = sorted[minIdx];
            sorted[minIdx] = tmp;
        }
    }
    memcpy(g_pidOrder, sorted, sizeof(g_pidOrder));

    g_pidOrderDirty = true;
    g_pidOrderHistoryCount = 0; // ★チェックポイント:これより前へはUndoできなくする
    rebuildPidListRows();
    updateOrderButtonStates();
}

// 「Save」ボタン:現在の並び(g_pidOrder、メモリ上)をNVSへ書き込んで確定する。
// ★これもチェックポイントとしてUndo履歴をクリアする。
static void onSaveOrderClicked(lv_event_t* e) {
    savePidOrder();
    g_pidOrderDirty = false;
    g_pidOrderHistoryCount = 0; // ★チェックポイント:保存済みの状態より前へはUndoできなくする
    updateOrderButtonStates();
}

// 「Recover」ボタン:NVSに保存されている「最後に保存された並び」を読み直し、
// まだ保存していない変更(ドラッグ・Undo・Reset)を破棄する。
// ★これもチェックポイントとしてUndo履歴をクリアする(破棄した変更を
// Undoで復活できてしまうと「破棄」の意味が薄れるため)。
static void onRecoverOrderClicked(lv_event_t* e) {
    restorePidOrder(); // g_pidOrderを直接、NVSの内容で上書きする
    g_pidOrderDirty = false;
    g_pidOrderHistoryCount = 0; // ★チェックポイント
    rebuildPidListRows();
    updateOrderButtonStates();
}

// 「Select All」ボタン:全PIDを選択状態にする。
// ★並び順(g_pidOrder)には触れない。選択状態(pidTable[i].selected)だけを
// 変更し、各行のチェックボックスの見た目にも反映した上で、選択状態は
// 従来通り即時保存する(savePidSelection。並び順のSave/Recoverとは別物)。
static void onSelectAllClicked(lv_event_t* e) {
    for (size_t i = 0; i < PID_COUNT; i++) {
        pidTable[i].selected = true;
        if (pidTable[i].checkbox) lv_obj_add_state(pidTable[i].checkbox, LV_STATE_CHECKED);
    }
    savePidSelection();
}

// 「Deselect All」ボタン:全PIDの選択を解除する。
static void onDeselectAllClicked(lv_event_t* e) {
    for (size_t i = 0; i < PID_COUNT; i++) {
        pidTable[i].selected = false;
        if (pidTable[i].checkbox) lv_obj_clear_state(pidTable[i].checkbox, LV_STATE_CHECKED);
    }
    savePidSelection();
}

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);

    // [LOGGING点滅] タイトル行の左側に表示。logFileOpen中だけ
    // refreshTimerCbがopaを切り替えて点滅させる(初期状態は非表示)
    lv_obj_t* loggingLblSel = lv_label_create(screenSelect);
    lv_label_set_text(loggingLblSel, "LOGGING");
    lv_obj_set_style_text_color(loggingLblSel, lv_color_hex(0xFF5252), 0);
    lv_obj_align(loggingLblSel, LV_ALIGN_TOP_LEFT, 10, 10);
    lv_obj_set_style_opa(loggingLblSel, LV_OPA_TRANSP, 0);
    g_loggingLblSelect = loggingLblSel;

    // [GPS時刻] タイトル行の右側に表示(緯度経度は表示しない=タイトルと
    // 重ならないよう短くしてある。未取得時は"-"、実際の値は
    // refreshTimerCbが200ms毎に更新する)
    lv_obj_t* gpsLblSel = lv_label_create(screenSelect);
    lv_label_set_text(gpsLblSel, "GPS: -");
    lv_obj_set_style_text_color(gpsLblSel, lv_color_hex(0x9E9E9E), 0);
    lv_obj_align(gpsLblSel, LV_ALIGN_TOP_RIGHT, -10, 10);
    g_gpsStatusLblSelect = gpsLblSel;

    // 下部にStart/Offの2ボタンを置くため、リストの高さは控えめに
    // ★ドラッグ&ドロップでの並べ替えに対応するため、以前の2列ウェブ折返し
    // (ROW_WRAP)ではなく単一列(COLUMN)のスクロールリストに変更した。
    // 2列レイアウトのままだと、指のY座標だけでは「上下どちらの列と
    // 入れ替えるべきか」を一意に決められず、ドラッグ処理が複雑になる
    // ため(1列なら「Y座標がどの行の中間点を超えたか」だけで判定できる)。
    // ★リスト右側にUndo/Resetボタンを縦に並べるため、リスト幅は控えめに
    // している(以前は90%幅で中央寄せだったが、右側にボタン用の余白を
    // 確保するため、左寄せ+幅を絞る形に変更した)。
    int32_t listAreaHeight = (int32_t)(g_verRes * 0.70f) - 20; // ★70%相当から20px縮小
    int32_t listWidth = (int32_t)(g_horRes * 0.66f);
    int32_t btnColWidth = (int32_t)(g_horRes * 0.20f);
    int32_t sideMargin = (int32_t)(g_horRes * 0.03f);

    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, listWidth, listAreaHeight);
    lv_obj_set_pos(list, sideMargin, 50);
    lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_row(list, 8, 0); // ★行の縦幅拡大に合わせて行間も少し広げた
    g_pidListObj = list;

    // [Undo/Reset] 並べ替え専用ボタンをリストの右側に縦に並べる
    lv_obj_t* orderBtnCol = lv_obj_create(screenSelect);
    lv_obj_set_size(orderBtnCol, btnColWidth, listAreaHeight);
    lv_obj_set_pos(orderBtnCol, sideMargin + listWidth + sideMargin, 50);
    lv_obj_set_style_bg_opa(orderBtnCol, LV_OPA_TRANSP, 0);
    lv_obj_set_style_border_width(orderBtnCol, 0, 0);
    // ★実機で6個のボタンが余裕を持って収まると確認できたため、
    // スクロールは無効化する(以前は低解像度での欠けを懸念して
    // 有効なままにしていた)
    lv_obj_clear_flag(orderBtnCol, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_set_flex_flow(orderBtnCol, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_flex_align(orderBtnCol, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
    lv_obj_set_style_pad_row(orderBtnCol, 14, 0); // ★実機で余白に余裕があると確認できたため広めに

    lv_obj_t* undoBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(undoBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(undoBtn, onUndoOrderClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* undoLbl = lv_label_create(undoBtn);
    lv_label_set_text(undoLbl, "Undo");
    lv_obj_center(undoLbl);
    g_undoBtn = undoBtn;

    lv_obj_t* resetBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(resetBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(resetBtn, onResetOrderClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* resetLbl = lv_label_create(resetBtn);
    lv_label_set_text(resetLbl, "Reset");
    lv_obj_center(resetLbl);

    // [Save] 現在の並び順(メモリ上)をNVSへ確定保存する
    lv_obj_t* saveBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(saveBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(saveBtn, onSaveOrderClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* saveLbl = lv_label_create(saveBtn);
    lv_label_set_text(saveLbl, "Save");
    lv_obj_center(saveLbl);
    g_saveOrderBtn = saveBtn;

    // [Recover] NVSに保存済みの並びを読み直し、未保存の変更を破棄する
    lv_obj_t* recoverBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(recoverBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(recoverBtn, onRecoverOrderClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* recoverLbl = lv_label_create(recoverBtn);
    lv_label_set_text(recoverLbl, "Recover");
    lv_obj_center(recoverLbl);
    g_recoverOrderBtn = recoverBtn;

    // ★Undo/Reset/Save/Recover(並び順の操作)と、Select All/Deselect All
    // (選択状態の操作)は意味的に別グループなので、透明なスペーサーで
    // 少し間を空けて視覚的に区切る
    lv_obj_t* groupSpacer = lv_obj_create(orderBtnCol);
    lv_obj_set_size(groupSpacer, lv_pct(100), 6);
    lv_obj_set_style_bg_opa(groupSpacer, LV_OPA_TRANSP, 0);
    lv_obj_set_style_border_width(groupSpacer, 0, 0);
    lv_obj_clear_flag(groupSpacer, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_clear_flag(groupSpacer, LV_OBJ_FLAG_CLICKABLE);

    // [Select All] 全PIDを選択状態にする(並び順とは無関係。選択状態のみ)
    lv_obj_t* selectAllBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(selectAllBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(selectAllBtn, onSelectAllClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* selectAllLbl = lv_label_create(selectAllBtn);
    lv_label_set_text(selectAllLbl, "Select All");
    lv_obj_center(selectAllLbl);

    // [Deselect All] 全PIDの選択を解除する
    lv_obj_t* deselectAllBtn = lv_button_create(orderBtnCol);
    lv_obj_set_size(deselectAllBtn, lv_pct(100), 62); // ★実機で余白に余裕があると確認できたため大きめに
    lv_obj_add_event_cb(deselectAllBtn, onDeselectAllClicked, LV_EVENT_CLICKED, NULL);
    lv_obj_t* deselectAllLbl = lv_label_create(deselectAllBtn);
    lv_label_set_text(deselectAllLbl, "Deselect All");
    lv_obj_center(deselectAllLbl);

    // ★起動直後はUndoできる変更も未保存の変更も無いので、無効化した
    // 状態から始める(個別にlv_obj_add_state()するのではなく、ここで
    // g_pidOrderHistoryCount/g_pidOrderDirtyの初期値(0/false)に沿ってまとめて反映する)
    updateOrderButtonStates();

    rebuildPidListRows(); // ★現在のg_pidOrderに従って行を生成

    // [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);
        if (!saveToSdEnabled && logFileOpen) {
            // ★チェックを外した瞬間に、開いているログファイルがあれば即座に閉じる。
            // 「Start Monitoringが押されるまで継続」ではなく、即時反映の方が
            // 直感的という要望に合わせた(チェックを外したのにLOGGING点滅が
            // 消えない、という違和感をなくすため)。
            requestCloseLogFile();
            loggingActiveForUI = false;
        }
    }, 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, 134, 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 lv_timer_t* imuRtcTimer; // ★IMUによる画面回転判定 + GPS初回時刻取得時のRTC同期用(150ms周期)

static void onBackToSelect(lv_event_t* e) {
    // ★以前はここでpollTimer削除・gateway.monitoring=false・ログファイル
    // クローズを行い、Back=ロギング停止としていた。「Backで選択画面に
    // 一時的に戻ってもロギングは裏で継続したい」という要望に合わせ、
    // ここでは画面遷移のみ行う(pollTimer・接続状態・開いているログ
    // ファイルはそのまま維持する)。
    // PID選択が変わった場合の列構成の扱いはonStartMonitoring()側で
    // 判断する(ファイル冒頭コメント16.参照)。
    // ロギングを完全に終了したい場合は「Eject SD」ボタンを使うこと
    // (開いているログファイルを安全に閉じてからアンマウントする)。
    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);
}

// ★isDisplayOnに関わらず毎回呼ぶ:
//   - IMUによる回転判定は消灯中でも判定を進めておきたい(updateDisplayRotationFromImu参照)
//   - RTC同期はGPSから初めて時刻が取れた瞬間の1回だけなので、消灯中に
//     取りこぼして機会を逃さないようにする
static void imuRtcTimerCb(lv_timer_t* t) {
    updateDisplayRotationFromImu();
    trySyncRtcFromGps();
}

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を再試行してもらう)
        }
    }

    // ★GPSステータス表示の更新(選択画面は時刻のみ、モニター画面は緯度経度+時刻)
    {
        bool fresh = isGpsFixFresh(g_gpsFix); // ロック無し読み取り(表示用途のため実害なし)
        uint32_t color = fresh ? 0x00C853 : 0x9E9E9E;

        if (g_gpsStatusLblSelect) {
            char gpsTimeText[48];
            formatGpsTimeOnlyText(gpsTimeText, sizeof(gpsTimeText));
            lv_label_set_text(g_gpsStatusLblSelect, gpsTimeText);
            lv_obj_set_style_text_color(g_gpsStatusLblSelect, lv_color_hex(color), 0);
        }
        if (g_gpsStatusLblDash) {
            char gpsText[64];
            formatGpsStatusText(gpsText, sizeof(gpsText));
            lv_label_set_text(g_gpsStatusLblDash, gpsText);
            lv_obj_set_style_text_color(g_gpsStatusLblDash, lv_color_hex(color), 0);
        }
    }

    // ★PID選択画面の「LOGGING」点滅表示の更新。logFileOpenの間だけ、
    // 約500ms周期(refreshTimerの200ms×2〜3回に1回反転)で不透明⇔透明を
    // 切り替えて点滅させる。ロギングしていない時は常に非表示。
    if (g_loggingLblSelect) {
        if (logFileOpen) {
            bool blinkOn = ((millis() / 500) % 2) == 0;
            lv_obj_set_style_opa(g_loggingLblSelect, blinkOn ? LV_OPA_COVER : LV_OPA_TRANSP, 0);
        } else {
            lv_obj_set_style_opa(g_loggingLblSelect, LV_OPA_TRANSP, 0);
        }
    }

    // 受信済みのデコード結果をタイルへ反映(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 GPS_ROW_HEIGHT = 26; // ★GPSステータス行の高さ分をグリッドの上に確保する
    const int32_t GPS_TOP = BTN_TOP + BTN_HEIGHT + GRID_GAP;
    const int32_t GRID_TOP = GPS_TOP + GPS_ROW_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);

    // [GPSステータス] ボタン行とタイルグリッドの間に、細い行として表示する
    // (未取得時は"-"、実際の値はrefreshTimerCbが200ms毎に更新する)
    lv_obj_t* gpsLblDash = lv_label_create(screenDash);
    lv_label_set_text(gpsLblDash, "GPS: -");
    lv_obj_set_style_text_color(gpsLblDash, lv_color_hex(0x9E9E9E), 0);
    lv_obj_set_pos(gpsLblDash, 10, GPS_TOP);
    g_gpsStatusLblDash = gpsLblDash;

    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 idx = 0; idx < PID_COUNT; idx++) {
        size_t i = g_pidOrder[idx]; // ★PID選択画面のドラッグ&ドロップ並び順とタイル配置を一致させる
        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; // 何も選ばれていなければ何もしない

    // ★並べ替え(ドラッグ/Undo/Reset)はメモリ上でのみ保持され、Saveボタンを
    // 押すまでNVSへ保存されない設計にしたが、「Saveを押し忘れたままStart
    // Monitoringした場合」に変更が失われるのは不親切なため、ここでも
    // 未保存の変更(g_pidOrderDirty)があれば保存しておく(Saveボタンと同じ
    // くチェックポイントとしてUndo履歴もクリアする)。
    if (g_pidOrderDirty) {
        savePidOrder();
        g_pidOrderDirty = false;
        g_pidOrderHistoryCount = 0;
        updateOrderButtonStates();
    }

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

    // ★ロギングの開始・継続・切り替えの判断はここで行う(詳細はファイル
    // 冒頭コメント16.参照)。Back中も裏でロギングを継続する設計にしたため、
    // 「押すたびに無条件で新規ファイルを開く」わけにはいかない。
    //   - まだファイルが開いていない → 新規に開く
    //   - 既に開いていて、PID選択が open時点と同じ → そのまま継続(切替不要)
    //   - 既に開いていて、PID選択が変わった → 一旦閉じてから新しい
    //     列構成で開き直す(sdQueueはFIFOなので、CLOSE_LOGの直後に
    //     OPEN_LOGを積めば「閉じてから開く」順序が保証される)
    //   - 「Save to SD」が無効 → 開いていれば閉じてロギングを止める
    bool wantLogging = saveToSdEnabled && sdAvailable;
    uint64_t currentMask = computeCurrentPidMask();

    if (wantLogging) {
        if (!logFileOpen) {
            requestOpenLogFile(); // 初回、またはEjectで一旦閉じられていた場合など
        } else if (currentMask != g_loggedPidMask) {
            requestCloseLogFile();
            requestOpenLogFile();
        }
        // currentMask == g_loggedPidMask かつ既に開いている場合は何もしない(継続)
        loggingActiveForUI = true; // 「LOGGING」表示用(実際のopen完了は待たない楽観的フラグ)
    } else {
        if (logFileOpen) {
            requestCloseLogFile();
        }
        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はBackを押しても削除されず裏で動き続けているため、
    // まだ存在しない場合(=本当に初回の起動)だけ作成する。
    // 既に存在するのに再度lv_timer_create()してしまうと、2つのタイマーが
    // 同時にrequestWriteLogRow()/PID要求を行い、CSVの行が二重に書き込
    // まれるなどの不具合になるため、二重生成は必ず避けること。
    if (!pollTimer) {
        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());

    // ★GPSはコールドスタート時に測位まで数十秒かかることがあるため、
    // 電源投入・UART/1PPS初期化はできるだけ早いタイミングで行っておく
    // (消費RAMはごく小さいため、BLE初期化前に行っても断片化への
    // 影響は無視できる)。GPS読み取りタスク自体の起動は、他の専用
    // タスク(SD)と同じくBLE初期化(gateway.begin())より後で行う。
    initGpsModule();
    Serial.printf("[1c] after initGpsModule: 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書き込み専用タスクを起動(メインループを絶対にブロックしないため)

    startGpsTaskIfNeeded(); // ★GPS読み取り専用タスクを起動(BLE初期化後、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 * 8; //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選択状態を復元
    restorePidOrder();     // ★同じく、並び順(ドラッグ&ドロップで変更した表示順)も復元
    buildSelectScreen();
    lv_screen_load(screenSelect);

    refreshTimer = lv_timer_create(refreshTimerCb, 200, NULL); // GUI反映は200ms間隔
    imuRtcTimer = lv_timer_create(imuRtcTimerCb, 150, NULL);   // IMUによる画面回転判定 + GPS初回時刻取得時のRTC同期
}

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

一旦、ここまでで完成とします。
もう少し改良するかもしれませんが、現時点は未定です。

(1)から始まり IoT工作としては、まあまあの規模になったと思います。

以上

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?