////////////////////////////////////////////////////
// M5Stack用マスフローデータ集約・可視化プログラム
// UECS-MassFlowMonitor ver. 1.0
// このプログラムは、無償提供する代わりに、動作結果に関する
// 一切の保証は致しません。自己責任でお使いください。また、
// 改造も自由ですが、公刊等をされる場合は、「近畿大学　塩田
//　智亜・星 岳彦が開発したプログラムを用いた」
// と引用して下さいますと、今後の研究開発の励みになりますので、
// どうぞよろしくお願いします。また、本プログラムに関する
// お問い合わせは、星(hoshi@hoshi-lab.net)までお願いします。
// 　　　　　               　　　 2026.3.6 Tomoa Shiota
// 
//　　　　　[簡易な使用準備と使い方説明書]
// このプログラムは、M5Satck BASIC development kitの
// ver.2.6-7向きに開発しました。
// https://docs.m5stack.com/en/core/basic_v2.6
// 書き込みは、Arduino IDE 1.8.15以降を使います。これ
// をパソコンにインストールし、このプログラムファイルを
// IDEで開いてください。まず、M5stackのUSB経由で書込
// み接続するドライバ CP210x_VCP_Windowをインストー
// ルします。次に、このプログラムが表示されているIDEから、
// 上のメニューのファイル→環境設定で開くウインドウの追
// 加のボードマネージャのURL欄に、
// https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/arduino/package_m5stack_index.json
// を書き込みます、そして、M5stackをIDEが動作するパソコン
// USB接続し、メニューのツールから、
// ボード→ボードマネージャ
// を選んで、検索欄にm5stackを入れます。すると、下の
// ボードリストにMStackが出ますので、それをインストー
// ルします。再度、メニューのツールから、
// ボード→EAP32 Arduino→M5Stack-Core-ESP32
// シリアルボート→M5Stackが接続しているcom
// を選択してください。
// 最後に、メニューのスケッチから、「マイコンボードに
// 書き込む」を選択するとプログラムをコンパイルして、
// 書き込めます。少し時間がかかりますので、気長に待っ
// てください。
// 書き込みが終われば、あとは、2GB程度のマイクロSD
// カードを用意し、そこに、WiFi.sysというファイル
// を作り、UECSの繋がっているWiFiのSSIDと暗号キー
// (パスワード)を2行のテキストで書き込み、M5Stack
// に挿します。そのあと、SDカードをM5Stackに差し込
// み、リセットボタンを押せば、自動的にWiFi接続し、
// ノード設定後に可視化がはじまります。
// 具体的な使い方については、ツール使い方説明書をご参照ください。
/*
-----------------------------------------------------------------------------
UECS-MassFlowMonitor software are distributed under the 2 clause BSD license.

Copyright 2026, Tomoa Shiota and Takehiko Hoshi All rights reserved.

Redistribution and use in source and binary forms, with or without 
modification, are permitted provided that the following conditions are met:

 1. Redistributions of source code must retain the above copyright notice, 
    this list of conditions and the following disclaimer.

 2. Redistributions in binary form must reproduce the above copyright notice, 
    this list of conditions and the following disclaimer in the documentation
    and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
POSSIBILITY OF SUCH DAMAGE.
-----------------------------------------------------------------------------
LU-OTMEASソフトウェアは、2条項BSDライセンスの下に配布されます。

Copyright 2026, Tomoa Shiota and Takehiko Hoshi All rights reserved.

  ソースコード形式かバイナリ形式か、変更するかしないかを問わず、以下の条件を
満たす場合に限り、再頒布および使用が許可されます。

1.ソースコードを再頒布する場合、上記の著作権表示、本条件一覧、および下記免責
  条項を含めること。
2.バイナリ形式で再頒布する場合、頒布物に付属のドキュメント等の資料に、上記の
  著作権表示、本条件一覧、および下記免責条項を含めること。

  本ソフトウェアは、著作権者およびコントリビューターによって「現状のまま」提供
されており、明示黙示を問わず、商業的な使用可能性、および特定の目的に対する適合
性に関する暗黙の保証も含め、またそれに限定されない、いかなる保証もありません。
著作権者もコントリビューターも、事由のいかんを問わず、 損害発生の原因いかんを
問わず、かつ責任の根拠が契約であるか厳格責任であるか（過失その他の）不法行為で
あるかを問わず、仮にそのような損害が発生する可能性を知らされていたとしても、
本ソフトウェアの使用によって発生した （代替品または代用サービスの調達、使用の
喪失、データの喪失、利益の喪失、業務の中断も含め、またそれに限定されない）直接
損害、間接損害、偶発的な損害、特別損害、懲罰的損害、または結果損害について、
一切責任を負わないものとします。
-----------------------------------------------------------------------------
*/
//---------------------- グローバル定義 ----------------------
// ヘッダ部分
#include <M5Stack.h>   
#include <WiFi.h>      
#include <WebServer.h> 
#include <WiFiUdp.h>   
#include <SD.h>        

// 通信関連オブジェクト
WebServer server(80); 
WiFiUDP udp;          

const int UDP_PORT = 16520;  //UECS用UDPポート番号
char incomingPacket[512];   

// ノード数管理　
#define MAX_NODES 20         //最大20機器まで保持

// マスフロー数管理
#define MAX_CATS 3           //可視化できる物質は1機器につき3つまで

//入力したノード情報を管理
struct Node {
  String IPaddress;               //IPアドレスを保持
  String type;                    //Type名を保持
  int room;                       //room値を保持
  int region;                     //region値を保持
  int order;                      //order値を保持
  int priority;                   //priority値を保持
  String category[MAX_CATS];      //どの物質に関する推定式かを保持
  double a[MAX_CATS];             //推定式の傾きを保持
  double b[MAX_CATS];             //推定式の切片を保持
  float cfpFuel = 0.000f;         //燃料消費による排出係数を保持  
  float lastValue;                //最新受信値の保存
  bool hasLast;                   //値が利用可能かの判別フラグ
  bool needsBaseValueCorrection;  //基点補正用フラグ
  double baseOffset[MAX_CATS];    //基点補正用の値の保持
};

Node nodes[MAX_NODES]; 
int nodeCount = 0;

// CFP計算用排出係数管理
struct Cfpconfig {
  float CFPEpower = 0.000f; //発電による排出係数を保持
};
Cfpconfig cfpconfig;

// 結果保存用関数
String nodeTimeStr = ""; 
String todayDate = "";     
String lastSavedDate = "";  
String nodeDateStr = ""; 
long lastTime = 0;           
bool initialize = true;   //初期化中かの判別フラグ
bool DateSave = false;    //日次保存後かの判別フラグ
bool DateSaving = false;  //日次保存中かの判別フラグ

// 各値を記憶する関数
double dailyCFP, dailyVW, dailyEpower, dailyOil, dailyGas; //本日の結果
double yCFP, yVW, yEpower, yOil, yGas;                     //前日の結果
double tCFP, tVW, tEpower, tOil, tGas;                     //本日までの累計値
double sumCFP, sumVW, sumEpower, sumOil, sumGas;           //前日までの累計値

// M5Stackディスプレイ表示用関数
unsigned long lastRecvTime = 0;             //受信時間管理用タイマー
unsigned long lastLcdUpdate = 0;            //ディスプレイ更新タイマー
int displayMode = 1;                        //ディスプレイ表示モード　0=昨日 1=今日 2=累積

// SD上のファイル名
const char * WIFI_CONFIG_FILE = "/WiFi.sys"; // WiFi設定ファイル
const char * NODE_CONFIG_FILE = "/nodes.csv"; // ノード設定ファイル
const char * RESULT_FILE = "/result.csv"; // 結果ファイル
const char * CFP_CONFIG_FILE = "/cfpconfig.csv"; //発電による排出係数保存ファイル


//---------------------- HTMLテンプレート ----------------------
//フォントサイズやページ上部のリンク集などを設定
String renderPage(const String& title, const String& body) {
  String css = "<style>"
    "select.category-select { font-size: 24px !important; line-height: 1.2; padding: 10px; }"
    "body{font-family:sans-serif;padding:20px;background:#f0f0f0;font-size:36px;}"
    "h1{color:#008080;font-size:48px;}"
    "nav a{margin-right:10px;text-decoration:none;color:#005588;font-size:22px;border-right:1px solid #888}"
    "nav a:last-child{border-right:none;}"
    "input,select,button{font-size:28px;padding:8px;margin:10px 0;}"
    "input[type='checkbox']{transform: scale(2);margin:10px;vertical-align:middle;}"
    "table{border-collapse:collapse;width:100%;table-layout:fixed;}"
    "th,td{border:1px solid #888;padding:10px;text-align:center;font-size:28px;}"
    "th{background-color:#ddd;}"
    "td:nth-child(2) {"
      "text-align: left;"
      "padding-right: 20px;"
      "display: flex;"
      "justify-content: space-between;"
    "}"
    "td:nth-child(2) {text-align: right;}"
    "#chart {width: 900px; height: 900px;display: block;margin: 0 auto;}"
    "</style>";

  String nav = "<nav>"
    "<a href='/'>ホーム</a>"
    "<a href='/nodeconfig'>ノード設定</a>"
    "<a href='/today'>本日のマスフロー</a>"
    "<a href='/daily'>前日のマスフロー</a>"
    "<a href='/total'>マスフローのシーズン累計</a>"
    "<a href='/graph'>グラフ表示</a>"
    "<a href='/reset' onclick=\"return confirm ('本当に今日の集計をリセットしますか？');\">リセット</a>" //リセット時の警告
    "<a href='/qr'>QRコード</a>"
    "</nav><hr>";
    
    return "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>" 
       + title + "</title>" + css + "</head><body>" + nav + body + "</body></html>";
 }


//---------------------- SDファイル操作(WiFi,ノード情報,マスフロー保存管理) ----------------------
//ファイルからのWiFi情報の読み込み
void loadWiFiConfig(char* ssid, char* pass) { 
  if (!SD.exists(WIFI_CONFIG_FILE)) return;
  File f = SD.open(WIFI_CONFIG_FILE, FILE_READ);
  if (!f) return;
  String s = f.readStringUntil('\n'); s.trim(); s.toCharArray(ssid, 32);
  String p = f.readStringUntil('\n'); p.trim(); p.toCharArray(pass, 32);
  f.close();
}

 // ノード情報をファイルへ保存
void saveNodes() {                          
  File f = SD.open(NODE_CONFIG_FILE, FILE_WRITE);
  if (!f) {
    return;
  }

  for (int i = 0; i < nodeCount; i++) {      //type,room,region,order,priorityの保存
    f.print(nodes[i].IPaddress); f.print(',');
    f.print(nodes[i].type); f.print(',');
    f.print(nodes[i].room); f.print(',');
    f.print(nodes[i].region); f.print(',');
    f.print(nodes[i].order); f.print(',');
    f.print(nodes[i].priority); f.print(',');

    for (int j = 0; j < MAX_CATS; j++) {     //傾き,切片,どの物質かを保存    
      f.print(nodes[i].a[j], 9);
      f.print(',');
    }  
    for (int j = 0; j < MAX_CATS; j++) {// 
      f.print(nodes[i].b[j], 9);
      f.print(',');
    }   
    for (int j = 0; j < MAX_CATS; j++) {// 
      f.print(nodes[i].category[j]);
      f.print(',');
    }
    f.print(nodes[i].cfpFuel,4); f.print(',');
    for (int j = 0; j < MAX_CATS; j++) {    //基点補正に用いる基点を保存
      f.print(nodes[i].baseOffset[j], 10);
      if (j < MAX_CATS - 1) f.print(','); 
    }
    f.println();
  }
  f.close();
}

//saveNodes()で保存されたノード情報をファイルから読み込み
void loadNodes() {                           
  nodeCount = 0;
  if (!SD.exists(NODE_CONFIG_FILE)) return;
  File f = SD.open(NODE_CONFIG_FILE, FILE_READ);
  
  const int FIELD_COUNT = 19;
  while (f.available() && nodeCount < MAX_NODES) {
    String line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0) continue;
    String fields[FIELD_COUNT];
    int start = 0; 
    for (int i = 0; i < FIELD_COUNT; i++) { 
      int  comma = line.indexOf(',', start);
        if (comma < 0) {
          fields[i] = line.substring(start);
          break;
        }
      fields[i] = line.substring(start, comma);
      start = comma + 1;
    }

    nodes[nodeCount].IPaddress = fields[0];
    nodes[nodeCount].type   = fields[1];
    nodes[nodeCount].room   = fields[2].toInt();
    nodes[nodeCount].region = fields[3].toInt();
    nodes[nodeCount].order    = fields[4].toInt();
    nodes[nodeCount].priority = fields[5].toInt();
    for (int i = 0; i < MAX_CATS; i++) {
      nodes[nodeCount].a[i]        = fields[6 + i].toDouble();
      nodes[nodeCount].b[i]        = fields[9 + i].toDouble();
      nodes[nodeCount].category[i] = fields[12 + i];
    }
    if (fields[15].length() > 0) {
      nodes[nodeCount].cfpFuel = fields[15].toFloat();
    } else {
      nodes[nodeCount].cfpFuel = 0.0f;
    }
    for (int i = 0; i < MAX_CATS; i++) {
      if (fields[16 + i].length() > 0) {
        nodes[nodeCount].baseOffset[i] = fields[16 + i].toDouble();
      } else {
        nodes[nodeCount].baseOffset[i] = 0.0d;
      }
     }

    nodes[nodeCount].lastValue = 0;
    nodes[nodeCount].hasLast   = false;
    nodes[nodeCount].needsBaseValueCorrection = true;
    nodeCount++;
  }
  f.close();
}

// 本日の値更新関数(昨日との差分 = 累積日次値 - 昨日の累積値　これが本日の値)
void updateDailyValue() {  
  totalResult(sumCFP, sumVW, sumEpower, sumOil, sumGas);
  dailyCFP = max(0.0d, tCFP - sumCFP); 
  dailyVW = max(0.0d, tVW - sumVW);
  dailyEpower = max(0.0d, tEpower - sumEpower);
  dailyOil = max(0.0d, tOil - sumOil);
  dailyGas = max(0.0d, tGas - sumGas);
}

//本日の結果のファイルへの保存(本日の日付を検索し、なければ追記し、あれば上書き保存する)
void appendDailyResult(String SaveDate) {                
  if (SaveDate.length() < 8) return;
  if (dailyCFP == 0 && dailyVW == 0 && dailyEpower == 0 && dailyOil == 0 && dailyGas == 0) {
    return;
  }
  bool fileExists = SD.exists(RESULT_FILE);
  String all = "";
  if (SD.exists(RESULT_FILE)) {
    File f = SD.open(RESULT_FILE, FILE_READ);
    if (f) {
      while (f.available()) {
        all += (char)f.read();
      }
      f.close();
    }
  }
  String newFile = "";
  bool foundSaveDate = false;
  int start = 0;
  while (true) {
    int nl = all.indexOf('\n', start);
    if (nl == -1) nl = all.length();
    String line = all.substring(start, nl);
    line.trim();
    if (line.length() > 0) {
      if (line.startsWith(SaveDate)) {
        newFile += String(SaveDate) + "," +
                   String(dailyCFP, 3) + "," +
                   String(dailyVW, 3) + "," +
                   String(dailyEpower, 3) + "," +
                   String(dailyOil, 3) + "," +
                   String(dailyGas, 3) + "\n";
        foundSaveDate = true;
      } else {
        newFile += line + "\n";
      }
    }
    if (nl >= all.length()) break;
    start = nl + 1;
  }

  if (!foundSaveDate) {
    newFile += String(SaveDate) + "," +
               String(dailyCFP, 3) + "," +
               String(dailyVW, 3) + "," +
               String(dailyEpower, 3) + "," +
               String(dailyOil, 3) + "," +
               String(dailyGas, 3) + "\n";
  }
  
  if (!fileExists) {      // ファイルが存在しなかった場合はヘッダを先頭に追加                          
    newFile = "Date,CFP,VW,Epower,Oil,Gas\n" + newFile;
  }

  File f = SD.open(RESULT_FILE, FILE_WRITE);
  if (!f) return;
  f.print(newFile);
  f.close();
  lastSavedDate = SaveDate;
}

//昨日の結果をファイルから読み込む(厳密には、日付が本日でない最新の結果)
void yesterdayResult(double &yCFP, double &yVW, double &yEpower, double &yOil, double &yGas) { 
  yCFP = yVW = yEpower = yOil = yGas = 0;
  if (!SD.exists(RESULT_FILE)) return;
  File f = SD.open(RESULT_FILE, FILE_READ);
  if (!f) return;
  f.readStringUntil('\n');
  String line;
  String lastLine = "";

  while (f.available()) {
    line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0) continue;
    String dateStr = line.substring(0,8);
    if (dateStr != todayDate) {           
      lastLine = line;                    
    }  
  }
  f.close();
  if (lastLine.length() == 0) return;

  int idx1 = lastLine.indexOf(',');
  int idx2 = lastLine.indexOf(',', idx1 + 1);
  int idx3 = lastLine.indexOf(',', idx2 + 1);
  int idx4 = lastLine.indexOf(',', idx3 + 1);
  int idx5 = lastLine.indexOf(',', idx4 + 1);
  if (idx5 < 0) return;
  yCFP     = lastLine.substring(idx1 + 1, idx2).toDouble();
  yVW      = lastLine.substring(idx2 + 1, idx3).toDouble();
  yEpower  = lastLine.substring(idx3 + 1, idx4).toDouble();
  yOil     = lastLine.substring(idx4 + 1, idx5).toDouble();
  yGas     = lastLine.substring(idx5 + 1).toDouble();
}

//昨日までの累計値を計算
void totalResult(double &sumCFP,double &sumVW,double &sumEpower,double &sumOil, double &sumGas) { 
  sumCFP = sumVW = sumEpower = sumOil = sumGas = 0;
  if (!SD.exists(RESULT_FILE)) return;
  File f = SD.open(RESULT_FILE, FILE_READ);
  if (!f) return;
  f.readStringUntil('\n');

  while (f.available()) {
    String line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0) continue;
    int idx1 = line.indexOf(',');
    int idx2 = line.indexOf(',', idx1 + 1);
    int idx3 = line.indexOf(',', idx2 + 1);
    int idx4 = line.indexOf(',', idx3 + 1);
    int idx5 = line.indexOf(',', idx4 + 1);
    if (idx5 < 0) continue;
    String dateStr = line.substring(0, 8);
    if (dateStr == todayDate) {
      continue; 
    }
    sumCFP    += line.substring(idx1 + 1, idx2).toDouble();
    sumVW     += line.substring(idx2 + 1, idx3).toDouble();
    sumEpower += line.substring(idx3 + 1, idx4).toDouble();
    sumOil    += line.substring(idx4 + 1, idx5).toDouble();
    sumGas    += line.substring(idx5 + 1).toDouble();
  }
  f.close();
}

// 発電による排出係数をSDカードに保存(cfpconfig.csv)
void saveCfpConfig() {  
  File f = SD.open(CFP_CONFIG_FILE, FILE_WRITE);
  if (!f) {
    return;
  }
  f.print(String(cfpconfig.CFPEpower, 4));
  f.close();
}

// 発電による排出係数をSDカードから読み込み(cfpconfig.csv)
void loadCfpConfig(){ 
  File f=SD.open(CFP_CONFIG_FILE, FILE_READ);
  if(!f){
    return;
  } 
  String line = f.readStringUntil('\n');
  f.close();
  line.trim();
  if (line.length() > 0) {
    cfpconfig.CFPEpower = line.toFloat();
  }
}

//---------------------- 日付管理(Dateパケットによる)・時刻管理(Timeパケットによる)----------------------
//ディスプレイ表示やファイル保存用に本日の日付を管理
void updateDate(String packetStr) {   
  int ts = packetStr.indexOf("type=\"Date\"");
  if (ts < 0) return;
  int ds = packetStr.indexOf(">", packetStr.indexOf("<DATA"));
  int de = packetStr.indexOf("</DATA>", ds);
  if (ds < 0 || de < 0) return;
  nodeDateStr = packetStr.substring(ds + 1, de);
  if (nodeDateStr.length() < 6) return;
  String newtodayDate = "20" + nodeDateStr; 

  if (todayDate.length() < 8) {
    todayDate = newtodayDate;
    yesterdayResult(yCFP, yVW, yEpower, yOil, yGas);
    return;
  }
  
  if (newtodayDate != todayDate) {
    if (DateSave) {
      todayDate = newtodayDate;
      DateSave = false;
    } 
  }
}

// 日次保存用に現在時刻を管理
void updateTime(String packetStr) { 
  int ts = packetStr.indexOf("type=\"Time\"");
  if (ts < 0) return;
  int ds = packetStr.indexOf(">", packetStr.indexOf("<DATA"));
  int de = packetStr.indexOf("</DATA>", ds);
  if (ds < 0 || de < 0) return;
  if(DateSaving) return;

  String nodeTimeStr = packetStr.substring(ds + 1, de); // Timeデータの文字列を取得 (例: "235959")
  char value_c[7]; 
  nodeTimeStr.toCharArray(value_c, 7);
  if (value_c[0] == '\0') return;

  char R_align[7] = "000000" ; // HHMMSS形式の時刻を格納し、0で初期化
  uint8_t len = strlen(value_c);
  uint8_t elm1 = 0 ;
    
  for(uint8_t elm2 = 6 - len; elm2 < 6; elm2++) { // 0時0分0秒の"0"や"100"などの短い形式に対応するため右寄せ処理
    R_align[elm2] = value_c[elm1++] ;             // 例: "100" -> "000100"(00:01:00)
  }
    
  long Th = (R_align[0] - '0') * 10 + (R_align[1] - '0') ; //時・分を抽出し、HHMM00 形式の整数 (currentTime) を計算
  long Tm = (R_align[2] - '0') * 10 + (R_align[3] - '0') ; 
  long currentTime = Th * 10000 + Tm * 100;  // HHMM00 形式の currentTime を生成 (秒は無視し 00 とする)

  if (lastTime > 230000 && currentTime < 10000 && !DateSave) { //23時台から0時台になった時に日次保存
    DateSaving = true;
    lastTime = currentTime;
    appendDailyResult(todayDate);                    //本日の結果を日次保存
    DateSave = true;
    yesterdayResult(yCFP, yVW, yEpower, yOil, yGas); //前日の結果(上で日次保存した結果)の読み込み
    sumCFP += dailyCFP;                              //前日までの累計値に保存した結果を加算
    sumVW  += dailyVW;
    sumEpower += dailyEpower;
    sumOil += dailyOil;
    sumGas += dailyGas;
    dailyCFP = dailyVW = dailyEpower = dailyOil = dailyGas = 0; //本日の値を0にリセット
    tCFP = sumCFP;                                              //本日までの累計値を前日までの累計値と合わせる
    tVW = sumVW;
    tEpower = sumEpower;
    tOil = sumOil;
    tGas = sumGas;
    for (int i = 0; i < nodeCount; i++) {.           //基点補正
      nodes[i].needsBaseValueCorrection = true;
      nodes[i].lastValue = 0.0f;
      nodes[i].hasLast = false;
      for (int c = 0; c < MAX_CATS; c++) {
        nodes[i].baseOffset[c] = 0.0d;
      }
    }
    DateSaving = false;
  } else {
    lastTime = currentTime;                           
  }
}


//---------------------- UDP受信解析および計算 ----------------------
//受信したUECS形式パケットに含まれる、ノード情報を参照し、入力情報と合致した場合はマスフロー計算に利用。
void parsePacket() {     
  if (initialize || DateSaving) return;

  int packetSize = udp.parsePacket();
  if (!packetSize) return;
  int len = udp.read(incomingPacket, sizeof(incomingPacket)-1);
  if (len <= 0) return;
  incomingPacket[len] = 0;
  String p = String(incomingPacket);

  updateDate(p); //'Date'パケットの場合、日付更新
  updateTime(p); //'Time'パケットの場合、時刻更新
  if (p.indexOf("type=\"Time\"") >= 0 || p.indexOf("type=\"Date\"") >= 0) {
    updateDailyValue();
    return; 
  }

  //パケットを解析し、任意のパケットであるかを確認
  int ts = p.indexOf("type=\"");
  int te = (ts>=0)? p.indexOf("\"", ts+6) : -1;
  int rs = p.indexOf("room=\"");
  int re = (rs>=0)? p.indexOf("\"", rs+6) : -1;
  int rgs = p.indexOf("region=\"");
  int rge = (rgs>=0)? p.indexOf("\"", rgs+8) : -1;
  int os  = p.indexOf("order=\"");
  int oe  = (os  >= 0) ? p.indexOf("\"", os  + 7)  : -1;
  int ps  = p.indexOf("priority=\"");
  int pe  = (ps  >= 0) ? p.indexOf("\"", ps  + 10) : -1;
  int ip_tag_s = p.indexOf("<IP>");
  int ip_tag_e = p.indexOf("</IP>");
  if (ts == -1 || te == -1 || rs == -1 || re == -1 || rgs == -1 || rge == -1|| ip_tag_s == -1 || ip_tag_e == -1) return;
  String type = p.substring(ts+6, te);
  int room = p.substring(rs+6, re).toInt();
  int region = p.substring(rgs+8, rge).toInt();
  int order      = (os >= 0 && oe >= 0) ? p.substring(os + 7, oe).toInt() : 0;
  int priority   = (ps >= 0 && pe >= 0) ? p.substring(ps + 10, pe).toInt() : 99;
  String nodeIpAddress = p.substring(ip_tag_s + 4, ip_tag_e);
  int ds2 = p.indexOf(">", p.indexOf("<DATA"));
  int de2 = p.indexOf("</DATA>", ds2);
  if (ds2 == -1 || de2 == -1) return;
  float val = p.substring(ds2+1, de2).toFloat();

  //電力および燃料消費の一つ前に受信した値との差分を保持
  double diffEpower = 0.0d; 
  double diffFuel   = 0.0d;
  float nodeCfpFuel = 0.0f;
  
  //ノードごとに入力情報を参照し、合致すれば計算。マスフローごとに分類し、集計。
    bool accumulation = false;
    for (int i = 0; i < nodeCount; i++) {
      if (nodes[i].IPaddress == nodeIpAddress &&
          nodes[i].type == type && nodes[i].room == room && nodes[i].region == region &&
          nodes[i].order == order && nodes[i].priority == priority) {
        nodeCfpFuel = nodes[i].cfpFuel;
        if (nodes[i].needsBaseValueCorrection) {
          nodes[i].hasLast = true;
          for (int c = 0; c < MAX_CATS; c++) {
            double currentCal = nodes[i].a[c] * val + nodes[i].b[c];
            nodes[i].baseOffset[c] = currentCal;
          }
          nodes[i].lastValue = val;
          nodes[i].needsBaseValueCorrection = false;
          accumulation = false;
          break;
        }
        
        if (nodes[i].hasLast && val < nodes[i].lastValue * 0.5) {
          nodes[i].lastValue = val;
          accumulation = false;
          break;
        }
        
        for (int c = 0; c < MAX_CATS; c++) {
          if (nodes[i].category[c].length() == 0) continue;
          double oldCalc = nodes[i].hasLast ? nodes[i].a[c] * nodes[i].lastValue + nodes[i].b[c] : 0.0;
          double newCalc = nodes[i].a[c] * val + nodes[i].b[c];
          double diff = newCalc - oldCalc;
          if (nodes[i].category[c] == "VW") { 
            tVW += diff;
          }
          else if (nodes[i].category[c] == "Epower") {
            tEpower += diff;
            diffEpower += diff;
          }
          else if (nodes[i].category[c] == "Oil") {
            tOil += diff;
            diffFuel += diff;
          }
          else if (nodes[i].category[c] == "Gas") {
            tGas += diff;
            diffFuel += diff;
          }
        }
        nodes[i].lastValue = val;
        nodes[i].hasLast = true;
        accumulation = true;
        break; // IP+type+room+region+order+priority で一致したらスキップ
      }
    }
  
  //CO2排出量の計算
  if (accumulation) {
    double cfpDiff = 0.0d;
    cfpDiff += diffEpower * cfpconfig.CFPEpower; //電力消費によるCO2排出量の計算
    cfpDiff += diffFuel * nodeCfpFuel;  // Oil,Gasどちらか一方の増分 × ノードごとの設定排出係数
    tCFP += cfpDiff; 
  }
    updateDailyValue(); //本日の値の更新
    lastRecvTime = millis();
}

// -----------マスフローデータのグラフ化-------------
String loadRecentDataJSON(int days = 7) { //ブラウザのホーム画面で過去七日間のマスフローグラフを作成する
 if (!SD.exists(RESULT_FILE)) return "[]";
  File f = SD.open(RESULT_FILE, FILE_READ);
  if (!f) return "[]";
  if (f.available()) {
    f.readStringUntil('\n');
  }
  String recentLines[days]; 
  int cnt = 0;
  int linesCount = 0;
  while (f.available()) {
    String line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0) continue;
    if (linesCount < days) {
      recentLines[linesCount++] = line; 
    } else {
      recentLines[cnt % days] = line; 
    }
    cnt++;
  }
  f.close();

  String json = "[";
  bool first = true;
  int totalLinesToProcess = (cnt < days) ? cnt : days;
  int startIndex = (cnt >= days) ? (cnt % days) : 0; 
  
  for (int i = 0; i < totalLinesToProcess; i++) {
    String l = recentLines[(i + startIndex) % days]; 
    int p1 = l.indexOf(',');
    int p2 = l.indexOf(',', p1+1);
    int p3 = l.indexOf(',', p2+1);
    int p4 = l.indexOf(',', p3+1);
    int p5 = l.indexOf(',', p4+1);
    if (p1<0 || p2<0 || p3<0 || p4<0 || p5<0) continue;
    String date = l.substring(0,p1);
    String cfp = l.substring(p1+1,p2);
    String vw = l.substring(p2+1,p3);
    String ep = l.substring(p3+1,p4);
    String oil = l.substring(p4+1,p5);
    String gas = l.substring(p5+1);
    if (!first) json += ",";
    json += "{\"date\":\"" + date + "\",\"CFP\":" + cfp + ",\"VW\":" + vw + ",\"Epower\":" + ep + ",\"Oil\":" + oil + ",\"Gas\":" + gas + "}";
    first = false;
  }

  if (todayDate.length() >= 8) { 
    bool lastIsToday = false;
    if (totalLinesToProcess > 0) {
      String lastLine = recentLines[(totalLinesToProcess - 1 + startIndex) % days];
      if (lastLine.startsWith(todayDate)) lastIsToday = true;
    }
    
    if (!lastIsToday) {
      if (!first) json += ",";
      json += "{\"date\":\"" + todayDate + "\",\"CFP\":" + String(dailyCFP,3) + ",\"VW\":" + String(dailyVW,3)
            + ",\"Epower\":" + String(dailyEpower,3) + ",\"Oil\":" + String(dailyOil,3) + ",\"Gas\":" + String(dailyGas,3) + "}";
    }
  }
  json += "]";
  return json;
}

String loadDataBetween(String startTime, String endTime) { // ブラウザ上で指定した期間のグラフを作成する
  if (!SD.exists(RESULT_FILE)) return "[]";
  File f = SD.open(RESULT_FILE, FILE_READ);
  if (!f) return "[]";
  String json = "[";
  bool first = true;
  while (f.available()) {
    String line = f.readStringUntil('\n');
    line.trim();
    if (line.length() == 0) continue;
    String dateStr = line.substring(0, 8); // YYYYMMDDの日付
    if (dateStr >= startTime.substring(0,8) && dateStr <= endTime.substring(0,8)) {
      int p1 = line.indexOf(',');
      int p2 = line.indexOf(',', p1 + 1);
      int p3 = line.indexOf(',', p2 + 1);
      int p4 = line.indexOf(',', p3 + 1);
      int p5 = line.indexOf(',', p4 + 1);
      if (p1 < 0 || p2 < 0 || p3 < 0 || p4 < 0 || p5 < 0) continue;
      String date = line.substring(0, p1);
      String cfp = line.substring(p1 + 1, p2);
      String vw = line.substring(p2 + 1, p3);
      String ep = line.substring(p3 + 1, p4);
      String oil = line.substring(p4 + 1, p5);
      String gas = line.substring(p5 + 1);  
      if (!first) json += ",";
      json += "{\"date\":\"" + date + "\",\"CFP\":" + cfp + ",\"VW\":" + vw + ",\"Epower\":" + ep + ",\"Oil\":" + oil + ",\"Gas\":" + gas + "}";
      first = false;
    }
  }
  f.close();

  if (todayDate.length() >= 8 && todayDate >= startTime.substring(0,8) && todayDate <= endTime.substring(0,8)) { // グラフ末尾に今日のデータを追加
     if (!first) json += ",";
     json += "{\"date\":\"" + todayDate + "\",\"CFP\":" + String(dailyCFP, 3) + ",\"VW\":" + String(dailyVW, 3)
            + ",\"Epower\":" + String(dailyEpower, 3) + ",\"Oil\":" + String(dailyOil, 3) + ",\"Gas\":" + String(dailyGas, 3) + "}";
  }
  json += "]";
  return json;
}


//---------------------- M5Stackディスプレイ表示 ----------------------
void updateLcd() {
  //3画面共通部分(ツールIPアドレスや日付など)
  M5.Lcd.fillScreen(TFT_BLACK);
  M5.Lcd.drawRect(3, 15, 312, 200, YELLOW);
  
  M5.Lcd.setTextColor(YELLOW);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(10,0);
  M5.Lcd.println("MassFlow Monitor by UECS*");
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(10,22); 
  M5.Lcd.printf("%s",todayDate.c_str());
  M5.Lcd.printf(" IP:%s",WiFi.localIP().toString().c_str());
  M5.Lcd.drawRect(3, 40, 312, 200, YELLOW);
  
  if (displayMode == 1) { //本日の結果表示画面
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(YELLOW);
    M5.Lcd.setCursor(15, 50);
    M5.Lcd.print("Present   (/house/today)");
    int sy = 85, dy = 28;
    M5.Lcd.setTextColor(WHITE);
    LcdLine(sy,      "CO2:",   dailyCFP,    "kg");
    LcdLine(sy+dy,   "Wat:", dailyVW,     "L");
    LcdLine(sy+dy*2, "Epw:",  dailyEpower, "kWh");
    M5.Lcd.setCursor(15, sy+dy*3); M5.Lcd.print("Fuel:");
    M5.Lcd.setCursor(75, sy+dy*3); M5.Lcd.printf("%6.2f L", dailyOil);
    M5.Lcd.setCursor(175, sy+dy*3); M5.Lcd.print("|");
    M5.Lcd.setCursor(190, sy+dy*3); M5.Lcd.printf("%6.2f kg", dailyGas);
  } else if (displayMode == 0) { //前日の結果および累計値の表示画面
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(YELLOW);
    M5.Lcd.setCursor(80, 50); M5.Lcd.print("[Yesterday]");
    M5.Lcd.setCursor(215, 50); M5.Lcd.print("[Total]");
    int sy = 75, dy = 22;
    drawHistoryLine(sy,      "CO2(kg):", yCFP,    dailyCFP + sumCFP);
    drawHistoryLine(sy+dy,   "Wat(L):",  yVW,     dailyVW + sumVW);
    drawHistoryLine(sy+dy*2, "Epw(kWh):",yEpower, dailyEpower + sumEpower);
    drawHistoryLine(sy+dy*3, "Oil(L):",  yOil,    dailyOil + sumOil);
    drawHistoryLine(sy+dy*4, "Gas(kg):",  yGas,    dailyGas + sumGas); 
  } else {  //QRコード表示画面
    M5.Lcd.setTextSize(2);
    M5.Lcd.setTextColor(YELLOW);
    M5.Lcd.setCursor(10, 45);
    String url = "http://" + WiFi.localIP().toString();
    M5.Lcd.printf("URL: http://%s", WiFi.localIP().toString().c_str()); 
    M5.Lcd.qrcode(url, 102, 65, 115, 4);
    M5.Lcd.setTextColor(WHITE); 
    M5.Lcd.setTextSize(1);
    M5.Lcd.setCursor(10, 190);
    M5.Lcd.print("CO2:CFP(kg) Water:VW(L) Epw:Epower cns(kWh)");
  }
  drawFooter();
}

//数値位置の調整
void LcdLine(int y, const char* label, double value, const char* unit) {
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(15, y);
  M5.Lcd.print(label);
  M5.Lcd.setCursor(110, y); 
  M5.Lcd.printf("%7.2f", value); // 本日の結果
  M5.Lcd.setCursor(215, y);
  M5.Lcd.print(unit);
}

void drawHistoryLine(int y, const char* label, double valY, double valT) {
  M5.Lcd.setTextSize(2);
  M5.Lcd.setTextColor(YELLOW);
  M5.Lcd.setCursor(7, y + 10); 
  M5.Lcd.print(label);
  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setCursor(120, y + 10);   M5.Lcd.printf("%5.1f", valY); // 前日の結果
  M5.Lcd.setCursor(215, y + 10);  M5.Lcd.printf("%7.1f", valT); // 累計値
}

//ボタン上部の文字表示
void drawFooter() {
  M5.Lcd.setTextSize(1);
  M5.Lcd.setTextColor(YELLOW);
  M5.Lcd.setCursor(50, 227);  M5.Lcd.print("istory");
  M5.Lcd.setCursor(147, 227); M5.Lcd.print("resent");
  M5.Lcd.setCursor(247, 227); M5.Lcd.print("Rcode");
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(39, 220);  M5.Lcd.print("H");
  M5.Lcd.setCursor(135, 220); M5.Lcd.print("P");
  M5.Lcd.setCursor(235, 220); M5.Lcd.print("Q");
}

//ボタン押下に応じた画面切り替え
void checkButtons() {
  if (M5.BtnA.wasPressed()){  
    displayMode = 0;
    updateLcd();
  }
  if (M5.BtnB.wasPressed()){
    displayMode = 1;
    updateLcd();
  }
  if (M5.BtnC.wasPressed()){
    displayMode = 2;
    updateLcd();
  }
}


//---------------------- Webサーバー関連 ----------------------
// ホームページ（過去７日分の結果グラフ＋リンクメニュー）
void handleRoot() {
  
  String jsonData = loadRecentDataJSON(7);   //グラフ作成
  String body = "<h1>マスフロー変化グラフ</h1>";
  body += "<canvas id='chart'></canvas>";
  body += "<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>";
  body += "<script>";
  body += "const data = " + jsonData + ";";
  body += R"(
          const ctx = document.getElementById('chart').getContext('2d');
          const labels = data.map(d => d.date);
          const cfpData = data.map(d => d.CFP);
          const vwData = data.map(d => d.VW);
          const epowerData = data.map(d => d.Epower);
          const oilData = data.map(d => d.Oil);
          const gasData = data.map(d => d.Gas);
          const chart = new Chart(ctx, {
                  type: 'line',
                  data: {
                    labels: labels,
                    datasets: [
                      {label: 'CFP (kg) ', data: cfpData, borderColor: 'red', fill: false},
                      {label: 'VW (L) ', data: vwData, borderColor: 'blue', fill: false},
                      {label: 'Epower (kWh) ', data: epowerData, borderColor: 'green', fill: false},
                      {label: 'Oil (L) ', data: oilData, borderColor: 'orange', fill: false},
                      {label: 'Gas (kg) ', data: gasData, borderColor: 'purple', fill: false}
                    ]
                  },
                  options: {
                    responsive: false,
                    scales: {
                      x: {
                        display: true,
                        title: {display: true, text: '日付'}
                      },
                      y: {
                        display: true,
                        title: {display: true, text: '消費量 / 排出量'}
                      }
                    }
                  }
                });
          )";
  body += "</script>";

  body += "<hr><h2>メニュー</h2><ul>";              // リンクメニュー
  body += "<li><a href='/today'>本日のマスフロー</a></li>";
  body += "<li><a href='/daily'>前日のマスフロー</a></li>";
  body += "<li><a href='/total'>マスフローのシーズン累計</a></li>";
  body += "<li><a href='/graph'>グラフ表示</a></li>";
  body += "<li><a href='/nodeconfig'>ノード設定</a></li>";
  body += "<li><a href='/reset' onclick=\"return confirm('本当に今日の集計をリセットしますか？');\">リセット</a></li>";
  body += "<li><a href='/qr'>QRコード</a></li>";
  body += "</ul>";
  server.send(200, "text/html", renderPage("ホーム", body));
}

//----------------------ブラウザ上での結果表示----------------------
void handleToday() {   //今日の結果表示
  String body = "<h1>本日のマスフロー</h1>";
  body += "<table border='1' cellpadding='5'>";
  body += "<tr><th>マスフロー項目</th><th>マスフロー値</th></tr>";
  body += "<tr><td>CO2 Emt</td><td><span>" + String(dailyCFP, 3) + "</span><span class='unit'> kg/house/today </td></tr>";
  body += "<tr><td>Water cns</td><td><span>" + String(dailyVW, 3) + "</span><span class='unit'> L/house/today </td></tr>";
  body += "<tr><td>Epower cns</td><td><span>" + String(dailyEpower, 3) + "</span><span class='unit'> kWh/house/today </td></tr>";
  body += "<tr><td>Fuel(Oil) cns</td><td><span>" + String(dailyOil, 3) + "</span><span class='unit'> L/house/today </td></tr>";
  body += "<tr><td>Fuel(Gas) cns</td><td><span>" + String(dailyGas, 3) + "</span><span class='unit'> kg/house/today </td></tr>";
  body += "</table>";
  server.send(200, "text/html", renderPage("本日のマスフロー", body));
}

void handleYesterday() {   // 前日の結果表示
  yesterdayResult(yCFP,yVW,yEpower,yOil,yGas);
  String body = "<h1>前日のマスフロー</h1>";
  body += "<table><tr><th>マスフロー項目</th><th>マスフロー値</th></tr>";
  body += "<tr><td>CO2 Emt</td><td><span>" + String(yCFP, 3) + "</span><span class='unit'> kg/house/day </td></tr>";
  body += "<tr><td>Water cns</td><td><span>" + String(yVW, 3) + "</span><span class='unit'> L/house/day </td></tr>";
  body += "<tr><td>Epower cns</td><td><span>" + String(yEpower, 3) + "</span><span class='unit'> kWh/house/day </td></tr>";
  body += "<tr><td>Fuel(Oil) cns</td><td><span>" + String(yOil, 3) + "</span><span class='unit'> L/house/day </td></tr>";
  body += "<tr><td>Fuel(Gas) cns</td><td><span>" + String(yGas, 3) + "</span><span class='unit'> kg/house/day </td></tr>";
  body += "</table>";
  server.send(200, "text/html", renderPage("前日のマスフロー", body));
}

void handleTotal() {   // シーズン累計の表示
  String body = "<h1>マスフローのシーズン累計</h1>";
  body += "<table><tr><th>マスフロー項目</th><th>マスフロー値</th></tr>";
  body += "<tr><td>CO2 Emt</td><td><span>" + String(dailyCFP + sumCFP, 3) + "</span><span class='unit'> kg/house/season </td></tr>";
  body += "<tr><td>Water cns</td><td><span>" + String(dailyVW + sumVW, 3) + "</span><span class='unit'> L/house/season </td></tr>";
  body += "<tr><td>Epower cns</td><td><span>" + String(dailyEpower + sumEpower, 3) + "</span><span class='unit'> kWh/house/season </td></tr>";
  body += "<tr><td>Fuel(Oil) cns</td><td><span>" + String(dailyOil + sumOil, 3) + "</span><span class='unit'> L/house/season </td></tr>";
  body += "<tr><td>Fuel(Gas) cns</td><td><span>" + String(dailyGas + sumGas, 3) + "</span><span class='unit'> kg/house/season </td></tr>";
  body += "</table>";
  server.send(200, "text/html", renderPage("マスフローのシーズン累計", body));
}

//----------------------グラフ表示----------------------
void handlegraph() {   // グラフ表示(期間指定可能)
  String body = "<h1>グラフ表示</h1>";
  body += "<form id='rangeForm'>";
  body += "開始日: <input type='date' id='start' name='start' value='" + todayDate.substring(0,4) + "-" + todayDate.substring(4,6) + "-" + todayDate.substring(6,8) + "'>";
  body += "終了日: <input type='date' id='end' name='end' value='" + todayDate.substring(0,4) + "-" + todayDate.substring(4,6) + "-" + todayDate.substring(6,8) + "'>";
  body += "<button type='submit'>表示</button>";
  body += "</form>";
  body += R"(
  <script>
    document.getElementById('rangeForm').addEventListener('submit', function(e){
      e.preventDefault();
      const start = document.getElementById('start').value.replace(/-/g,'');
      const end = document.getElementById('end').value.replace(/-/g,'');
      window.location.href = `/graph?start=${start}&end=${end}`;
    });
  </script>
  )";
  String startTime = server.arg("start");
  String endTime = server.arg("end");   
  
  if (startTime.length() == 0 || endTime.length() == 0) { //指定がなければ、デフォルトで今日のデータを表示する
    startTime = todayDate;
    endTime = todayDate;
  }
  String jsonData = loadDataBetween(startTime, endTime);
  body += "<canvas id='chart'></canvas>";
  body += "<script src='https://cdn.jsdelivr.net/npm/chart.js'></script>";
  body += "<script>";
  body += "const data = " + jsonData + ";";
  body += R"(
          const ctx = document.getElementById('chart').getContext('2d');
          const labels = data.map(d => d.date);
          const cfpData = data.map(d => d.CFP);
          const vwData = data.map(d => d.VW);
          const epowerData = data.map(d => d.Epower);
          const oilData = data.map(d => d.Oil);
          const gasData = data.map(d => d.Gas);
          new Chart(ctx, {
            type: 'line',
            data: {
              labels: labels,
              datasets: [
                {label: 'CFP (kg) ', data: cfpData, borderColor: 'red', fill: false},
                {label: 'VW (L) ', data: vwData, borderColor: 'blue', fill: false},
                {label: 'Epower (kWh) ', data: epowerData, borderColor: 'green', fill: false},
                {label: 'Oil (L) ', data: oilData, borderColor: 'orange', fill: false},
                {label: 'Gas (kg) ', data: gasData, borderColor: 'purple', fill: false}
              ]
            },
            options: {
              responsive: false,
              scales: {
                x: {
                  display: true,
                  title: {display: true, text: '日付'}
                },
                y: {
                  display: true,
                  title: {display: true, text: '消費量 / 排出量'}
                }
              }
            }
          });
          )";
  body += "</script>";
  server.send(200, "text/html", renderPage("グラフ表示", body));
}

//----------------------ノード情報入力・保存----------------------
void handleNodeConfig() {   // ノード情報設定ページ
  String body = "<h1>ノード設定</h1>";
  
  // 発電による排出係数設定
  body += "<h2>二酸化炭素排出係数設定</h2>";
  body += "<form action='/cfpsave' method='post' style='margin-bottom: 30px; padding: 15px; border: 2px solid #ccc;'>";
  body += "<label>発電の排出係数 (kg/kWh):<br><input name='ef_ep' type='number' step='0.0001' value='" + String(cfpconfig.CFPEpower, 4) + "'></label><br>";
  body += "<button type='submit' style='margin-top: 10px; padding: 5px 10px;'>排出係数を保存</button>";
  body += "<div style='font-size: 0.8rem;'>参考値</div>";
  body += "<div style='font-size: 0.8rem;'>発電による排出係数　　　関西電力:0.396 kg/kWh　全国平均:0.414 kg/kWh</div>";
  body += "<div style='font-size: 0.8rem;'>燃料消費による排出係数　ガソリン:2.322 kg/L　軽油:2.619 kg/L　灯油:2.489 kg/L　重油:2.710 kg/L　LPG:2.99 kg/kg　LNG:2.79 kg/kg</div>";
  body += "</form>";
  body += "<form action='/nodesave' method='post'>";
  
  body += "<h2>ノード情報設定</h2>";

  //ノードごとの設定(IPアドレスや排出係数など)
  for (int i = 0; i < nodeCount; i++) {
    body += "<fieldset style='margin-bottom:20px;padding:10px;border:1px solid #ccc;'>";
    body += "<legend>ノード #" + String(i+1) + "</legend>";
    body += "<label>IP Address:<br><input name='ip" + String(i) + "' value='" + nodes[i].IPaddress + "'></label><br>";
    body += "<label>Type:<br><input name='type" + String(i) + "' value='" + nodes[i].type + "'></label><br>";
    body += "<div style='display:flex;gap:10px;align-items:center;margin-top:5px;'>";
    body += "<label>Room:<br><input name='room" + String(i) + "' type='number' value='" + String(nodes[i].room) + "' style='width:80px;'></label>";
    body += "<label>Region:<br><input name='region" + String(i) + "' type='number' value='" + String(nodes[i].region) + "' style='width:80px;'></label>";
    body += "</div>";
    body += "<div style='display:flex;gap:10px;align-items:center;margin-top:5px;'>";
    body += "<label>Order:<br><input name='order" + String(i) + "' type='number' value='" + String(nodes[i].order) + "' style='width:80px;'></label>";
    body += "<label>Priority:<br><input name='priority" + String(i) + "' type='number' value='" + String(nodes[i].priority) + "' style='width:80px;'></label>";
    body += "</div>";
    body += "<label>燃料の排出係数 (kg/L)(kg/kg):<br><input name='cfp_fuel" + String(i) + "' type='number' step='0.0001' value='" + String(nodes[i].cfpFuel, 4) + "'></label><br>";
    
  //マスフロー項目の選択および推定式の設定
  for (int c = 0; c < MAX_CATS; c++) {
      body += "<div style='margin-top:5px; font-size: 0.9rem;'>";
      body += "マスフロー項目" + String(c+1) + ": ";
      body += "<select name='cat" + String(i) + "_" + String(c) + "' style='width:80px;'>";
      
      String currentCat = nodes[i].category[c];
      String options[] = {"", "VW", "Epower", "Oil", "Gas"};
      String labels[]  = {"(未設定)", "VW (水消費量)(L/s)", "Epower (電力消費量)(kWh/s)", "Oil (油燃料消費量)(L/s)", "Gas (ガス燃料消費量)(kg/s)"};
      
      for (int o = 0; o < 5; o++) {
        String sel = (currentCat == options[o]) ? " selected" : "";
        body += "<option value='" + options[o] + "'" + sel + ">" + labels[o] + "</option>";
      }
      body += "</select>";

      body += " a: <input name='a" + String(i) + "_" + String(c) + "' type='number' step='0.000000001' value='" + String(nodes[i].a[c], 9) + "' style='width:200px;'>";
      body += " b: <input name='b" + String(i) + "_" + String(c) + "' type='number' step='0.000000001' value='" + String(nodes[i].b[c], 9) + "' style='width:200px;'>";
      body += "</div>";
    }

  body += "<label><input type='checkbox' name='delete" + String(i) + "'> このノードを削除</label><br>";
  body += "</fieldset>";
  } 
  
  if (nodeCount < MAX_NODES) {   //新規ノード追加欄
    body += "<fieldset style='margin-bottom:20px;padding:10px;border:1px solid #ccc;'>";
    body += "<legend>新規ノードを追加する</legend>";
    body += "<label>IP Address:<br><input name='ip" + String(nodeCount) + "'></label><br>";
    body += "<label>Type:<br><input name='type" + String(nodeCount) + "'></label><br>";
    body += "<div style='display:flex;gap:10px;align-items:center;margin-top:5px;'>";
    body += "<label>Room:<br><input name='room" + String(nodeCount) + "' type='number' style='width:80px;'></label>";
    body += "<label>Region:<br><input name='region" + String(nodeCount) + "' type='number' style='width:80px;'></label>";
    body += "</div>";
    body += "<div style='display:flex;gap:10px;align-items:center;margin-top:5px;'>";
    body += "<label>Order:<br><input name='order" + String(nodeCount) + "' type='number' style='width:80px;'></label>";
    body += "<label>Priority:<br><input name='priority" + String(nodeCount) + "' type='number' style='width:80px;'></label>";
    body += "</div>";
    body += "<label>燃料排出係数 (kg/L)(kg/kg):<br><input name='cfp_fuel" + String(nodeCount) + "' type='number' step='0.0001'></label><br>";
    for (int c = 0; c < MAX_CATS; c++) {
      body += "マスフロー項目" + String(c+1) + ": ";
      body += "<select name='cat" + String(nodeCount) + "_" + String(c) + "' style='width:220px;'>";
      body += "<option value=''> (未設定) </option>";
      body += "<option value='VW'> VW (水消費量)(L/s) </option>";
      body += "<option value='Epower'> Epower (電力消費量)(kWh/s) </option>";
      body += "<option value='Oil'> Oil (油燃料消費量)(L/s) </option>";
      body += "<option value='Gas'> Gas (ガス燃料消費量)(kg/s) </option>";
      body += "</select>";
      body += " a: <input name='a" + String(nodeCount) + "_" + String(c) + "' type='number' step='0.000000001' style='width:200px;'>";
      body += " b: <input name='b" + String(nodeCount) + "_" + String(c) + "' type='number' step='0.000000001' style='width:200px;'><br>";
    }
    body += "</fieldset>";
  } else { //すでに20ノード登録している場合
    body += "<p style='color:red;'>ノードは最大数保存されています (" + String(MAX_NODES) + ")</p>";
  }
  
  body += "<button type='submit' style='font-size:24px;padding:10px 20px;'>ノード情報を保存</button>";
  body += "</form>";
  server.send(200, "text/html", renderPage("ノード設定", body));
}


void handleNodeSave() {   //入力したノード情報をSDに保存
  Node newNodes[MAX_NODES];
  int newCount = 0;
  for (int i = 0; i < nodeCount; i++) {
    if (server.hasArg("delete" + String(i))) {
      continue;
    }

    Node &n = newNodes[newCount];
    n.IPaddress = server.arg("ip" + String(i));
    n.type = server.arg("type" + String(i));
    n.room = server.arg("room" + String(i)).toInt();
    n.region = server.arg("region" + String(i)).toInt();
    n.order = server.arg("order" + String(i)).toInt();
    n.priority = server.arg("priority" + String(i)).toInt();
    n.cfpFuel = server.arg("cfp_fuel" + String(i)).toFloat();
    for (int c = 0; c < MAX_CATS; c++) {
      String aStr = server.arg("a" + String(i) + "_" + String(c));
      String bStr = server.arg("b" + String(i) + "_" + String(c));
      String catStr = server.arg("cat" + String(i) + "_" + String(c));
      n.a[c] = aStr.length() > 0 ? aStr.toDouble() : 0.0;
      n.b[c] = bStr.length() > 0 ? bStr.toDouble() : 0.0;
      n.category[c] = catStr;
      n.baseOffset[c] = nodes[i].baseOffset[c];
    }
    n.lastValue = 0;
    n.hasLast = false;
    n.needsBaseValueCorrection = true;
    newCount++;
    if (newCount >= MAX_NODES) break;
  }

  if (nodeCount < MAX_NODES) {   // 新規追加欄の処理
    Node &n = newNodes[newCount];
    String t = server.arg("type" + String(nodeCount));
    if (t.length() > 0) {
      n.type = t;
      n.room = server.arg("room" + String(nodeCount)).toInt();
      n.region = server.arg("region" + String(nodeCount)).toInt();
      n.order = server.arg("order" + String(nodeCount)).toInt();
      n.priority = server.arg("priority" + String(nodeCount)).toInt();
      n.IPaddress = server.arg("ip" + String(nodeCount));
      n.cfpFuel = server.arg("cfp_fuel" + String(nodeCount)).toFloat();
      for (int c = 0; c < MAX_CATS; c++) {
        String aStr = server.arg("a" + String(nodeCount) + "_" + String(c));
        String bStr = server.arg("b" + String(nodeCount) + "_" + String(c));
        String catStr = server.arg("cat" + String(nodeCount) + "_" + String(c));
        n.a[c] = aStr.length() > 0 ? aStr.toDouble() : 0.0;
        n.b[c] = bStr.length() > 0 ? bStr.toDouble() : 0.0;
        n.category[c] = catStr;
        n.baseOffset[c] = 0.0d;
      }
      n.lastValue = 0;
      n.hasLast = false;
      n.needsBaseValueCorrection = true;
      newCount++;
    }
  }
  
  nodeCount = newCount;
  for (int i = 0; i < nodeCount; i++) {
    nodes[i] = newNodes[i];
  }
  saveNodes();
  server.sendHeader("Location", "/nodeconfig");  // ノード設定保存後は設定ページにリダイレクト
  server.send(303); 
}

void handleCfpSave() { //フォームから値を取得し、保存
  cfpconfig.CFPEpower = server.arg("ef_ep").toFloat();
  saveCfpConfig();
  server.sendHeader("Location", "/nodeconfig");
  server.send(303);
}

//----------------------WEBリセット機能----------------------
void handleReset() {  //本日の集計をリセット
  initialize = true;
  appendDailyResult(todayDate);
  dailyCFP = dailyVW = dailyEpower = dailyOil = dailyGas = 0;
  tCFP    = sumCFP;
  tVW     = sumVW;
  tEpower = sumEpower;
  tOil    = sumOil;
  tGas    = sumGas;
  for (int i=0; i<nodeCount; i++) {
    nodes[i].needsBaseValueCorrection = true;
    nodes[i].lastValue = 0.0f;
    nodes[i].hasLast = false;
    for (int c = 0; c < MAX_CATS; c++) {
      nodes[i].baseOffset[c] = 0.0d;
    }
  }
  updateDailyValue();
  yesterdayResult(yCFP, yVW, yEpower, yOil, yGas); 
  initialize = false;
  String body = "<h1>今日の集計をリセットしました</h1>";
  body += "<p>3秒後にホームに戻ります...</p>";
  body += "<script>setTimeout(()=>{location.href='/';}, 3000);</script>";
  server.send(200, "text/html", renderPage("リセット完了", body));
}

//----------------------QRコード表示----------------------
void handleQr() {
  String body = "<h1>QRコード</h1>";
  body += "<div id='qrcode'></div>";
  body += "<script src='https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js'></script>";
  body += "<script>new QRCode(document.getElementById('qrcode'), {text: 'http://' + location.hostname + '/', width: 400, height: 400});</script>";
  server.send(200, "text/html", renderPage("QRコード", body));
}

//---------------------- WiFi接続 ----------------------
void connectWiFi() {
  char ssid[64] = {0}, ckey[64] = {0};
  M5.Lcd.setTextSize(2) ;
  M5.Lcd.setCursor(0, 3) ;
  if (!SD.begin()) {
    M5.Lcd.fillScreen(TFT_RED);
    M5.Lcd.println("SD not Found"); //SDカードがなければ赤く点灯し、「SD not Found」と表示
    delay(60000);                   //60秒後再びSDチェック
  } else {
    M5.Lcd.println("UECS MassFlow Monitor") ;
    M5.Lcd.println("2025/12/25 by T.Shiota") ;

    loadWiFiConfig(ssid, ckey);
    M5.Lcd.println("") ;
    M5.Lcd.print("Connecting to ") ;
    M5.Lcd.println(ssid) ;

    // 固定IP設定（必要なら）
    //IPAddress local_IP(192,168,*,*);
    //IPAddress gateway(192,168,*,*);
    //IPAddress subnet(255,255,255,0);
    //IPAddress primaryDNS(192,168,*,*);
    //if (!WiFi.config(local_IP, gateway, subnet, primaryDNS)) {
      //M5.Lcd.setTextColor(TFT_RED);
      //M5.Lcd.println("Failed to configure IP address.");
      //while(1);
    //}

    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, ckey);
    for(int p = 0; p < 200; p++) {
      if(WiFi.status() == WL_CONNECTED) break ;
      delay(100);
      M5.Lcd.print(".") ;
    }

    if(WiFi.status() != WL_CONNECTED) {
      M5.Lcd.println("") ;
      M5.Lcd.setTextColor(TFT_RED) ;
      M5.Lcd.println("Fail to connect WiFi network.") ;
      while(1) ;
    }  
  
    M5.Lcd.println("") ;
    M5.Lcd.println("WiFi connected.") ;
    M5.Lcd.print("SSID: ") ;
    M5.Lcd.println(ssid) ;
    M5.Lcd.print("IP address: ") ;
    M5.Lcd.println(WiFi.localIP()) ; //ツールのIPアドレスを画面表示
    M5.Lcd.println("") ;
    delay(6000);
  }
}

//---------------------- setup & loop ----------------------
//起動時/再起動時
void setup() {
  initialize = true;
  M5.begin();
  M5.Lcd.setRotation(1);
  M5.Lcd.fillScreen(TFT_BLACK);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setTextColor(TFT_WHITE);
  Serial.begin(115200);

  loadNodes();                                            //ノード情報の読み込み
  loadCfpConfig();                                        //発電排出係数の読み込み
  totalResult(sumCFP, sumVW, sumEpower, sumOil, sumGas);  //前日までの累計値の計算
  yesterdayResult(yCFP, yVW, yEpower, yOil, yGas);        //前日の結果の読み込み
  tCFP    = sumCFP;                                       //前日までの累計値と本日までの累計値を合致させる
  tVW     = sumVW;
  tEpower = sumEpower;
  tOil    = sumOil;
  tGas    = sumGas;
  dailyCFP = dailyVW = dailyEpower = dailyOil = dailyGas = 0;  //本日の結果を0にリセット
  todayDate = "-";                                             //日付を初期値「-」に設定
  for (int i = 0; i < nodeCount; i++) {                        //基点補正フラグON
        nodes[i].needsBaseValueCorrection = true;
    }
  updateDailyValue();                                          
  connectWiFi();                                               //WiFi接続
  udp.begin(UDP_PORT);                                         //UDP通信開始
  
  server.on("/", handleRoot);   // サーバールート設定
  server.on("/today", handleToday);
  server.on("/daily", handleYesterday);
  server.on("/total", handleTotal);
  server.on("/nodeconfig", HTTP_GET, handleNodeConfig);
  server.on("/nodesave", HTTP_POST, handleNodeSave);
  server.on("/cfpsave", HTTP_POST, handleCfpSave);
  server.on("/reset", handleReset);
  server.on("/qr", handleQr);
  server.on("/graph", handlegraph);
  server.begin();
  initialize = false;
}

void loop() {
  M5.update();
  checkButtons();                         
  parsePacket();                          //データ処理はパケットを受信するたび
  if (millis() - lastLcdUpdate > 30000) { //画面表示切り替えは30秒おき
    updateLcd();
    lastLcdUpdate = millis();  
  }
  server.handleClient();
}
