/*
 * =============================================================================
 *  firmware.ino - ESP32-C3 + VL53L5CX (8x8 / 4x4 Time-of-Flight Sensor)
 * =============================================================================
 *
 * Was macht diese Firmware?
 *  1) Versucht sich mit dem gespeicherten WLAN zu verbinden (STA-Modus).
 *     Wird das WLAN nicht gefunden / schlägt die Verbindung fehl, baut der
 *     ESP32 selbst einen Access Point auf (Fallback-AP). Beim Booten wird
 *     ausführlich über die serielle Schnittstelle geloggt (WLAN-Scan,
 *     gewählter Modus, IP, Sensor-Status, ...).
 *  2) Stellt eine Landing-Page bereit, die die zuletzt gelesenen VL53L5CX
 *     Punkte als Array anzeigt (kleines Grid + rohes JSON-Array). Die Seite
 *     bekommt neue Frames per WebSocket gepusht (Fallback: HTTP-Polling).
 *  3) Stellt die Punkte zusätzlich über eine JSON-API bereit: GET /api/points
 *  4) Stellt einen WebSocket-Server bereit (Port 81). Jeder verbundene
 *     Client bekommt jeden neuen Frame sofort nach dem Auslesen gepusht.
 *  5) Config-Page (GET/POST /config): WLAN-SSID/Passwort, Sensor-Frequenz
 *     und Sensor-Auflösung (8x8 / 4x4) sind dort ohne Neu-Flashen änderbar.
 *     Die Werte werden persistent im Flash (Preferences/NVS) gespeichert.
 *  6) Der Sensor wird NUR dann aktiv ausgelesen, wenn mindestens ein
 *     HTTP- oder WebSocket-Client aktiv ist ("jemand ist auf der WebPage").
 *     Ist niemand da, wird das Ranging gestoppt (spart Strom / I2C-Traffic).
 *  7) Debug-Seite GET /i2c: scannt den I2C-Bus (1..126) und zeigt alle
 *     antwortenden Geraete an (z.B. OLED 0x3C, VL53L5CX 0x29). Knopf, um die
 *     Sensor-Initialisierung manuell neu auszuloesen (ohne Neustart).
 *
 * Benötigte Bibliotheken (Arduino Library Manager):
 *   - "SparkFun VL53L5CX" von SparkFun Electronics
 *   - "WebSockets" von Markus Sattler (Links2004)
 *   - ESP32 Board-Paket von Espressif (Board: "ESP32C3 Dev Module")
 *   (WiFi.h, WebServer.h, Wire.h, ESPmDNS.h, Preferences.h sind im ESP32-Core enthalten)
 *
 * Erst-Inbetriebnahme ("Provisioning"):
 *   Wenn das in DEFAULT_WIFI_SSID/-PASSWORD hinterlegte Netz nicht erreichbar
 *   ist, startet der ESP32 einen eigenen Access Point (siehe info.md). Darin
 *   einfach http://192.168.4.1/config öffnen und das echte WLAN eintragen -
 *   kein erneutes Flashen nötig.
 *
 * Verkabelung: siehe info.md im selben Verzeichnis (inkl. ASCII-Schaubild).
 * =============================================================================
 */

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Wire.h>
#include <Preferences.h>
#include <WebSocketsServer.h>
#include <SparkFun_VL53L5CX_Library.h>
#include <U8g2lib.h>

// ----------------------------------------------------------------------------
// KONFIGURATION - Compile-Time Defaults (nur fuer den allerersten Boot, bevor
// jemals etwas über die /config-Seite gespeichert wurde). Danach gewinnen
// immer die in den Preferences/NVS gespeicherten Werte - siehe loadConfig().
// ----------------------------------------------------------------------------

static const char *DEFAULT_WIFI_SSID     = "ZyXEL7A9E80";
static const char *DEFAULT_WIFI_PASSWORD = "geheim";
static const int   DEFAULT_SENSOR_HZ         = 10; // 8x8 erlaubt bis 15 Hz, 4x4 bis 60 Hz
static const int   DEFAULT_SENSOR_RESOLUTION = 64; // 64 = 8x8 Zonen, 16 = 4x4 Zonen

// Fallback Access-Point, falls kein bekanntes WLAN gefunden wird
static const char *AP_SSID     = "VL53L5CX-ESP32";
static const char *AP_PASSWORD = "tofsensor"; // mind. 8 Zeichen (WPA2) oder "" fuer offenes Netz

// Hostname fuer mDNS (nur im STA-Modus nutzbar): http://vl53l5cx.local
static const char *MDNS_NAME = "vl53l5cx";

// Wie lange (ms) versuchen wir, das gespeicherte WLAN zu finden, bevor wir
// auf den eigenen Access Point umschalten?
static const unsigned long WIFI_CONNECT_TIMEOUT_MS = 10000;

// I2C Pins - werden vom ESP32-C3 0.42" OLED-Board intern bereits fuer das
// eingebaute OLED-Display benutzt (SSD1306 @ 0x3C). Laut AliExpress-Listing
// und unabhaengigen Quellen liegen diese Pins bei GPIO8 (SDA) / GPIO9 (SCL)
// (vorher fälschlich auf 5/6 gesetzt - siehe Git-Historie). Da dieses Board
// nur EINEN I2C-Bus hat, haengt der externe VL53L5CX-Sensor (Adresse 0x29)
// einfach an den GLEICHEN Pins - kein Konflikt, da unterschiedliche
// I2C-Adressen. Siehe info.md.
#define SDA_PIN 8
#define SCL_PIN 9

// Wie lange (ms) nach der letzten Anfrage gilt "jemand ist noch auf der
// WebPage"? In dieser Zeit bleibt das Sensor-Ranging aktiv.
static const unsigned long ACTIVE_TIMEOUT_MS = 5000;

// Port des WebSocket-Servers, der neue Frames an verbundene Clients pusht
static const uint16_t WS_PORT = 81;

// ----------------------------------------------------------------------------
// Globale Variablen
// ----------------------------------------------------------------------------

Preferences prefs;
WebServer server(80);
WebSocketsServer webSocket(WS_PORT);
SparkFun_VL53L5CX myImager;
VL53L5CX_ResultsData measurementData;

// Eingebautes 0.42" OLED (SSD1306, 72x40 Pixel sichtbar), haengt am selben
// I2C-Bus wie der VL53L5CX (siehe SDA_PIN/SCL_PIN oben).
U8G2_SSD1306_72X40_ER_F_HW_I2C u8g2(U8G2_R0, /* reset= */ U8X8_PIN_NONE);
unsigned long lastDisplayMillis = 0;

// Laufzeit-Konfiguration (aus Preferences/NVS geladen, ueber /config aenderbar)
String cfgSsid;
String cfgPassword;
int cfgHz;
int cfgResolution;
unsigned long sampleIntervalMs;

bool sensorFound   = false; // Sensor beim Start erfolgreich initialisiert?
bool sensorRanging = false; // Laeuft das Ranging aktuell?
bool apMode        = false; // true = eigener Access Point, false = WLAN-Client

unsigned long lastClientMillis = 0; // letzter HTTP-Zugriff auf "/" oder "/api/points"
unsigned long lastSampleMillis = 0;

float latestDistanceMm[64];
int   latestStatus[64];
unsigned long latestTimestamp = 0;
bool haveData = false;

String wifiModeStr = "unbekannt";
String ipAddrStr   = "-";

// ----------------------------------------------------------------------------
// Konfiguration laden / speichern (Flash, ueberlebt Neustarts)
// ----------------------------------------------------------------------------
void loadConfig() {
  prefs.begin("cfg", true); // read-only
  cfgSsid       = prefs.getString("ssid", DEFAULT_WIFI_SSID);
  cfgPassword   = prefs.getString("pass", DEFAULT_WIFI_PASSWORD);
  cfgHz         = prefs.getInt("hz", DEFAULT_SENSOR_HZ);
  cfgResolution = prefs.getInt("res", DEFAULT_SENSOR_RESOLUTION);
  prefs.end();

  if (cfgResolution != 16 && cfgResolution != 64) cfgResolution = DEFAULT_SENSOR_RESOLUTION;
  int maxHz = (cfgResolution == 64) ? 15 : 60;
  if (cfgHz < 1) cfgHz = 1;
  if (cfgHz > maxHz) cfgHz = maxHz;
  sampleIntervalMs = 1000 / cfgHz;

  Serial.println("--- Gespeicherte Konfiguration (NVS) ---");
  Serial.printf("  WLAN SSID      : %s\n", cfgSsid.c_str());
  Serial.printf("  WLAN Passwort  : %s (%u Zeichen)\n",
                cfgPassword.length() ? "********" : "(leer)",
                (unsigned)cfgPassword.length());
  Serial.printf("  Sensor Aufloes.: %d Zonen (%s)\n", cfgResolution, cfgResolution == 64 ? "8x8" : "4x4");
  Serial.printf("  Sensor Frequenz: %d Hz\n", cfgHz);
}

void saveConfig(const String &ssid, const String &pass, int hz, int res) {
  prefs.begin("cfg", false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.putInt("hz", hz);
  prefs.putInt("res", res);
  prefs.end();
}

// ----------------------------------------------------------------------------
// WLAN: erst gespeichertes Netz versuchen, sonst eigenen AP aufbauen
// Loggt ausfuehrlich: gefundene Netze, gewaehlter Modus, IP, MAC, ...
// ----------------------------------------------------------------------------
void connectWiFi() {
  Serial.println("\n--- WLAN-Scan ---");
  WiFi.mode(WIFI_STA);
  int n = WiFi.scanNetworks();
  if (n <= 0) {
    Serial.println("  Keine WLAN-Netzwerke in Reichweite gefunden.");
  } else {
    bool foundConfigured = false;
    for (int i = 0; i < n; i++) {
      bool isTarget = WiFi.SSID(i) == cfgSsid;
      if (isTarget) foundConfigured = true;
      Serial.printf("  %s %-32s RSSI=%4d dBm  Kanal=%2d  %s\n",
                    isTarget ? "[ZIEL]" : "      ",
                    WiFi.SSID(i).c_str(), WiFi.RSSI(i), WiFi.channel(i),
                    WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "(offen)" : "(verschluesselt)");
    }
    Serial.printf("  Konfiguriertes Netz \"%s\" %s gefunden.\n",
                  cfgSsid.c_str(), foundConfigured ? "wurde" : "wurde NICHT");
  }
  WiFi.scanDelete();

  Serial.printf("\nVerbinde mit WLAN \"%s\" ...\n", cfgSsid.c_str());
  WiFi.begin(cfgSsid.c_str(), cfgPassword.c_str());
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_CONNECT_TIMEOUT_MS) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    apMode = false;
    wifiModeStr = "STA (" + cfgSsid + ")";
    ipAddrStr = WiFi.localIP().toString();

    Serial.println("--- WLAN verbunden ---");
    Serial.printf("  Modus       : Station (STA)\n");
    Serial.printf("  SSID        : %s\n", cfgSsid.c_str());
    Serial.printf("  IP-Adresse  : %s\n", ipAddrStr.c_str());
    Serial.printf("  MAC-Adresse : %s\n", WiFi.macAddress().c_str());
    Serial.printf("  RSSI        : %d dBm\n", WiFi.RSSI());
    Serial.printf("  Kanal       : %d\n", WiFi.channel());

    if (MDNS.begin(MDNS_NAME)) {
      Serial.printf("  mDNS        : http://%s.local\n", MDNS_NAME);
    } else {
      Serial.println("  mDNS        : Start fehlgeschlagen");
    }
  } else {
    Serial.println("--- WLAN-Verbindung fehlgeschlagen ---");
    Serial.printf("  Letzter Status-Code: %d\n", (int)WiFi.status());
    Serial.println("  -> baue eigenen Access Point auf.");

    WiFi.mode(WIFI_AP);
    WiFi.softAP(AP_SSID, AP_PASSWORD);
    apMode = true;
    wifiModeStr = "AP (" + String(AP_SSID) + ")";
    ipAddrStr = WiFi.softAPIP().toString();

    Serial.println("--- Access Point gestartet ---");
    Serial.printf("  Modus       : Access Point (AP)\n");
    Serial.printf("  SSID        : %s\n", AP_SSID);
    Serial.printf("  Passwort    : %s\n", AP_PASSWORD);
    Serial.printf("  IP-Adresse  : %s\n", ipAddrStr.c_str());
    Serial.printf("  MAC-Adresse : %s\n", WiFi.softAPmacAddress().c_str());
    Serial.println("  -> Bitte mit diesem WLAN verbinden und http://192.168.4.1/config oeffnen,");
    Serial.println("     um das echte WLAN einzutragen (kein Neu-Flashen noetig).");
  }
}

// ----------------------------------------------------------------------------
// I2C-Bus + OLED initialisieren (muss vor WiFi-Verbindung & Sensor-Setup
// laufen, damit das Display von Anfang an benutzbar ist)
// ----------------------------------------------------------------------------
void setupI2CAndDisplay() {
  Wire.begin(SDA_PIN, SCL_PIN);
  Wire.setClock(400000); // Standard Fast-Mode I2C; auf 1000000 erhoehbar wenn Verkabelung kurz/stabil ist

  u8g2.begin();
  u8g2.setFont(u8g2_font_5x7_tr);
  u8g2.clearBuffer();
  u8g2.drawStr(0, 7, "Starte...");
  u8g2.sendBuffer();

  Serial.printf("I2C gestartet: SDA=GPIO%d, SCL=GPIO%d (OLED + VL53L5CX teilen sich den Bus)\n", SDA_PIN, SCL_PIN);
}

// ----------------------------------------------------------------------------
// Zeigt WLAN-Modus, IP-Adresse und Sensor-Status auf dem 0.42" OLED an
// ----------------------------------------------------------------------------
void updateDisplay() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_5x7_tr);
  u8g2.drawStr(0, 7,  apMode ? "WLAN: AP" : "WLAN: STA");
  u8g2.drawStr(0, 16, ipAddrStr.c_str());
  u8g2.drawStr(0, 25, sensorFound ? (sensorRanging ? "Sensor: aktiv" : "Sensor: idle") : "Sensor: ---");
  u8g2.sendBuffer();
}

// ----------------------------------------------------------------------------
// VL53L5CX Sensor initialisieren (Ranging wird hier NOCH NICHT gestartet,
// das passiert erst dynamisch, wenn ein Client aktiv ist)
// I2C-Bus wurde bereits in setupI2CAndDisplay() gestartet.
// ----------------------------------------------------------------------------
bool setupSensor() {
  Serial.println("\n--- VL53L5CX Initialisierung ---");
  Serial.printf("  I2C Pins   : SDA=GPIO%d, SCL=GPIO%d\n", SDA_PIN, SCL_PIN);

  if (!myImager.begin()) {
    Serial.println("  FEHLER: Sensor nicht gefunden! Verkabelung pruefen (siehe info.md).");
    return false;
  }

  myImager.setResolution(cfgResolution);
  myImager.setRangingFrequency(cfgHz);
  Serial.printf("  Aufloesung : %d Zonen (%s)\n", cfgResolution, cfgResolution == 64 ? "8x8" : "4x4");
  Serial.printf("  Frequenz   : %d Hz\n", cfgHz);
  Serial.println("  Sensor bereit (Ranging startet erst, wenn ein Client aktiv ist).");
  return true;
}

// ----------------------------------------------------------------------------
// Hilfsfunktion: merkt sich, dass gerade ein HTTP-Client zugegriffen hat
// ----------------------------------------------------------------------------
void markClientActive() {
  lastClientMillis = millis();
}

// ----------------------------------------------------------------------------
// WebSocket Events
// ----------------------------------------------------------------------------
void onWsEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
  switch (type) {
    case WStype_CONNECTED:
      Serial.printf("[WS] Client #%u verbunden von %s\n", num, webSocket.remoteIP(num).toString().c_str());
      markClientActive();
      break;
    case WStype_DISCONNECTED:
      Serial.printf("[WS] Client #%u getrennt\n", num);
      break;
    default:
      break; // eingehende Nachrichten werden aktuell nicht ausgewertet
  }
}

// ----------------------------------------------------------------------------
// JSON-Antwort fuer /api/points + WebSocket-Push zusammenbauen
// (ohne externe JSON-Bibliothek)
// ----------------------------------------------------------------------------
String buildPointsJson() {
  bool httpActive = (millis() - lastClientMillis) < ACTIVE_TIMEOUT_MS;
  size_t wsClients = webSocket.connectedClients();
  bool active = httpActive || (wsClients > 0);

  int rows = (cfgResolution == 64) ? 8 : 4;
  int cols = rows;
  int count = rows * cols;

  String j = "{";
  j += "\"active\":";       j += (active ? "true" : "false");        j += ",";
  j += "\"ranging\":";      j += (sensorRanging ? "true" : "false"); j += ",";
  j += "\"sensorFound\":";  j += (sensorFound ? "true" : "false");   j += ",";
  j += "\"hz\":";           j += cfgHz;                              j += ",";
  j += "\"resolution\":";   j += cfgResolution;                      j += ",";
  j += "\"rows\":";         j += rows;                               j += ",";
  j += "\"cols\":";         j += cols;                               j += ",";
  j += "\"wsClients\":";    j += (int)wsClients;                     j += ",";
  j += "\"timestampMs\":";  j += latestTimestamp;                    j += ",";
  j += "\"wifiMode\":\"";   j += wifiModeStr;                        j += "\",";
  j += "\"ip\":\"";         j += ipAddrStr;                          j += "\",";

  j += "\"distanceMm\":[";
  for (int i = 0; i < count; i++) {
    j += haveData ? String((int)latestDistanceMm[i]) : "0";
    if (i < count - 1) j += ",";
  }
  j += "],";

  j += "\"status\":[";
  for (int i = 0; i < count; i++) {
    j += haveData ? String(latestStatus[i]) : "0";
    if (i < count - 1) j += ",";
  }
  j += "]";

  j += "}";
  return j;
}

// ----------------------------------------------------------------------------
// HTTP Handler: /api/points -> liefert die Punkte als JSON
// ----------------------------------------------------------------------------
void handleApiPoints() {
  markClientActive();
  server.send(200, "application/json", buildPointsJson());
}

// ----------------------------------------------------------------------------
// HTTP Handler: / -> Landing Page (HTML, zeigt Punkte als Grid + als Array)
// Bezieht neue Frames per WebSocket (Push), Fallback: HTTP-Polling.
// ----------------------------------------------------------------------------
const char INDEX_HTML_TEMPLATE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VL53L5CX Live Punkte</title>
<style>
  body { font-family: monospace, sans-serif; background:#111; color:#eee; margin:0; padding:16px; }
  h1 { font-size:1.2rem; margin-bottom:4px; }
  a { color:#6cf; }
  #meta { font-size:0.85rem; color:#9c9; margin-bottom:16px; }
  #apbanner { background:#642; color:#fc6; padding:8px; border-radius:6px; margin-bottom:12px; }
  #grid { display:grid; gap:2px; margin-bottom:16px; }
  .cell {
    display:flex; align-items:center; justify-content:center;
    width:38px; height:38px;
    font-size:0.6rem; border-radius:3px; color:#000;
    background:#333;
  }
  pre#raw {
    background:#000; color:#0f0; padding:10px; border-radius:6px;
    max-height:300px; overflow:auto; font-size:0.75rem;
  }
  .ok   { color:#9f9; }
  .warn { color:#fc6; }
</style>
</head>
<body>
  <h1>VL53L5CX &mdash; ESP32-C3 Live Punkte</h1>
  <div id="apbanner" style="display:none;">
    &#9888; Kein bekanntes WLAN gefunden &ndash; ESP32 läuft im eigenen Access-Point-Modus.
    <a href="/config">Jetzt WLAN konfigurieren</a>
  </div>
  <p><a href="/config">&#9881; Konfiguration (WLAN / Sensor)</a> | <a href="/i2c">&#128270; I2C Uebersicht</a></p>
  <div id="meta">Lade Status ...</div>
  <div id="grid"></div>
  <h2 style="font-size:1rem;">Rohdaten (Array, distanceMm)</h2>
  <pre id="raw">-</pre>

<script>
  const POLL_MS  = __POLL_INTERVAL_MS__;
  const WS_PORT  = __WS_PORT__;
  const grid = document.getElementById('grid');
  const metaEl = document.getElementById('meta');
  let cells = [];
  let currentCellCount = 0;
  let usingWs = false;
  let ws;

  function ensureGrid(rows, cols) {
    const total = rows * cols;
    if (total === currentCellCount) return;
    grid.innerHTML = '';
    grid.style.gridTemplateColumns = `repeat(${cols}, 38px)`;
    grid.style.gridTemplateRows = `repeat(${rows}, 38px)`;
    cells = [];
    for (let i = 0; i < total; i++) {
      const c = document.createElement('div');
      c.className = 'cell';
      grid.appendChild(c);
      cells.push(c);
    }
    currentCellCount = total;
  }

  function colorForDistance(mm) {
    if (!mm || mm <= 0) return '#333';
    const clamped = Math.max(200, Math.min(4000, mm));
    const t = (clamped - 200) / (4000 - 200); // 0 = nah, 1 = weit
    const r = Math.round(255 * (1 - t));
    const g = Math.round(255 * t);
    return `rgb(${r},${g},80)`;
  }

  function renderData(data) {
    document.getElementById('apbanner').style.display =
      data.wifiMode && data.wifiMode.startsWith('AP') ? 'block' : 'none';

    ensureGrid(data.rows, data.cols);

    metaEl.innerHTML =
      `Quelle: ${usingWs ? 'WebSocket (Push)' : 'HTTP-Polling'} | ` +
      `WLAN: ${data.wifiMode} | IP: ${data.ip} | Aufl.: ${data.rows}x${data.cols} @ ${data.hz} Hz | ` +
      `WS-Clients: ${data.wsClients} | ` +
      (data.sensorFound
        ? `<span class="${data.ranging ? 'ok' : 'warn'}">${data.ranging ? 'Sensor liest aktiv' : 'Sensor pausiert (kein Client)'}</span>`
        : '<span class="warn">Sensor NICHT gefunden!</span>');

    for (let i = 0; i < data.distanceMm.length; i++) {
      const mm = data.distanceMm[i];
      cells[i].style.background = colorForDistance(mm);
      cells[i].textContent = mm;
    }

    document.getElementById('raw').textContent = JSON.stringify(data.distanceMm);
  }

  function connectWs() {
    const url = `ws://${location.hostname}:${WS_PORT}/`;
    ws = new WebSocket(url);
    ws.onopen = () => { usingWs = true; console.log('WS verbunden:', url); };
    ws.onmessage = (evt) => { try { renderData(JSON.parse(evt.data)); } catch (e) {} };
    ws.onclose = () => { usingWs = false; setTimeout(connectWs, 2000); };
    ws.onerror = () => { ws.close(); };
  }

  async function pollFallback() {
    if (!usingWs) {
      try {
        const res = await fetch('/api/points');
        renderData(await res.json());
      } catch (e) {
        metaEl.innerHTML = '<span class="warn">Verbindung zum ESP32 verloren...</span>';
      }
    }
    setTimeout(pollFallback, POLL_MS);
  }

  connectWs();
  pollFallback();
</script>
</body>
</html>
)rawliteral";

void handleRoot() {
  markClientActive();
  String page = String(INDEX_HTML_TEMPLATE);
  page.replace("__POLL_INTERVAL_MS__", String(sampleIntervalMs));
  page.replace("__WS_PORT__", String(WS_PORT));
  server.send(200, "text/html", page);
}

// ----------------------------------------------------------------------------
// HTTP Handler: /config -> WLAN- und Sensor-Einstellungen aendern
// ----------------------------------------------------------------------------
const char CONFIG_HTML_TEMPLATE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VL53L5CX Konfiguration</title>
<style>
  body { font-family: monospace, sans-serif; background:#111; color:#eee; margin:0; padding:16px; max-width:480px; }
  a { color:#6cf; }
  h1 { font-size:1.2rem; }
  label { display:block; margin-bottom:14px; font-size:0.9rem; }
  input, select { width:100%; padding:6px; margin-top:4px; background:#222; color:#eee; border:1px solid #444; border-radius:4px; box-sizing:border-box; }
  button { padding:10px 16px; background:#2a6; color:#fff; border:none; border-radius:6px; font-size:1rem; cursor:pointer; }
  button:hover { background:#3b7; }
  #status { font-size:0.85rem; color:#9c9; margin-top:16px; }
</style>
</head>
<body>
  <h1>&#9881; VL53L5CX Konfiguration</h1>
  <p><a href="/">&larr; zurueck zur Live-Ansicht</a> | <a href="/i2c">&#128270; I2C Uebersicht</a></p>
  <form method="POST" action="/config">
    <label>WLAN SSID
      <input type="text" name="ssid" value="__SSID__" maxlength="32">
    </label>
    <label>WLAN Passwort (leer lassen = unveraendert)
      <input type="password" name="pass" value="" maxlength="64" placeholder="unveraendert">
    </label>
    <label>Sensor Aufloesung
      <select name="res" id="res" onchange="adjustHz()">
        <option value="64" __SEL64__>8&times;8 (64 Zonen, max. 15 Hz)</option>
        <option value="16" __SEL16__>4&times;4 (16 Zonen, max. 60 Hz)</option>
      </select>
    </label>
    <label>Sensor Frequenz (Hz)
      <input type="number" name="hz" id="hz" value="__HZ__" min="1" max="__MAXHZ__">
    </label>
    <button type="submit">Speichern &amp; Neustart</button>
  </form>
  <div id="status">Aktuell: __WIFIMODE__ | IP __IP__</div>

<script>
function adjustHz(){
  const res = document.getElementById('res').value;
  const hz = document.getElementById('hz');
  hz.max = (res === '64') ? 15 : 60;
  if (parseInt(hz.value, 10) > parseInt(hz.max, 10)) hz.value = hz.max;
}
</script>
</body>
</html>
)rawliteral";

void handleConfigGet() {
  markClientActive();
  String page = String(CONFIG_HTML_TEMPLATE);
  page.replace("__SSID__", cfgSsid);
  page.replace("__SEL64__", cfgResolution == 64 ? "selected" : "");
  page.replace("__SEL16__", cfgResolution == 16 ? "selected" : "");
  page.replace("__HZ__", String(cfgHz));
  page.replace("__MAXHZ__", String(cfgResolution == 64 ? 15 : 60));
  page.replace("__WIFIMODE__", wifiModeStr);
  page.replace("__IP__", ipAddrStr);
  server.send(200, "text/html", page);
}

void handleConfigPost() {
  String newSsid = server.arg("ssid");
  String newPass = server.arg("pass"); // leer = Passwort unveraendert
  int newRes = server.arg("res").toInt();
  int newHz  = server.arg("hz").toInt();

  if (newRes != 16 && newRes != 64) newRes = cfgResolution;
  int maxHz = (newRes == 64) ? 15 : 60;
  if (newHz < 1) newHz = 1;
  if (newHz > maxHz) newHz = maxHz;
  if (newSsid.length() == 0) newSsid = cfgSsid;
  if (newPass.length() == 0) newPass = cfgPassword;

  saveConfig(newSsid, newPass, newHz, newRes);

  Serial.println("\n--- Neue Konfiguration gespeichert (/config) ---");
  Serial.printf("  SSID: %s | Aufloesung: %d | Frequenz: %d Hz\n", newSsid.c_str(), newRes, newHz);
  Serial.println("  Geraet startet in 1 Sekunde neu...");

  server.send(200, "text/html",
    "<html><body style='font-family:monospace;background:#111;color:#eee;padding:16px;'>"
    "<h1>Gespeichert!</h1><p>Das Geraet startet jetzt neu und verbindet sich neu...</p>"
    "</body></html>");

  delay(1000);
  ESP.restart();
}

void handleNotFound() {
  server.send(404, "text/plain", "404 - Not found");
}

// ----------------------------------------------------------------------------
// I2C-Scan: testet alle Adressen 1..126 mit einem leeren Schreibversuch.
// Laeuft komplett synchron innerhalb des HTTP-Handlers (Arduino loop() ist
// single-threaded), stoert daher nichts am laufenden Sensor-Ranging.
// ----------------------------------------------------------------------------
String knownI2CDeviceName(uint8_t addr) {
  switch (addr) {
    case 0x3C: return "OLED-Display (SSD1306)";
    case 0x29: return "VL53L5CX (Standard-Adresse)";
    default:   return "unbekannt";
  }
}

int scanI2C(uint8_t *foundAddresses, int maxCount) {
  int count = 0;
  for (uint8_t addr = 1; addr < 127 && count < maxCount; addr++) {
    Wire.beginTransmission(addr);
    uint8_t err = Wire.endTransmission();
    if (err == 0) {
      foundAddresses[count++] = addr;
    }
  }
  return count;
}

// ----------------------------------------------------------------------------
// HTTP Handler: /i2c -> I2C-Bus-Uebersicht (Debug-Seite)
// ----------------------------------------------------------------------------
const char I2C_HTML_TEMPLATE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>I2C Uebersicht</title>
<style>
  body { font-family: monospace, sans-serif; background:#111; color:#eee; margin:0; padding:16px; max-width:480px; }
  a { color:#6cf; }
  h1 { font-size:1.2rem; }
  table { width:100%; border-collapse:collapse; margin:12px 0; }
  th, td { text-align:left; padding:6px 8px; border-bottom:1px solid #333; font-size:0.9rem; }
  .ok   { color:#9f9; }
  .warn { color:#fc6; }
  button { padding:8px 14px; background:#2a6; color:#fff; border:none; border-radius:6px; font-size:0.9rem; cursor:pointer; margin-right:8px; }
  button:hover { background:#3b7; }
  #status { font-size:0.85rem; color:#9c9; margin-top:16px; }
</style>
</head>
<body>
  <h1>&#128270; I2C Uebersicht</h1>
  <p><a href="/">&larr; zurueck zur Live-Ansicht</a> | <a href="/config">Konfiguration</a></p>

  <h2 style="font-size:1rem;">Gefundene Geraete (SDA=GPIO__SDA__, SCL=GPIO__SCL__)</h2>
  <table>
    <tr><th>Adresse</th><th>Bekannt als</th></tr>
    __ROWS__
  </table>

  <h2 style="font-size:1rem;">VL53L5CX Status</h2>
  <p>Initialisiert: <span class="__SENSORCLASS__">__SENSORTXT__</span></p>
  <form method="POST" action="/i2c/retry-sensor" style="display:inline;">
    <button type="submit">Sensor erneut initialisieren</button>
  </form>
  <a href="/i2c"><button type="button">Neu scannen</button></a>

  <div id="status">Tipp: Nur die OLED-Adresse 0x3C gefunden? -&gt; Verkabelung/Versorgung des
  VL53L5CX pruefen (3V3, GND, SDA, SCL, LPN auf 3V3). Adresse 0x29 da, aber Sensor trotzdem
  nicht initialisiert? -&gt; eventuell Bibliotheks-/Timing-Problem, nicht Verkabelung.</div>
</body>
</html>
)rawliteral";

void handleI2CGet() {
  markClientActive();

  uint8_t found[127];
  int count = scanI2C(found, 127);

  String rows = "";
  if (count == 0) {
    rows = "<tr><td colspan=\"2\" class=\"warn\">Keine Geraete gefunden!</td></tr>";
  } else {
    for (int i = 0; i < count; i++) {
      char hex[8];
      snprintf(hex, sizeof(hex), "0x%02X", found[i]);
      rows += "<tr><td>" + String(hex) + "</td><td>" + knownI2CDeviceName(found[i]) + "</td></tr>";
    }
  }

  String page = String(I2C_HTML_TEMPLATE);
  page.replace("__ROWS__", rows);
  page.replace("__SDA__", String(SDA_PIN));
  page.replace("__SCL__", String(SCL_PIN));
  page.replace("__SENSORCLASS__", sensorFound ? "ok" : "warn");
  page.replace("__SENSORTXT__", sensorFound ? "ja" : "nein");
  server.send(200, "text/html", page);
}

void handleSensorRetryPost() {
  Serial.println("\n--- Manueller Retry: VL53L5CX Initialisierung (/i2c/retry-sensor) ---");
  if (sensorRanging) {
    myImager.stopRanging();
    sensorRanging = false;
  }
  sensorFound = setupSensor();
  updateDisplay();
  server.sendHeader("Location", "/i2c");
  server.send(303);
}

// ----------------------------------------------------------------------------
// Webserver-Routen registrieren
// ----------------------------------------------------------------------------
void setupWebServer() {
  server.on("/", HTTP_GET, handleRoot);
  server.on("/api/points", HTTP_GET, handleApiPoints);
  server.on("/config", HTTP_GET, handleConfigGet);
  server.on("/config", HTTP_POST, handleConfigPost);
  server.on("/i2c", HTTP_GET, handleI2CGet);
  server.on("/i2c/retry-sensor", HTTP_POST, handleSensorRetryPost);
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("Webserver gestartet auf Port 80 (/, /api/points, /config, /i2c).");
}

// ----------------------------------------------------------------------------
// setup() / loop()
// ----------------------------------------------------------------------------
void setup() {
  Serial.begin(115200);
  delay(300);

  Serial.println("\n\n=== VL53L5CX ESP32-C3 Firmware startet ===");
  Serial.printf("Chip        : %s Rev %u, %u Kern(e), %u MHz\n",
                ESP.getChipModel(), (unsigned)ESP.getChipRevision(),
                (unsigned)ESP.getChipCores(), (unsigned)ESP.getCpuFreqMHz());
  Serial.printf("Flash       : %u KB\n", (unsigned)(ESP.getFlashChipSize() / 1024));
  Serial.printf("Freier Heap : %u Bytes\n", (unsigned)ESP.getFreeHeap());

  setupI2CAndDisplay();
  loadConfig();
  connectWiFi();
  updateDisplay();

  sensorFound = setupSensor();
  updateDisplay();

  setupWebServer();

  webSocket.begin();
  webSocket.onEvent(onWsEvent);
  Serial.printf("WebSocket gestartet: ws://%s:%u/\n", ipAddrStr.c_str(), WS_PORT);

  Serial.println("=== Setup abgeschlossen ===\n");
}

void loop() {
  server.handleClient();
  webSocket.loop();

  // OLED periodisch aktualisieren (z.B. damit "Sensor: aktiv/idle" live mitgeht)
  if (millis() - lastDisplayMillis >= 1000) {
    lastDisplayMillis = millis();
    updateDisplay();
  }

  if (!sensorFound) return;

  bool clientActive = (millis() - lastClientMillis < ACTIVE_TIMEOUT_MS) || (webSocket.connectedClients() > 0);

  // Ranging dynamisch starten/stoppen, je nachdem ob jemand auf der WebPage ist
  if (clientActive && !sensorRanging) {
    myImager.startRanging();
    sensorRanging = true;
    lastSampleMillis = 0; // sofort beim naechsten loop()-Durchlauf neu lesen
    Serial.println("Client aktiv -> Sensor-Ranging gestartet.");
  } else if (!clientActive && sensorRanging) {
    myImager.stopRanging();
    sensorRanging = false;
    Serial.println("Kein Client mehr aktiv -> Sensor-Ranging gestoppt.");
  }

  // Daten mit der konfigurierten Frequenz auslesen (nicht-blockierend) und
  // sofort per WebSocket an alle verbundenen Clients pushen
  if (sensorRanging && (millis() - lastSampleMillis >= sampleIntervalMs)) {
    lastSampleMillis = millis();
    if (myImager.isDataReady()) {
      if (myImager.getRangingData(&measurementData)) {
        for (int i = 0; i < cfgResolution; i++) {
          latestDistanceMm[i] = measurementData.distance_mm[i];
          latestStatus[i]     = measurementData.target_status[i];
        }
        latestTimestamp = millis();
        haveData = true;

        if (webSocket.connectedClients() > 0) {
          String json = buildPointsJson(); // broadcastTXT() will nicht-const String&, kein Temporary
          webSocket.broadcastTXT(json);
        }
      }
    }
  }
}
