×
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <DHT.h>

#include <driver/i2s.h>
#include <arduinoFFT.h>
#include <math.h>
#include <WiFiClientSecure.h>

// ============================================================
// Audio_Monitor_R8
// ESP32 Audio Status Monitor + INMP441 6-Band FFT
//
// [Integrated Functions]
// - WiFi Web Dashboard
// - Roon ROCK monitoring
// - DHT11 temperature / humidity
// - INMP441 6-band FFT spectrum monitor
//
// [Design policy]
// - Shared status via gState
// - Web reads only gState
// - FFT runs periodically and publishes averaged result
// - Calibration offset per band
// ============================================================

// ============================================================
// 0. USER SETTINGS
// ============================================================

// ---------- WiFi ----------
const char* ssid     = "私のSSID";
const char* password = "私のパスワード";

// ---------- Roon ROCK API ----------
const char* ROON_STATE_URL = "http://192.168.3.<ROCK-IP>/1/getstate";

// ---------- DHT11 ----------
#define DHTPIN  13
#define DHTTYPE DHT11

DHT dht(DHTPIN, DHTTYPE);

// ---------- Temperature thresholds ----------
const float TEMP_WARNING = 29.0;
const float TEMP_ERROR   = 36.0;

// ---------- Web refresh ----------
const uint32_t PAGE_REFRESH_SEC = 30;

// ---------- Update intervals ----------
const uint32_t ROON_UPDATE_MS     = 10000;
const uint32_t ENV_UPDATE_MS      = 5000;
const uint32_t FFT_BLOCK_MS       = 250;    // one FFT cycle trigger
const uint32_t SPECTRUM_UPDATE_MS = 2000;   // publish to dashboard every 2 sec

// ============================================================
// 1. FFT / INMP441 SETTINGS
// ============================================================

// ---------- I2S pin assignment ----------
static const int I2S_BCLK_PIN = 14;   // INMP441 SCK / BCLK
static const int I2S_WS_PIN   = 15;   // INMP441 WS / LRCLK
static const int I2S_SD_PIN   = 32;   // INMP441 SD

// ---------- audio settings ----------
static const uint16_t FFT_SIZE    = 1024;
static const double   SAMPLE_RATE = 16000.0;
static const double   BIN_WIDTH   = SAMPLE_RATE / FFT_SIZE;   // 15.625 Hz/bin

// ---------- low-frequency handling ----------
static const bool EXCLUDE_BIN_1 = true;          // ignore 15.625 Hz bin
static const bool INCLUDE_BIN2_IN_63HZ = false;  // include 31.25 Hz in 63Hz band?

// ---------- spectrum averaging ----------
static const uint8_t SPECTRUM_AVG_BLOCKS = 8;    // 250ms x 8 = approx 2 sec

// ---------- display meter range ----------
static const float METER_MIN_DB = 10.0;
static const float METER_MAX_DB = 90.0;

// ---------- calibration offsets ----------
float calibrationOffsetDb[6] = {
  -78.9,   // 63Hz
  -80.9,   // 160Hz
  -84.7,   // 380Hz
  -85.5,   // 1kHz
  -84.3,   // 2.5kHz
  -81.7    // 6.3kHz
};

// ---------- band names ----------
static const char* BAND_NAMES[6] = {
  "63Hz", "160Hz", "380Hz", "1kHz", "2.5kHz", "6.3kHz"
};

// ---------- band definition ----------
struct BandDef {
  const char* name;
  double fLow;
  double fHigh;
};

static const BandDef bands[6] = {
  {"63Hz",   45.00,   93.75},
  {"160Hz",  93.75,  250.00},
  {"380Hz", 250.00,  625.00},
  {"1kHz",  625.00, 1625.00},
  {"2.5kHz",1625.00,4000.00},
  {"6.3kHz",4000.00,8000.00}
};

// ---------- I2S port ----------
static const i2s_port_t I2S_PORT = I2S_NUM_0;

// ---------- raw I2S buffer ----------
int32_t rawSamples[FFT_SIZE];

// ---------- FFT buffers ----------
double vReal[FFT_SIZE];
double vImag[FFT_SIZE];

ArduinoFFT<double> FFT = ArduinoFFT<double>(vReal, vImag, FFT_SIZE, SAMPLE_RATE);

// ============================================================
// 2. Roon ROCK Status
// ============================================================

struct SpectrumState {
  float bandDb[6];          // calibrated display dB
  float rawBandDb[6];       // raw relative dB before calibration
  float peakFreq;
  float peakDb;
  unsigned long lastUpdateMs;
  bool valid;
};

struct EnvironmentState {
  float temperature;
  float humidity;
  String tempStatus;
};

struct RoonState {
  String roonStatus;
  String roonVersion;
  String roonUptimeText;
  String rockVersion;
  String rockUptime;
  String storageFree;
  String rockIP;
};

struct SystemState {
  RoonState roon;
  EnvironmentState env;
  SpectrumState spectrum;
};

SystemState gState;

//==================================================
// 3. WiiM Status
//==================================================

struct WiiMStatus
{
    String source;
    String playStatus;

    String title;
    String artist;
    String album;

    String albumArt;

    String format;
    String sampleRate;
    String bitDepth;
};

WiiMStatus wiim;

// ============================================================
// 3. GLOBALS
// ============================================================

WebServer server(80);

unsigned long lastRoonUpdate = 0;
unsigned long lastEnvUpdate  = 0;
unsigned long lastFftKick    = 0;

// averaging accumulators
float spectrumAccum[6] = {0, 0, 0, 0, 0, 0};
uint8_t spectrumAccumCount = 0;

float peakFreqAccum = 0.0;
float peakDbAccum   = 0.0;

// ============================================================
// 4. HELPER FUNCTIONS
// ============================================================

// ------------------------------------------------------------
// uptime sec -> "Xd Yh"
// ------------------------------------------------------------
String formatUptime(unsigned long sec)
{
  int days  = sec / 86400;
  int hours = (sec % 86400) / 3600;

  return String(days) + "d " + String(hours) + "h";
}

// ------------------------------------------------------------
// dB -> bar percent
// ------------------------------------------------------------
int dbToPercent(float db)
{
  if (db < METER_MIN_DB) db = METER_MIN_DB;
  if (db > METER_MAX_DB) db = METER_MAX_DB;

  return (int)(100.0f * (db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB));
}

// ------------------------------------------------------------
// power -> dB
// ------------------------------------------------------------
double powerToDb(double p)
{
  const double eps = 1e-20;
  return 10.0 * log10(p + eps);
}

// ------------------------------------------------------------
// bin -> frequency
// ------------------------------------------------------------
double binToFreq(uint16_t bin)
{
  return (double)bin * BIN_WIDTH;
}

// ------------------------------------------------------------
// inclusive lower / upper bin helpers
// ------------------------------------------------------------
uint16_t freqToBinFloor(double freq)
{
  int b = (int)floor(freq / BIN_WIDTH + 1e-9);
  if (b < 0) b = 0;
  if (b > (FFT_SIZE / 2 - 1)) b = FFT_SIZE / 2 - 1;
  return (uint16_t)b;
}

uint16_t freqToBinCeil(double freq)
{
  int b = (int)ceil(freq / BIN_WIDTH - 1e-9);
  if (b < 0) b = 0;
  if (b > (FFT_SIZE / 2 - 1)) b = FFT_SIZE / 2 - 1;
  return (uint16_t)b;
}

// ============================================================
// 5. ROON / ENVIRONMENT
// ============================================================

// ------------------------------------------------------------
// Roon status update
// ------------------------------------------------------------
void updateRoonStatus()
{
  HTTPClient http;
  http.begin(ROON_STATE_URL);
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");

  int httpCode = http.POST("");

  if (httpCode > 0)
  {
    String payload = http.getString();

    DynamicJsonDocument doc(8192);
    DeserializationError error = deserializeJson(doc, payload);

    if (!error)
    {
      bool running = doc["data"]["state"]["roon_running"];

      gState.roon.roonStatus =
        running ? "ONLINE" : "OFFLINE";

      gState.roon.roonVersion =
        doc["data"]["state"]["roon_version"].as<String>();

      gState.roon.rockVersion =
        doc["data"]["device_display_version"].as<String>();

      gState.roon.rockIP =
        doc["data"]["interfaces"][0]["state"]["ip"].as<String>();

      unsigned long rockSec =
        String(doc["data"]["state"]["uptime"]).toInt();

      unsigned long roonSec =
        String(doc["data"]["state"]["roon_uptime"]).toInt();

      gState.roon.rockUptime = formatUptime(rockSec);
      gState.roon.roonUptimeText = formatUptime(roonSec);

      uint64_t used =
        doc["data"]["state"]["internal_storage"]["data_used"];

      uint64_t total =
        doc["data"]["state"]["internal_storage"]["data_total"];

      float freeGB =
        (float)(total - used) / 1024.0 / 1024.0;

      float totalGB =
        (float)total / 1024.0 / 1024.0;

      int percent = 0;
      if (total > 0) {
        percent = ((total - used) * 100) / total;
      }

      gState.roon.storageFree =
        String((int)freeGB)
        + " GB / "
        + String((int)totalGB)
        + " GB ("
        + String(percent)
        + "% available)";
    }
    else
    {
      gState.roon.roonStatus = "JSON ERROR";
    }
  }
  else
  {
    gState.roon.roonStatus = "NO RESPONSE";
  }

  http.end();
}

// ------------------------------------------------------------
// DHT11 update
// ------------------------------------------------------------
void updateEnvironment()
{
  float h = dht.readHumidity();
  float t = dht.readTemperature();

  if (!isnan(h) && !isnan(t))
  {
    gState.env.humidity = h;
    gState.env.temperature = t;
  }

  if (gState.env.temperature >= TEMP_ERROR)
  {
    gState.env.tempStatus = "ERROR";
  }
  else if (gState.env.temperature >= TEMP_WARNING)
  {
    gState.env.tempStatus = "WARNING";
  }
  else
  {
    gState.env.tempStatus = "OK";
  }
}

// ============================================================
// 6. INMP441 / Fan Noise FFT
// ============================================================

// ------------------------------------------------------------
// I2S init
// ------------------------------------------------------------
void initI2SMic()
{
  const i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = (uint32_t)SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = false,
    .fixed_mclk = 0
  };

  const i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_BCLK_PIN,
    .ws_io_num = I2S_WS_PIN,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_SD_PIN
  };

  i2s_driver_uninstall(I2S_PORT);

  esp_err_t err;
  err = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL);
  if (err != ESP_OK) {
    Serial.printf("ERROR: i2s_driver_install failed (%d)\n", err);
    while (1) delay(1000);
  }

  err = i2s_set_pin(I2S_PORT, &pin_config);
  if (err != ESP_OK) {
    Serial.printf("ERROR: i2s_set_pin failed (%d)\n", err);
    while (1) delay(1000);
  }

  i2s_zero_dma_buffer(I2S_PORT);
  Serial.println("I2S microphone initialized.");
}

// ------------------------------------------------------------
// read one mic block
// ------------------------------------------------------------
bool readMicBlock(int32_t* dst, size_t count)
{
  size_t bytesRead = 0;
  esp_err_t err = i2s_read(
    I2S_PORT,
    (void*)dst,
    count * sizeof(int32_t),
    &bytesRead,
    portMAX_DELAY
  );

  if (err != ESP_OK) {
    Serial.printf("ERROR: i2s_read failed (%d)\n", err);
    return false;
  }

  size_t samplesRead = bytesRead / sizeof(int32_t);
  return (samplesRead == count);
}

// ------------------------------------------------------------
// Prepare FFT input
// 1) convert raw sample
// 2) mean
// 3) DC remove
// 4) Hann window
// ------------------------------------------------------------
double prepareFFTInput(const int32_t* src, uint16_t n)
{
  double mean = 0.0;

  // pass 1 : mean
  for (uint16_t i = 0; i < n; i++) {
    double s = (double)(src[i] >> 8);
    mean += s;
  }
  mean /= (double)n;

  // pass 2 : DC remove + Hann
  for (uint16_t i = 0; i < n; i++) {
    double s = (double)(src[i] >> 8);
    s -= mean;

    double w = 0.5 * (1.0 - cos((2.0 * PI * i) / (n - 1)));
    vReal[i] = s * w;
    vImag[i] = 0.0;
  }

  return mean;
}

// ------------------------------------------------------------
// Find peak bin
// ------------------------------------------------------------
void findPeakBin(uint16_t& peakBin, double& peakMag)
{
  uint16_t startBin = EXCLUDE_BIN_1 ? 2 : 1;

  peakBin = startBin;
  peakMag = vReal[startBin];

  for (uint16_t i = startBin + 1; i < FFT_SIZE / 2; i++) {
    if (vReal[i] > peakMag) {
      peakMag = vReal[i];
      peakBin = i;
    }
  }
}

// ------------------------------------------------------------
// Integrate one band by power sum
// ------------------------------------------------------------
double integrateBandPower(double fLow, double fHigh, bool is63Band)
{
  uint16_t binStart = freqToBinCeil(fLow);
  uint16_t binEnd   = freqToBinFloor(fHigh);

  if (binEnd < binStart) return 0.0;

  double sumPower = 0.0;

  for (uint16_t bin = binStart; bin <= binEnd; bin++) {

    if (bin == 0) continue;
    if (EXCLUDE_BIN_1 && bin == 1) continue;
    if (is63Band && !INCLUDE_BIN2_IN_63HZ && bin == 2) continue;

    double mag = vReal[bin];
    sumPower += mag * mag;
  }

  return sumPower;
}

// ------------------------------------------------------------
// Publish averaged spectrum to gState
// ------------------------------------------------------------
void publishSpectrumAverage()
{
  if (spectrumAccumCount == 0) return;

  for (int i = 0; i < 6; i++) {
    float avgRaw = spectrumAccum[i] / spectrumAccumCount;

    gState.spectrum.rawBandDb[i] = avgRaw;
    gState.spectrum.bandDb[i]    = avgRaw + calibrationOffsetDb[i];

    spectrumAccum[i] = 0.0;
  }

  gState.spectrum.peakFreq = peakFreqAccum / spectrumAccumCount;
  gState.spectrum.peakDb   = peakDbAccum   / spectrumAccumCount;

  peakFreqAccum = 0.0;
  peakDbAccum   = 0.0;

  spectrumAccumCount = 0;
  gState.spectrum.lastUpdateMs = millis();
  gState.spectrum.valid = true;
}

// ------------------------------------------------------------
// One FFT update cycle
// ------------------------------------------------------------
void updateSpectrum()
{
  // 1) read one block
  if (!readMicBlock(rawSamples, FFT_SIZE)) {
    return;
  }

  // 2) prepare input
  prepareFFTInput(rawSamples, FFT_SIZE);

  // 3) FFT
  FFT.compute(FFT_FORWARD);
  FFT.complexToMagnitude();

  // 4) peak
  uint16_t peakBin;
  double peakMag;
  findPeakBin(peakBin, peakMag);

  float peakFreq = (float)binToFreq(peakBin);
  float peakDb   = (float)(20.0 * log10(peakMag + 1e-12));

  // 5) 6-band integration
  float bandRawDb[6];

  for (uint8_t i = 0; i < 6; i++) {
    bool is63Band = (i == 0);
    double p = integrateBandPower(bands[i].fLow, bands[i].fHigh, is63Band);
    bandRawDb[i] = (float)powerToDb(p);
  }

  // 6) accumulate
  for (int i = 0; i < 6; i++) {
    spectrumAccum[i] += bandRawDb[i];
  }

  peakFreqAccum += peakFreq;
  peakDbAccum   += peakDb;
  spectrumAccumCount++;

  // 7) publish every N blocks
  if (spectrumAccumCount >= SPECTRUM_AVG_BLOCKS) {
    publishSpectrumAverage();
  }
}


//==================================================
// 7. WiiM API / Audio Playback Stats
//==================================================

// WiiM HTTP API
const char* WiiM_URL =
    "https://192.168.3.<WiiM-IP>/httpapi.asp?command=getPlayerStatus";

// WiiM SOAP URL
const char* WiiM_SOAP_URL =
    "http://192.168.3.<WiiM-IP>:49152/upnp/control/rendertransport1";

//             WiiM Ultra 情報経路は2系統
//                 |
//       +---------+---------+
//       |                   |
//   HTTP API             SOAP
// getPlayerStatus      GetInfoEx
//       |                   |
//       |                   |
// INPUT情報            Artwork
// input_mode                 Title
// status               Artist
// format               Album
// sampleRate
//       |
//       +---------+
//                 |
//             WiiMStatus
//                 |
//             Web表示

//--------------------------------------------------
// Input Selector
//--------------------------------------------------
String decodeMode(const String& input_mode)
{
    if (input_mode == "10") return "wifi";  // Roon
    if (input_mode == "33") return "wifi";  // Roon
    if (input_mode == "41") return "bluetooth";  // Bluetooth
    if (input_mode == "40") return "line-in";  // Analog
    if (input_mode == "43") return "optical";  // TV
    if (input_mode == "45") return "phono";  // Phono MM

    return "Undef";
}


//--------------------------------------------------
// HEX → ASCII
//--------------------------------------------------
String hexToString(const String& hex)
{
    String result;

    if (hex.length() < 2)
        return "";

    for (int i = 0; i < hex.length(); i += 2)
    {
        String byteString = hex.substring(i, i + 2);
        char c = (char)strtol(byteString.c_str(), nullptr, 16);
        result += c;
    }

    return result;
}


//--------------------------------------------------
// JSON Parse
//--------------------------------------------------
void parseWiiM(const String& payload)
{
        // wiim debug serial
        Serial.println("===== RAW WiiM JSON =====");
        Serial.println(payload);
        Serial.println("=========================");

    DynamicJsonDocument doc(4096);

    DeserializationError error =
        deserializeJson(doc, payload);

    if (error)
    {
        Serial.print("JSON Error : ");
        Serial.println(error.c_str());
        return;
    }

    wiim.playStatus =
        doc["status"] | "unknown";

        // wiim debug serial
        Serial.print("input_mode RAW = ");
        Serial.println(doc["input_mode"].as<String>());
    
    wiim.source =
        decodeMode(doc["mode"] | "0");
    
        // wiim debug serial
        Serial.print("decoded source = ");
        Serial.println(wiim.source);

    wiim.title =
        hexToString(doc["Title"] | "");

    wiim.artist =
        hexToString(doc["Artist"] | "");

    wiim.album =
        hexToString(doc["Album"] | "");

    wiim.albumArt =
        doc["albumArt"] | "";
    
    wiim.format =
        doc["bitDepth"] | "";

    wiim.sampleRate =
        doc["sampleRate"] | "";
}



//--------------------------------------------------
// HTTP GET WiiMStatus
//--------------------------------------------------

void fetchWiiMStatus()
{
    WiFiClientSecure client;
    client.setInsecure();

    HTTPClient http;

    http.begin(client, WiiM_URL);

    int httpCode = http.GET();

    if (httpCode > 0)
    {
        String payload = http.getString();

        parseWiiM(payload);
    }
    else
    {
        Serial.print("HTTP Error : ");
        Serial.println(http.errorToString(httpCode));
    }

    http.end();
}

//--------------------------------------------------
// HTTP GET WiiMInfoEx Track-Informations
//--------------------------------------------------

void fetchWiiMInfoEx()
{
    HTTPClient http;

    http.begin(WiiM_SOAP_URL);

    http.addHeader(
        "Content-Type",
        "text/xml; charset=\"utf-8\""
    );

    http.addHeader(
        "SOAPAction",
        "\"urn:schemas-upnp-org:service:AVTransport:1#GetInfoEx\""
    );


    String body =
R"(<?xml version="1.0" encoding="utf-8"?>
<s:Envelope
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">

<s:Body>

<u:GetInfoEx
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">

<InstanceID>0</InstanceID>

</u:GetInfoEx>

</s:Body>

</s:Envelope>)";


    int code = http.POST(body);


    if(code == 200)
    {
        String xml = http.getString();


        Serial.println();
        Serial.println("===== GetInfoEx =====");


        // -------------------------
        // albumArtURI
        // -------------------------

        int p1 =
        xml.indexOf("albumArtURI&gt;");


        if(p1 >= 0)
        {
            p1 += strlen("albumArtURI&gt;");

            int p2 =
            xml.indexOf("&lt;", p1);

            if(p2 > p1)
            {
                wiim.albumArt =
                    xml.substring(p1,p2);

                Serial.print("Artwork : ");
                Serial.println(wiim.albumArt);
            }
        }


        // -------------------------
        // Title
        // -------------------------

        int t1 =
        xml.indexOf("&lt;dc:title&gt;");

        if(t1 >=0)
        {
            t1 += strlen("&lt;dc:title&gt;");

            int t2 =
            xml.indexOf("&lt;",t1);

            wiim.title =
            xml.substring(t1,t2);
        }


        // -------------------------
        // Artist
        // -------------------------

        int a1 =
        xml.indexOf("&lt;upnp:artist&gt;");

        if(a1 >=0)
        {
            a1 += strlen("&lt;upnp:artist&gt;");

            int a2 =
            xml.indexOf("&lt;",a1);

            wiim.artist =
            xml.substring(a1,a2);
        }


        // -------------------------
        // Album
        // -------------------------

        int al1 =
        xml.indexOf("&lt;upnp:album&gt;");

        if(al1 >=0)
        {
            al1 += strlen("&lt;upnp:album&gt;");

            int al2 =
            xml.indexOf("&lt;",al1);

            wiim.album =
            xml.substring(al1,al2);
        }


        Serial.println("Title : " + wiim.title);
        Serial.println("Artist: " + wiim.artist);
        Serial.println("Album : " + wiim.album);

        Serial.println("====================");
    }
    else
    {
        Serial.print("SOAP Error=");
        Serial.println(code);
    }


    http.end();
}

//--------------------------------------------------
// Input Select Service
//--------------------------------------------------
void callInputApi(String input)
{
    Serial.println("=== INPUT API START ===");

    Serial.print("Input command = ");
    Serial.println(input);

    WiFiClientSecure client;
    client.setInsecure();

    HTTPClient http;

    String url =
      "https://192.168.3.<WiiM-IP>/httpapi.asp?command=setPlayerCmd:switchmode:"
      + input;

    wiim.source = input;

    Serial.print("URL = ");
    Serial.println(url);

    http.begin(client, url);

    int code = http.GET();

    Serial.print("HTTP Response = ");
    Serial.println(code);

    http.end();
}

// ============================================================
// 8. HTML HELPERS
// ============================================================

// ------------------------------------------------------------
// Return health label for Roon system
// ------------------------------------------------------------
String buildSystemHealthHtml()
{
  if (gState.roon.roonStatus == "ONLINE") {
    return "<span class='ok'>OK</span>";
  }
  return "<span class='ng'>ERROR</span>";
}

// ------------------------------------------------------------
// Return temp status label
// ------------------------------------------------------------
String buildTempStatusHtml()
{
  if (gState.env.tempStatus == "OK") {
    return "<span class='ok'>OK</span>";
  }
  else if (gState.env.tempStatus == "WARNING") {
    return "<span class='warning'>WARNING</span>";
  }
  else {
    return "<span class='ng'>NG</span>";
  }
}

// ------------------------------------------------------------
// Build one spectrum row
// ------------------------------------------------------------
String buildSpectrumRow(const char* label, float db)
{
  String html;
  int width = dbToPercent(db);

  html += "<div class='spec-row'>";
  html += "<div class='spec-label'>";
  html += label;
  html += "</div>";

  html += "<div class='spec-bar-wrap'>";
  html += "<div class='spec-bar' style='width:";
  html += String(width);
  html += "%;'></div>";
  html += "</div>";

  html += "<div class='spec-value'>";
  html += String(db, 1);
  html += " dB</div>";

  html += "</div>";

  return html;
}

//------------------------------------------------------------
// Build WiiM Input Selector Table
//------------------------------------------------------------
String buildInputTableHtml()
{
    String html;

    html += "<table id='inputTable' class='input-table'><tr>";

    html += (wiim.source == "wifi")
        ? "<td class='td_enable'>Roon</td>"
        : "<td class='td_disable' onclick=\"fetch('/wifi').then(()=>updateInputTable())\">Roon</td>";

    html += (wiim.source == "line-in")
        ? "<td class='td_enable'>Analog</td>"
        : "<td class='td_disable' onclick=\"fetch('/line-in').then(()=>updateInputTable())\">Analog</td>";

    html += (wiim.source == "bluetooth")
        ? "<td class='td_enable'>Bluetooth</td>"
        : "<td class='td_disable' onclick=\"fetch('/bluetooth').then(()=>updateInputTable())\">Bluetooth</td>";

    html += (wiim.source == "optical")
        ? "<td class='td_enable'>TV</td>"
        : "<td class='td_disable' onclick=\"fetch('/optical').then(()=>updateInputTable())\">TV</td>";

    html += (wiim.source == "phono")
        ? "<td class='td_enable'>Phono MM</td>"
        : "<td class='td_disable' onclick=\"fetch('/phono').then(()=>updateInputTable())\">Phono MM</td>";

    html += "</tr></table>";

    return html;
}

// ============================================================
// 9. WEB PAGE
// ============================================================

void handleRoot()
{
  String html;

  html += "<html>";
  html += "<head>";

  // INPUTテーブル表示更新を早くする
  html += R"rawliteral(
  <script>
  function updateInputTable()
  {
      fetch('/inputTable')
      .then(response => response.text())
      .then(html => {
          document.getElementById('inputTable').outerHTML = html;
      });
  }
  </script>
  )rawliteral";

  html += "<title>Audio System Monitoring</title>";
  html += "<meta http-equiv='refresh' content='";
  html += String(PAGE_REFRESH_SEC);
  html += "'>";

  html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
  html += "<link rel='icon' type='image/x-icon' href='/favicon.ico'>";
  html += "<link rel='keroyon-audio-monitor' href='https://keroyon-audio.com/ext-content/keroyon-audio-monitor.png' sizes='180x180'>";

// -----------------
// CSS Style
// -----------------
  html += "<style>";
  html += "body{font-family:Arial,Helvetica,sans-serif;margin:20px;background:#ffffff;color:#222;}";
  html += "h1{margin-bottom:8px;color:#6D6D6D}";
  html += "h2{margin-top:28px;margin-bottom:10px;}";
  html += "table{width:100%;border-collapse:collapse;margin-bottom:10px;}";
  html += "td{padding:8px;border-bottom:1px solid #cccccc;vertical-align:top;}";
  html += ".ok{color:green;font-weight:bold;}";
  html += ".ng{color:red;font-weight:bold;}";
  html += ".warning{color:orange;font-weight:bold;}";
  html += ".spec-row{display:flex;align-items:center;width:64%;margin:10px 0;}";
  html += ".spec-label{width:60px;font-weight:bold;}";
  html += ".spec-bar-wrap{flex:1;height:20px;background:#e0e0e0;border-radius:4px;overflow:hidden;margin-right:8px;}";
  html += ".spec-bar{height:100%;background:#4caf50;}";
  html += ".spec-value{width:60px;text-align:right;font-family:monospace;}";
  html += ".note{color:#666;font-size:12px;}";
  html += ".section-box{border:1px solid #dddddd;border-radius:8px;padding:16px;margin-top:16px;}";
  html += ".section-box-dark{border:1px solid #dddddd;border-radius:8px;padding:16px;margin-top:16px;background-color:#070707;}";

  html += ".white-title{font-size:22px;font-weight:bold;margin-top:10px;color:#848484;}";
  html += ".playing-title{font-size:22px;font-weight:bold;color:#eee;}";
  html += ".playing-artist{font-size:20px;font-weight:bold;color:#eee;}";
  html += ".playing-album{font-size:17px;color:#eee;}";
  html += ".playing-label{margin-top:10px;font-size:14px;font-weight:bold;color:#848484;}";

  html += ".input-table{width:56%;border-collapse:separate;border-spacing:6px;margin-top:8px;margin-bottom:18px;}";
  html += ".input-table td{padding:8px 14px;min-width:90px;text-align:center;border-radius:6px;font-weight:bold;}";
  html += ".td_enable{background:#2ecc71;color:white;}";
  html += ".td_disable{background:#555;color:#ddd; cursor: pointer;}";
  html += ".wiim-container{";

  html += "display:flex;";
  html += "align-items:flex-start;";
  html += "gap:24px;";
  html += "margin-top:12px;";
  html += "}";
  
  html += ".wiim-artwork{";
  html += "width:220px;";
  html += "flex-shrink:0;";
  html += "}";
  
  html += ".wiim-artwork img{";
  html += "width:220px;";
  html += "height:220px;";
  html += "object-fit:cover;";
  html += "border-radius:8px;";
  html += "}";
  
  html += ".wiim-info{";
  html += "flex:1;";
  html += "}";

  html += "</style>";

  html += "</head>";
  html += "<body>";

  // --------------------------------------------------------
  // title
  // --------------------------------------------------------
  html += "<h1>Audio System Monitoring</h1>";
  html += "<hr>";

  // --------------------------------------------------------
  // WiiM Playback section
  // --------------------------------------------------------
  html += "<div class='section-box-dark'>";

  html += "<div class='white-title'>Playing Status</div>";

  html += "<div class='wiim-container'>";
  
  //-------------------
  // artwork
  //-------------------
  
  html += "<div class='wiim-artwork'>";

    if (wiim.albumArt.length() > 0)
    {
        html += "<img src='";
        html += wiim.albumArt;
        html += "'>";
    }
    else
    {
        html += "<div style='color:#888;'>No Artwork</div>";
    }

html += "</div>";
  
  //-------------------
  // information
  //-------------------
  
  html += "<div class='wiim-info'>";
  
  html += buildInputTableHtml();
  
  html += "<div class='playing-label'>Artist</div>";
  html += "<div class='playing-artist'>";
  html += wiim.artist;
  html += "</div>";
  
  html += "<div class='playing-label'>Title</div>";
  html += "<div class='playing-title'>";
  html += wiim.title;
  html += "</div>";
  
  html += "<div class='playing-label'>Album</div>";
  html += "<div class='playing-album'>";
  html += wiim.album;
  html += "</div>";
  
  html += "</div>";   // wiim-info
  html += "</div>";   // wiim-container
  
  html += "</div>";   // section-box-dark

  // --------------------------------------------------------
  // system health
  // --------------------------------------------------------
  html += "<h2>System Health : ";
  html += buildSystemHealthHtml();
  html += "</h2>";

  html += "<p>Audit since update : ";
  html += String(millis() / 60000);
  html += " min.</p>";

  // --------------------------------------------------------
  // Roon ROCK section
  // --------------------------------------------------------
  html += "<div class='section-box'>";
  html += "<h2>Roon ROCK</h2>";
  html += "<table>";

  html += "<tr>";
  html += "<td>Roon OS</td>";
  html += "<td class='ok'>OK</td>";
  html += "<td>Version : ";
  html += gState.roon.rockVersion;
  html += " Running : ";
  html += gState.roon.rockUptime;
  html += "</td>";
  html += "</tr>";

  html += "<tr>";
  html += "<td>Roon Server</td>";
  if (gState.roon.roonStatus == "ONLINE") {
    html += "<td class='ok'>OK</td>";
  } else {
    html += "<td class='ng'>NG</td>";
  }
  html += "<td>Version : ";
  html += gState.roon.roonVersion;
  html += " Running : ";
  html += gState.roon.roonUptimeText;
  html += "</td>";
  html += "</tr>";

  html += "<tr>";
  html += "<td>Music Storage</td>";
  html += "<td class='ok'>OK</td>";
  html += "<td>";
  html += gState.roon.storageFree;
  html += "</td>";
  html += "</tr>";

  html += "<tr>";
  html += "<td>ROCK-side Temp.</td>";
  html += "<td>";
  html += buildTempStatusHtml();
  html += "</td>";
  html += "<td>";
  html += String(gState.env.temperature, 1);
  html += " degrees";
  html += "</td>";
  html += "</tr>";

  html += "<tr>";
  html += "<td>ROCK-side Humidity</td>";
  html += "<td class='ok'>OK</td>";
  html += "<td>";
  html += String(gState.env.humidity, 1);
  html += " %";
  html += "</td>";
  html += "</tr>";

  html += "<tr>";
  html += "<td>ROCK IP Address</td>";
  html += "<td></td>";
  html += "<td>";
  html += gState.roon.rockIP;
  html += "</td>";
  html += "</tr>";

  html += "</table>";

  // --------------------------------------------------------
  // ROCK Fan Spectrum section
  // --------------------------------------------------------

  html += "<h2>Fan Noise Spectrum</h2>";

  if (!gState.spectrum.valid)
  {
    html += "<p>Initializing spectrum monitor...</p>";
  }
  else
  {
    for (int i = 0; i < 6; i++) {
      html += buildSpectrumRow(BAND_NAMES[i], gState.spectrum.bandDb[i]);
    }

    html += "<p class='note'>";
    html += "Peak : ";
    html += String(gState.spectrum.peakFreq, 1);
    html += " Hz / ";
    html += String(gState.spectrum.peakDb, 1);
    html += " dB raw";
    html += "</p>";

    html += "<p class='note'>";
    html += "Spectrum update : every ";
    html += String(SPECTRUM_UPDATE_MS / 1000.0, 1);
    html += " sec, averaged over ";
    html += String(SPECTRUM_AVG_BLOCKS);
    html += " FFT blocks";
    html += "</p>";
  }

  html += "</div>";

  // --------------------------------------------------------
  // footer
  // --------------------------------------------------------
  html += "<hr>";
  html += "<small>";
  html += "ESP32 : ";
  html += WiFi.localIP().toString();
  html += " | RSSI ";
  html += String(WiFi.RSSI());
  html += " dBm";
  html += " | Heap ";
  html += String(ESP.getFreeHeap());
  html += " byte";
  html += "</small>";

  html += "</body>";
  html += "</html>";

  server.send(200, "text/html", html);
}

// ============================================================
// 10. SETUP
// ============================================================

void setup()
{
  Serial.begin(115200);
  delay(2000);

  Serial.println();
  Serial.println("============================================================");
  Serial.println("Audio_Monitor_R3 : boot start");
  Serial.println("============================================================");

  // ---------- initialize state ----------
  gState.roon.roonStatus     = "UNKNOWN";
  gState.roon.roonVersion    = "-";
  gState.roon.roonUptimeText = "-";
  gState.roon.rockVersion    = "-";
  gState.roon.rockUptime     = "-";
  gState.roon.storageFree    = "-";
  gState.roon.rockIP         = "-";

  gState.env.temperature = 0.0;
  gState.env.humidity    = 0.0;
  gState.env.tempStatus  = "OK";

  gState.spectrum.peakFreq = 0.0;
  gState.spectrum.peakDb   = 0.0;
  gState.spectrum.lastUpdateMs = 0;
  gState.spectrum.valid = false;

  for (int i = 0; i < 6; i++) {
    gState.spectrum.bandDb[i] = 0.0;
    gState.spectrum.rawBandDb[i] = 0.0;
  }

  // ---------- DHT ----------
  dht.begin();
  Serial.println("DHT11 READY");

  // ---------- WiFi ----------
  WiFi.mode(WIFI_STA);
  Serial.println("CONNECTING TO WIFI...");
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.println("WiFi CONNECTED");
  Serial.print("IP ADDRESS: ");
  Serial.println(WiFi.localIP());

  // ---------- I2S Mic ----------
  initI2SMic();

  // ---------- initial update ----------
  updateEnvironment();
  updateRoonStatus();

  // ---------- Web server ----------

  server.on("/", handleRoot);


  // WiFi input
  server.on("/wifi", [](){

    Serial.println("INPUT : wifi");

    callInputApi("wifi");

    server.send(
      200,
      "text/plain",
      "OK"
    );

  });


  // Line-input
  server.on("/line-in", [](){

    Serial.println("INPUT : line-in");

    callInputApi("line-in");

    server.send(
      200,
      "text/plain",
      "OK"
    );

  });


  // Bluetooth input
  server.on("/bluetooth", [](){

    Serial.println("INPUT : bluetooth");

    callInputApi("bluetooth");

    server.send(
      200,
      "text/plain",
      "OK"
    );

  });


  // Optical input
  server.on("/optical", [](){

    Serial.println("INPUT : optical");

    callInputApi("optical");

    server.send(
      200,
      "text/plain",
      "OK"
    );

  });


  // Phono MM input
  server.on("/phono", [](){

    Serial.println("INPUT : phono");

    callInputApi("phono");

    server.send(
      200,
      "text/plain",
      "OK"
    );

  });

  // INPUT切替の即時更新
  server.on("/inputTable", [](){
      server.send(200, "text/html", buildInputTableHtml());
  });

  server.begin();

  Serial.println("WEB SERVER STARTED");

  Serial.println("BOOT COMPLETE");
}

// ============================================================
// 11. LOOP
// ============================================================

void loop()
{
  server.handleClient();

  unsigned long now = millis();

  // ---------- Roon ----------
  if (now - lastRoonUpdate >= ROON_UPDATE_MS)
  {
    lastRoonUpdate = now;
    updateRoonStatus();

    Serial.println("[ROON] status updated");
    Serial.println(gState.roon.roonStatus);
    Serial.println(gState.roon.roonVersion);
  }

  // ---------- Environment ----------
  if (now - lastEnvUpdate >= ENV_UPDATE_MS)
  {
    lastEnvUpdate = now;
    updateEnvironment();

    Serial.print("[ENV] Temp = ");
    Serial.print(gState.env.temperature, 1);
    Serial.print(" C, Humidity = ");
    Serial.print(gState.env.humidity, 1);
    Serial.println(" %");
  }

  // ---------- FFT ----------
  if (now - lastFftKick >= FFT_BLOCK_MS)
  {
    lastFftKick = now;
    updateSpectrum();

    if (gState.spectrum.valid) {
      Serial.print("[FFT] ");
      for (int i = 0; i < 6; i++) {
        Serial.print(BAND_NAMES[i]);
        Serial.print(":");
        Serial.print(gState.spectrum.bandDb[i], 1);
        if (i < 5) Serial.print(" / ");
      }
      Serial.println();
    }
  }

  // ---------- WiiM ----------
  static unsigned long lastUpdate = 0;

if (millis() - lastUpdate > 20000)
{
    lastUpdate = millis();
    fetchWiiMStatus();     // INPUT / Status
    fetchWiiMInfoEx();     // Artwork / Metadata
}

}

投稿者

KeroYon