⚙ Bredahl - Udviklingsprojekter

← Puls Timer

Kode – Puls Timer

Download alle filer (.zip)
main.ino · 2263 linjer Download
/* * btf_pulsetimer_v1.ino * ESP32 Puls-Timer med multi-tids trigger, NTP-sync, OTA og web-UI * Sejlads-tema (dansk UI, engelske internals) * Rel'ae output: GPIO 16 (HIGH = aktiv) * * FLBRE / 2026 - v1.1 */#include <WiFi.h>#include <WebServer.h>#include <Preferences.h>#include <LittleFS.h>#include <ESPmDNS.h>#include <HTTPClient.h>#include <HTTPUpdate.h>#include <WiFiClientSecure.h>#include <DNSServer.h>#include <time.h>#include <sys/time.h>// ---------- Konstanter ----------#define FW_VERSION       "v1.1"#define RELAY_PIN        16#define MAX_TIMES        24#define OTA_URL          "https://datahome.bredahl.dk/uploads/Sensor/btf_pulsetimer.bin"#define AP_SSID_PREFIX   "PulseTimer-Setup"#define AP_PASS          "sejlads2026"#define DEFAULT_NTP      "pool.ntp.org"#define DEFAULT_DEVICE   "pulsetimer"#define DEFAULT_PULSE_S  3#define DEFAULT_ADMINPW  "pulsetimer"#define MAX_SESSIONS     4// ---------- State ----------Preferences prefs;WebServer   server(80);DNSServer   dnsServer;struct Config {  int    numTimes;  String times[MAX_TIMES];   // "HH:MM:SS"  String ntpServer;  bool   summerTime;         // true = CEST (+2), false = CET (+1)  int    pulseSeconds;  String wifiSSID;  String wifiPass;  String deviceName;  String adminPass;          // tom = ingen auth kraevet};Config cfg;// ---------- Forward decls ----------void loadConfig();void saveConfig();void saveWifi();void backupToFS();void restoreFromFS();void resetSequence();void saveSequenceState();void loadSequenceState();String randToken();String getCookie(const String &name);bool isAuthed();String issueSession();void clearSession(const String &token);bool rejectIfUnauthed();void handleApiLogin();void handleApiLogout();void handleApiPassword();void applyTimezone();void startPulse(const char* reason);void updateRelay();void checkTriggers();bool parseHms(const String &s, int &hh, int &mm, int &ss);long secondsToNext();String nextTimeLabel();void setupWiFiSTA();void startAP();void setupNTP();bool getLocalNow(struct tm &out);String jsonEscape(const String &s);String jsonGetString(const String &body, const String &key);void sendPage(const char* title, const char* activePage, const char* bodyProgmem);void handleControl();void handleDashboard();void handleSettings();void handleFirmware();void handleApiStatus();void handleApiConfigGet();void handleApiConfigSave();void handleApiWifi();void handleApiWifiScan();void handleApiPulse();void handleApiRestart();void handleApiSync();void handleApiOta();void handleApiOtaStatus();void setupWebServer();bool          relayActive       = false;unsigned long relayOffMillis    = 0;bool          hasFired[MAX_TIMES];   // per-index: har denne tid fyret siden sidste konfig?bool          sequenceComplete  = false; // alle konfigurerede tider fyret - stop indtil nye tidertime_t        lastNtpSync       = 0;bool          apMode            = false;String        lastOtaMsg        = "";unsigned long bootMillis        = 0;// ==================== UTIL ====================bool getLocalNow(struct tm &out) {  time_t now = time(nullptr);  if (now < 1700000000) return false;  localtime_r(&now, &out);  return true;}void applyTimezone() {  // Fixed offset baseret p'aa checkbox - sommertid (CEST +2) eller vintertid (CET +1)  if (cfg.summerTime) {    setenv("TZ", "CEST-2", 1);  } else {    setenv("TZ", "CET-1", 1);  }  tzset();}// ==================== CONFIG ====================void loadConfig() {  prefs.begin("pulsetimer", true);  cfg.numTimes     = prefs.getInt("numTimes", 1);  if (cfg.numTimes < 0) cfg.numTimes = 0;  if (cfg.numTimes > MAX_TIMES) cfg.numTimes = MAX_TIMES;  for (int i = 0; i < MAX_TIMES; i++) {    char key[8];    snprintf(key, sizeof(key), "t%d", i);    cfg.times[i] = prefs.getString(key, "");  }  cfg.ntpServer    = prefs.getString("ntp",      DEFAULT_NTP);  cfg.summerTime   = prefs.getBool  ("dst",      true);  cfg.pulseSeconds = prefs.getInt   ("pulse",    DEFAULT_PULSE_S);  cfg.wifiSSID     = prefs.getString("ssid",     "");  cfg.wifiPass     = prefs.getString("pass",     "");  cfg.deviceName   = prefs.getString("name",     DEFAULT_DEVICE);  cfg.adminPass    = prefs.getString("apass",    DEFAULT_ADMINPW);  prefs.end();  // Sanity  if (cfg.pulseSeconds < 1)   cfg.pulseSeconds = 1;  if (cfg.pulseSeconds > 600) cfg.pulseSeconds = 600;  if (cfg.deviceName.length() == 0) cfg.deviceName = DEFAULT_DEVICE;}void saveConfig() {  prefs.begin("pulsetimer", false);  prefs.putInt("numTimes",   cfg.numTimes);  for (int i = 0; i < MAX_TIMES; i++) {    char key[8];    snprintf(key, sizeof(key), "t%d", i);    prefs.putString(key, cfg.times[i]);  }  prefs.putString("ntp",     cfg.ntpServer);  prefs.putBool  ("dst",     cfg.summerTime);  prefs.putInt   ("pulse",   cfg.pulseSeconds);  prefs.putString("name",    cfg.deviceName);  prefs.putString("apass",   cfg.adminPass);  prefs.end();  backupToFS();}void saveWifi() {  prefs.begin("pulsetimer", false);  prefs.putString("ssid", cfg.wifiSSID);  prefs.putString("pass", cfg.wifiPass);  prefs.end();  backupToFS();}void backupToFS() {  if (!LittleFS.begin(true)) return;  File f = LittleFS.open("/config.txt", "w");  if (!f) return;  f.printf("name=%s\n",  cfg.deviceName.c_str());  f.printf("ssid=%s\n",  cfg.wifiSSID.c_str());  f.printf("pass=%s\n",  cfg.wifiPass.c_str());  f.printf("apass=%s\n", cfg.adminPass.c_str());  f.printf("ntp=%s\n",   cfg.ntpServer.c_str());  f.printf("dst=%d\n",   cfg.summerTime ? 1 : 0);  f.printf("pulse=%d\n", cfg.pulseSeconds);  f.printf("n=%d\n",     cfg.numTimes);  for (int i = 0; i < MAX_TIMES; i++) {    f.printf("t%d=%s\n", i, cfg.times[i].c_str());  }  f.close();}void restoreFromFS() {  // Kun brugt hvis NVS er tom (efter erase-flash)  if (!LittleFS.begin(true)) return;  if (!LittleFS.exists("/config.txt")) return;  File f = LittleFS.open("/config.txt", "r");  if (!f) return;  while (f.available()) {    String line = f.readStringUntil('\n');    line.trim();    int eq = line.indexOf('=');    if (eq < 1) continue;    String k = line.substring(0, eq);    String v = line.substring(eq + 1);    if (k == "name")  cfg.deviceName   = v;    else if (k == "ssid")  cfg.wifiSSID     = v;    else if (k == "pass")  cfg.wifiPass     = v;    else if (k == "apass") cfg.adminPass    = v;    else if (k == "ntp")   cfg.ntpServer    = v;    else if (k == "dst")   cfg.summerTime   = (v.toInt() != 0);    else if (k == "pulse") cfg.pulseSeconds = v.toInt();    else if (k == "n")     cfg.numTimes     = v.toInt();    else if (k.startsWith("t")) {      int idx = k.substring(1).toInt();      if (idx >= 0 && idx < MAX_TIMES) cfg.times[idx] = v;    }  }  f.close();  saveConfig();}// ---- Sekvens-tilstand ----// hasFired[] og sequenceComplete gemmes i NVS, s'aa en genstart genoptager// sekvensen der hvor den slap. Nulstilles kun n'aar brugeren gemmer nye tider.void loadSequenceState() {  prefs.begin("pulsetimer", true);  sequenceComplete = prefs.getBool("seqDone", false);  for (int i = 0; i < MAX_TIMES; i++) {    char key[10];    snprintf(key, sizeof(key), "f%d", i);    hasFired[i] = prefs.getBool(key, false);  }  prefs.end();}void saveSequenceState() {  prefs.begin("pulsetimer", false);  prefs.putBool("seqDone", sequenceComplete);  for (int i = 0; i < MAX_TIMES; i++) {    char key[10];    snprintf(key, sizeof(key), "f%d", i);    prefs.putBool(key, hasFired[i]);  }  prefs.end();}void resetSequence() {  for (int i = 0; i < MAX_TIMES; i++) hasFired[i] = false;  sequenceComplete = false;  saveSequenceState();  Serial.println("[SEQ] Nulstillet - nye tider aktive");}// ==================== AUTH / SESSIONS ====================// Login med cfg.adminPass. Ved success udstedes et 16-tegn token som cookie.// Tokens gemmes kun i RAM - alle sessioner nulstilles ved reboot.String sessionTokens[MAX_SESSIONS];int    nextSessionSlot = 0;String randToken() {  String out; out.reserve(16);  const char* hex = "0123456789abcdef";  for (int i = 0; i < 8; i++) {    uint32_t r = esp_random();    out += hex[(r) & 0xF];    out += hex[(r >> 4) & 0xF];  }  return out;}String getCookie(const String &name) {  String h = server.header("Cookie");  if (h.length() == 0) return "";  String needle = name + "=";  int p = h.indexOf(needle);  if (p < 0) return "";  int start = p + needle.length();  int end = h.indexOf(';', start);  if (end < 0) end = h.length();  String v = h.substring(start, end);  v.trim();  return v;}bool isAuthed() {  // Ingen adgangskode sat = ingen auth kraevet (aabent lokalt)  if (cfg.adminPass.length() == 0) return true;  String token = getCookie("pt_auth");  if (token.length() == 0) return false;  for (int i = 0; i < MAX_SESSIONS; i++) {    if (sessionTokens[i].length() > 0 && sessionTokens[i] == token) return true;  }  return false;}String issueSession() {  String tok = randToken();  sessionTokens[nextSessionSlot] = tok;  nextSessionSlot = (nextSessionSlot + 1) % MAX_SESSIONS;  return tok;}void clearSession(const String &token) {  for (int i = 0; i < MAX_SESSIONS; i++) {    if (sessionTokens[i] == token) sessionTokens[i] = "";  }}// Kaldes fra beskyttede API-handlers - returnerer true hvis afvist (401 sendt)bool rejectIfUnauthed() {  if (isAuthed()) return false;  server.sendHeader("Cache-Control", "no-store");  server.send(401, "application/json", "{\"ok\":false,\"err\":\"auth\"}");  return true;}// ==================== RELAY ====================void startPulse(const char* reason) {  digitalWrite(RELAY_PIN, HIGH);  relayActive    = true;  relayOffMillis = millis() + (unsigned long)cfg.pulseSeconds * 1000UL;  Serial.printf("[RELAY] ON (%s) - %d s\n", reason, cfg.pulseSeconds);}void updateRelay() {  if (relayActive && (long)(millis() - relayOffMillis) >= 0) {    digitalWrite(RELAY_PIN, LOW);    relayActive = false;    Serial.println("[RELAY] OFF");  }}// ==================== TRIGGER ====================// Parser "HH:MM" eller "HH:MM:SS". Returnerer true hvis gyldig; ss=0 hvis udeladt.bool parseHms(const String &s, int &hh, int &mm, int &ss) {  hh = mm = ss = 0;  if (s.length() < 4) return false;  int c1 = s.indexOf(':');  if (c1 < 1) return false;  hh = s.substring(0, c1).toInt();  int c2 = s.indexOf(':', c1 + 1);  if (c2 > c1) {    mm = s.substring(c1 + 1, c2).toInt();    ss = s.substring(c2 + 1).toInt();  } else {    mm = s.substring(c1 + 1).toInt();    ss = 0;  }  if (hh < 0 || hh > 23) return false;  if (mm < 0 || mm > 59) return false;  if (ss < 0 || ss > 59) return false;  return true;}void checkTriggers() {  // Grace: undg'aa at fyre i de f'orste 30 sek. efter boot  if (millis() - bootMillis < 30000UL) return;  // Sekvensen er f'erdig - stop indtil brugeren gemmer nye tider  if (sequenceComplete) return;  if (cfg.numTimes <= 0) return;  struct tm t;  if (!getLocalNow(t)) return;  long secOfDay = (long)t.tm_hour * 3600L + (long)t.tm_min * 60L + t.tm_sec;  bool fire = false;  bool stateChanged = false;  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (hasFired[i]) continue;    int hh, mm, ss;    if (!parseHms(cfg.times[i], hh, mm, ss)) continue;    long trigSec = (long)hh * 3600L + (long)mm * 60L + ss;    // checkTriggers k'ores hvert 500ms, s'aa hvert sekund rammes 1-2 gange.    // Vi accepterer en 1-sek tolerance for at undg'aa at misse pga. timing.    long diff = secOfDay - trigSec;    if (diff >= 0 && diff <= 1) {      hasFired[i] = true;      stateChanged = true;      fire = true;    }  }  if (fire) startPulse("skema");  // Tjek om alle er fyret nu  if (stateChanged) {    bool allDone = true;    for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {      if (!hasFired[i]) { allDone = false; break; }    }    if (allDone) {      sequenceComplete = true;      Serial.println("[SEQ] F'erdig - sidste tid fyret. Stopper til nye tider gemmes.");    }    saveSequenceState();  }}long secondsToNext() {  if (sequenceComplete) return -1;  if (cfg.numTimes <= 0) return -1;  struct tm t;  if (!getLocalNow(t)) return -1;  long secOfDay = (long)t.tm_hour * 3600L + (long)t.tm_min * 60L + t.tm_sec;  long best = -1;  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (hasFired[i]) continue;    int hh, mm, ss;    if (!parseHms(cfg.times[i], hh, mm, ss)) continue;    long trigSec = (long)hh * 3600L + (long)mm * 60L + ss;    long deltaSec = trigSec - secOfDay;    if (deltaSec <= 0) deltaSec += 86400L; // ufyret, s'aa vent til n'aeste d'oegn    if (best < 0 || deltaSec < best) best = deltaSec;  }  return best;}String nextTimeLabel() {  if (sequenceComplete) return "F'erdig";  if (cfg.numTimes <= 0) return "--:--:--";  struct tm t;  if (!getLocalNow(t)) return "--:--:--";  long secOfDay = (long)t.tm_hour * 3600L + (long)t.tm_min * 60L + t.tm_sec;  long best = -1;  String bestStr = "--:--:--";  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (hasFired[i]) continue;    int hh, mm, ss;    if (!parseHms(cfg.times[i], hh, mm, ss)) continue;    long trigSec = (long)hh * 3600L + (long)mm * 60L + ss;    long deltaSec = trigSec - secOfDay;    if (deltaSec <= 0) deltaSec += 86400L;    if (best < 0 || deltaSec < best) {      best = deltaSec;      char buf[10];      snprintf(buf, sizeof(buf), "%02d:%02d:%02d", hh, mm, ss);      bestStr = String(buf);    }  }  return bestStr;}// ==================== WIFI ====================void startAP() {  apMode = true;  WiFi.mode(WIFI_AP);  String ssid = String(AP_SSID_PREFIX) + "-" + String((uint32_t)ESP.getEfuseMac(), HEX);  WiFi.softAP(ssid.c_str(), AP_PASS);  IPAddress ip = WiFi.softAPIP();  Serial.printf("[WIFI] AP-mode: %s / %s @ %s\n",                ssid.c_str(), AP_PASS, ip.toString().c_str());  // Captive portal: DNS wildcard - alle opslag peger p'aa vores IP.  // Kombineret med onNotFound-handler g'or det at telefon/PC's  // "log ind til netv'aerk"-popup dukker op med det samme.  dnsServer.setErrorReplyCode(DNSReplyCode::NoError);  dnsServer.start(53, "*", ip);  Serial.println("[DNS] Captive portal aktiv");}void setupWiFiSTA() {  if (cfg.wifiSSID.length() == 0) { startAP(); return; }  WiFi.mode(WIFI_STA);  WiFi.setHostname(cfg.deviceName.c_str());  WiFi.begin(cfg.wifiSSID.c_str(), cfg.wifiPass.c_str());  Serial.printf("[WIFI] Forbinder til %s ...\n", cfg.wifiSSID.c_str());  unsigned long t0 = millis();  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 12000) {    delay(250);    Serial.print(".");  }  Serial.println();  if (WiFi.status() == WL_CONNECTED) {    apMode = false;    Serial.printf("[WIFI] STA OK - %s\n", WiFi.localIP().toString().c_str());    if (MDNS.begin(cfg.deviceName.c_str())) {      Serial.printf("[MDNS] http://%s.local\n", cfg.deviceName.c_str());    }  } else {    Serial.println("[WIFI] STA fejlede - starter AP");    startAP();  }}// ==================== NTP ====================void setupNTP() {  if (apMode) return;  configTime(0, 0, cfg.ntpServer.c_str()); // s'aet UTC f'orst  applyTimezone();  Serial.printf("[NTP] Synkroniserer mod %s ...\n", cfg.ntpServer.c_str());  unsigned long t0 = millis();  time_t now = 0;  while ((now = time(nullptr)) < 1700000000 && millis() - t0 < 8000) {    delay(200);  }  if (now >= 1700000000) {    lastNtpSync = now;    struct tm t;    getLocalNow(t);    Serial.printf("[NTP] OK - %04d-%02d-%02d %02d:%02d:%02d (lokal)\n",                  t.tm_year + 1900, t.tm_mon + 1, t.tm_mday,                  t.tm_hour, t.tm_min, t.tm_sec);  } else {    Serial.println("[NTP] Timeout");  }}// ==================== JSON HELPERS ====================String jsonEscape(const String &s) {  String o; o.reserve(s.length() + 2);  for (size_t i = 0; i < s.length(); i++) {    char c = s[i];    if (c == '"' || c == '\\') { o += '\\'; o += c; }    else if (c == '\n') o += "\\n";    else if (c == '\r') o += "\\r";    else if ((uint8_t)c < 0x20) { char b[8]; snprintf(b, sizeof(b), "\\u%04x", c); o += b; }    else o += c;  }  return o;}// Meget simpel JSON-v'aerdi-ekstraktion: hent v'aerdi for "key":...// H'aandterer strings ("..."), tal, booleans. Ikke rekursiv - nok til vores flade payload.String jsonGetString(const String &body, const String &key) {  String needle = "\"" + key + "\"";  int p = body.indexOf(needle);  if (p < 0) return "";  int c = body.indexOf(':', p);  if (c < 0) return "";  int i = c + 1;  while (i < (int)body.length() && (body[i] == ' ' || body[i] == '\t')) i++;  if (i >= (int)body.length()) return "";  if (body[i] == '"') {    int end = i + 1;    String out;    while (end < (int)body.length() && body[end] != '"') {      if (body[end] == '\\' && end + 1 < (int)body.length()) {        char nx = body[end + 1];        if (nx == 'n') out += '\n';        else if (nx == 'r') out += '\r';        else if (nx == 't') out += '\t';        else out += nx;        end += 2;      } else {        out += body[end];        end++;      }    }    return out;  }  // tal/bool  int end = i;  while (end < (int)body.length() && body[end] != ',' && body[end] != '}' && body[end] != ']') end++;  String v = body.substring(i, end);  v.trim();  return v;}// ==================== HTML: HEAD ====================const char PAGE_HEAD[] PROGMEM = R"HTML(<!DOCTYPE html><html lang="da"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Puls-Timer</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"><style>)HTML";// ==================== HTML: FOOT ====================const char PAGE_FOOT[] PROGMEM = R"HTML(</main><footer class="foot">  <span class="rope"></span>  <div class="footinner">    <span class="anchor">&#9875;</span>    <span>Puls-Timer &middot; Copyright Bredahl.dk 2026</span>    <span class="pill" id="footIp">-</span>  </div></footer><script>// F'aelles: opdater footer med IP + s'aet auth-tilstand p'aa body(function(){  var applyAuth = function(s){    var showAsAuthed = !!(s.authed || !s.authReq);    document.querySelectorAll('.authRequired').forEach(function(el){      if (showAsAuthed) el.removeAttribute('hidden');      else              el.setAttribute('hidden','');    });    document.querySelectorAll('.authGuest').forEach(function(el){      if (showAsAuthed) el.setAttribute('hidden','');      else              el.removeAttribute('hidden');    });  };  window.__ptRefreshAuth = function(){    return fetch('/api/status').then(function(r){return r.json();}).then(function(s){      var i = document.getElementById('footIp'); if (i) i.textContent = s.ip || '-';      applyAuth(s);      return s;    }).catch(function(){});  };  window.__ptRefreshAuth();  // Overlay-styring  window.showLogin = function(){    var o = document.getElementById('loginOverlay');    if (!o) return;    o.classList.add('show');    document.getElementById('loginErr').textContent = '';    document.getElementById('loginPw').value = '';    setTimeout(function(){ document.getElementById('loginPw').focus(); }, 60);  };  var hideLogin = function(){    var o = document.getElementById('loginOverlay');    if (o) o.classList.remove('show');  };  window.doLogout = function(){    fetch('/api/logout', {method:'POST'}).then(function(){ location.href = '/'; });  };  var doLogin = function(){    var pw = document.getElementById('loginPw').value;    var err = document.getElementById('loginErr');    err.textContent = '';    fetch('/api/login', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({password:pw})})      .then(function(r){return r.json().then(function(j){ return {ok:r.ok, j:j}; });})      .then(function(res){        if (res.ok && res.j.ok){          hideLogin();          // Hvis vi er p'aa en beskyttet side, reload s'aa dens JS kan hente config          if (location.pathname === '/settings' || location.pathname === '/firmware') {            location.reload();          } else {            window.__ptRefreshAuth();          }        } else {          err.textContent = (res.j && res.j.err) ? res.j.err : 'Forkert adgangskode';          document.getElementById('loginPw').select();        }      }).catch(function(){ err.textContent = 'Netv\u00e6rksfejl'; });  };  var btnSubmit = document.getElementById('btnLoginSubmit');  var btnCancel = document.getElementById('btnLoginCancel');  var pwInput   = document.getElementById('loginPw');  if (btnSubmit) btnSubmit.addEventListener('click', doLogin);  if (btnCancel) btnCancel.addEventListener('click', function(){    hideLogin();    if (location.pathname !== '/') location.href = '/';  });  if (pwInput) pwInput.addEventListener('keydown', function(e){    if (e.key === 'Enter') { e.preventDefault(); doLogin(); }  });  // Beskyttede sider: hvis ikke logget ind, vis overlay automatisk  if ((location.pathname === '/settings' || location.pathname === '/firmware')) {    window.__ptRefreshAuth().then(function(s){      if (s && s.authReq && !s.authed) showLogin();    });  }})();</script></body></html>)HTML";// ==================== SHARED CSS ====================const char SHARED_CSS[] PROGMEM = R"HTML(:root{  --navy:#0d2f4b;  --navy2:#153c5c;  --deep:#071b2e;  --brass:#c9a961;  --brass2:#d9bd7e;  --rope:#b08968;  --cream:#f6efe0;  --paper:#fbf7ee;  --ink:#1a2a3a;  --muted:#6a7a8a;  --line:#e2d7c1;  --ok:#4a7c59;  --bad:#c1272d;  --shadow:0 8px 24px rgba(7,27,46,.18);  --shadow-soft:0 4px 14px rgba(7,27,46,.10);}*{box-sizing:border-box}html,body{margin:0;padding:0}body{  font-family:'Inter',-apple-system,'Segoe UI',Roboto,sans-serif;  color:var(--ink);  min-height:100vh;  background:    radial-gradient(1200px 600px at 80% -10%, rgba(201,169,97,.10), transparent 60%),    radial-gradient(900px 500px at -10% 110%, rgba(21,60,92,.35), transparent 55%),    linear-gradient(160deg,#0b2740 0%, #0d2f4b 40%, #164060 100%);  background-attachment:fixed;}h1,h2,h3{font-family:'Playfair Display',Georgia,serif;font-weight:700;letter-spacing:.2px;margin:0 0 .3em}h1.pageTitle{color:var(--cream);font-size:2.1rem;margin:0 0 1.2rem;text-shadow:0 2px 12px rgba(0,0,0,.35)}em{font-style:italic;color:var(--brass2)}/* --- Nav --- */.topnav{  display:flex;align-items:center;justify-content:space-between;  padding:14px 28px;  background:linear-gradient(180deg, rgba(7,27,46,.85), rgba(7,27,46,.6));  border-bottom:1px solid rgba(201,169,97,.35);  backdrop-filter:blur(6px);  position:sticky;top:0;z-index:10;}.brand{  font-family:'Playfair Display',serif;  color:var(--cream);  font-size:1.25rem;letter-spacing:.5px;}.brand .anchor{color:var(--brass);margin-right:8px;font-size:1.35rem}.brand em{color:var(--brass2);font-size:.95rem;margin-left:6px}.navlinks a{  color:var(--cream);text-decoration:none;  margin-left:22px;padding:6px 2px;font-weight:500;  border-bottom:2px solid transparent;transition:.15s;}.navlinks a:hover{color:var(--brass2)}.navlinks a.active{color:var(--brass);border-bottom-color:var(--brass)}/* --- Layout --- */.wrap{max-width:960px;margin:0 auto;padding:32px 24px 80px}.card{  background:var(--paper);  border:1px solid var(--line);  border-radius:14px;  padding:22px 24px;  box-shadow:var(--shadow);  margin-bottom:22px;  position:relative;}.card h2{color:var(--navy);font-size:1.25rem;margin-top:0;margin-bottom:14px;display:flex;align-items:center;gap:10px}.card h2 .glyph{color:var(--brass);font-size:1.1em}.grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}@media (max-width:720px){.grid{grid-template-columns:1fr}}/* --- Ur --- */.clockCard{  text-align:center;  background:linear-gradient(180deg,#faf3e2,#efe1c2);  border:1px solid #e6d5b8;}.clockDisplay{  font-family:'JetBrains Mono',monospace;  font-weight:700;  font-size:5.2rem;  color:var(--navy);  letter-spacing:6px;  text-shadow:0 2px 0 rgba(255,255,255,.6);  margin:6px 0 4px;  line-height:1;}.clockDate{  font-family:'Inter',sans-serif;  color:var(--muted);  font-size:1.05rem;letter-spacing:2px;text-transform:uppercase;}.clockDeco{  display:flex;justify-content:center;gap:14px;margin:14px 0 0;color:var(--brass);  font-size:1.05rem;letter-spacing:8px;}@media (max-width:520px){.clockDisplay{font-size:3.6rem;letter-spacing:3px}}/* --- Countdown --- */.next{  display:grid;grid-template-columns:1fr 1fr;gap:12px;}.next .box{  background:#fff;border:1px solid var(--line);border-radius:12px;  padding:16px;text-align:center;}.next .lbl{font-size:.78rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted);margin-bottom:6px}.next .val{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:1.9rem;color:var(--navy)}.next .val.brass{color:var(--brass)}/* --- Chips / pills --- */.pill{  display:inline-block;padding:3px 12px;border-radius:999px;  background:rgba(201,169,97,.18);color:var(--cream);  border:1px solid rgba(201,169,97,.5);font-size:.78rem;letter-spacing:1px;}.chip{  display:inline-flex;align-items:center;gap:6px;  background:#fff;border:1px solid var(--line);border-radius:999px;  padding:6px 14px;font-family:'JetBrains Mono',monospace;font-weight:700;  color:var(--navy);}.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.badge{display:inline-block;padding:4px 10px;border-radius:6px;font-size:.78rem;font-weight:600;letter-spacing:.5px}.badge.on{background:#e7f3ea;color:var(--ok);border:1px solid #bcd9c4}.badge.off{background:#f2ede1;color:var(--muted);border:1px solid var(--line)}/* --- Knapper --- */.btn{  display:inline-flex;align-items:center;justify-content:center;gap:8px;  border:none;cursor:pointer;border-radius:10px;  padding:12px 22px;font-weight:600;font-size:.95rem;letter-spacing:.4px;  transition:transform .08s ease, box-shadow .15s ease, background .15s ease;  font-family:'Inter',sans-serif;}.btn:active{transform:translateY(1px)}.btn.primary{  background:linear-gradient(180deg,#d9bd7e,#c9a961);  color:#3a2b0a;box-shadow:var(--shadow-soft);  border:1px solid #b18b3b;}.btn.primary:hover{background:linear-gradient(180deg,#e3ca8f,#d0b06d)}.btn.navy{  background:var(--navy);color:var(--cream);border:1px solid #0a253c;}.btn.navy:hover{background:var(--navy2)}.btn.ghost{  background:transparent;color:var(--navy);border:1px solid var(--navy);}.btn.ghost:hover{background:rgba(13,47,75,.05)}.btn.danger{background:var(--bad);color:#fff;border:1px solid #8a1c22}.btn.big{padding:18px 28px;font-size:1.05rem;border-radius:14px}.btn.wide{width:100%}/* --- Formularer --- */label.field{  display:block;margin-bottom:14px;}label.field > span{  display:block;font-size:.82rem;text-transform:uppercase;letter-spacing:1.5px;  color:var(--navy);margin-bottom:6px;font-weight:600;}input[type=text],input[type=password],input[type=number],input[type=time],select{  width:100%;padding:11px 12px;border-radius:10px;  border:1px solid var(--line);background:#fff;  font:inherit;color:var(--ink);  transition:border-color .15s, box-shadow .15s;}input:focus,select:focus{outline:none;border-color:var(--brass);box-shadow:0 0 0 3px rgba(201,169,97,.25)}.check{display:flex;align-items:center;gap:10px;margin-bottom:14px}.check input{width:20px;height:20px;accent-color:var(--brass)}.check label{color:var(--navy);font-weight:500}.hint{font-size:.82rem;color:var(--muted);margin-top:-6px;margin-bottom:12px}.row2{display:grid;grid-template-columns:1fr 1fr;gap:14px}.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}@media (max-width:560px){.row2,.row3{grid-template-columns:1fr}}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:8px}/* --- Tider-liste --- */.timesList{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}.timesList .tItem{  background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;  display:flex;align-items:center;gap:10px;}.timesList .tItem .idx{  font-family:'JetBrains Mono',monospace;color:var(--brass);font-weight:700;min-width:22px;text-align:right;}.timeInput{  display:inline-flex;align-items:center;gap:2px;flex:1;  background:#fff;border:1px solid var(--line);border-radius:8px;  padding:4px 8px;}.timeInput input{  width:2em;padding:5px 2px;text-align:center;  border:none;background:transparent;  font-family:'JetBrains Mono',monospace;font-size:1rem;  color:var(--navy);font-weight:700;  -moz-appearance:textfield;}.timeInput input::-webkit-outer-spin-button,.timeInput input::-webkit-inner-spin-button{  -webkit-appearance:none;margin:0;}.timeInput input:focus{outline:none;background:#faf3e2;border-radius:4px}.timeInput .sep{color:var(--brass);font-weight:700;font-family:'JetBrains Mono',monospace;padding:0 1px}/* --- WiFi scan liste --- */.scanList{display:flex;flex-direction:column;gap:6px;margin:6px 0 4px}.scanRow{  display:flex;align-items:center;gap:12px;  background:#fff;border:1px solid var(--line);border-radius:10px;  padding:9px 14px;cursor:pointer;transition:border-color .12s, background .12s;}.scanRow:hover{border-color:var(--brass);background:#fefaf0}.scanRow.picked{border-color:var(--brass);background:linear-gradient(180deg,#faf3e2,#efe1c2)}.scanBars{display:inline-flex;gap:2px;align-items:flex-end;height:14px;min-width:22px}.scanBars i{display:block;width:3px;background:var(--line);border-radius:1px}.scanBars i:nth-child(1){height:4px}.scanBars i:nth-child(2){height:8px}.scanBars i:nth-child(3){height:11px}.scanBars i:nth-child(4){height:14px}.scanBars.b1 i:nth-child(-n+1),.scanBars.b2 i:nth-child(-n+2),.scanBars.b3 i:nth-child(-n+3),.scanBars.b4 i:nth-child(-n+4){background:var(--brass)}.scanSSID{flex:1;font-weight:600;color:var(--navy);font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scanMeta{font-family:'JetBrains Mono',monospace;color:var(--muted);font-size:.78rem;letter-spacing:.5px}.scanLock{color:var(--brass);font-size:.9rem}.scanEmpty{color:var(--muted);font-size:.85rem;padding:8px 4px}/* --- Firmware-log --- */.changelog{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}.changelog li{  border-left:3px solid var(--brass);  padding:8px 0 8px 14px;  background:linear-gradient(90deg, rgba(201,169,97,.10), transparent 55%);  border-radius:0 8px 8px 0;}.changelog li.old{  border-left-color:#c9bfa4;  background:linear-gradient(90deg, rgba(201,169,97,.04), transparent 45%);}.changelog .v{  font-family:'JetBrains Mono',monospace;color:var(--navy);  font-weight:700;font-size:1rem;  display:flex;align-items:center;gap:12px;flex-wrap:wrap;}.changelog li.old .v{color:var(--muted)}.changelog .date{  font-family:'Inter',sans-serif;font-size:.72rem;font-weight:500;  color:var(--muted);letter-spacing:1px;text-transform:uppercase;}.changelog .pill-current{  font-family:'Inter',sans-serif;font-size:.62rem;font-weight:700;  background:var(--brass);color:#3a2b0a;padding:2px 10px;border-radius:999px;  letter-spacing:1px;}.changelog .notes{  color:var(--navy);font-size:.87rem;line-height:1.5;margin-top:4px;}.changelog li.old .notes{color:var(--muted)}/* --- Info-display (Dashboard / forside) --- */body.page-dash .wrap{max-width:none;padding:0;margin:0}body.page-dash .foot{margin-top:0}.display{  display:flex;flex-direction:column;  justify-content:space-around;align-items:center;  padding:2vh 4vw;gap:1vh;  min-height:calc(100vh - 62px - 42px); /* viewport minus nav og footer */  text-align:center;}.dispZone{  display:flex;flex-direction:column;align-items:center;  gap:1.2vh;text-align:center;}.dispLbl{  font-family:'Playfair Display',serif;color:var(--brass);  letter-spacing:6px;text-transform:uppercase;font-weight:700;  font-size:clamp(0.9rem, min(1.5vw, 2.2vh), 1.6rem);}.dispClock{  font-family:'JetBrains Mono',monospace;font-weight:700;  color:var(--cream);line-height:1;letter-spacing:8px;  font-size:clamp(80px, min(14vw, 32vh), 360px);  text-shadow:0 4px 24px rgba(0,0,0,.4);}.dispDate{  font-family:'Playfair Display',serif;color:var(--cream);  letter-spacing:3px;  font-size:clamp(1.2rem, min(2.5vw, 5vh), 3rem);}.nextRow{  display:flex;gap:6vw;justify-content:center;align-items:baseline;flex-wrap:wrap;}.nextItem{display:flex;flex-direction:column;gap:.6vh;align-items:center}.dispNext{  font-family:'JetBrains Mono',monospace;font-weight:700;  color:var(--brass);line-height:1;letter-spacing:5px;  font-size:clamp(60px, min(10vw, 22vh), 240px);}.dispNext.done{color:var(--muted);letter-spacing:3px}.dispCount{  font-family:'JetBrains Mono',monospace;font-weight:700;  color:var(--cream);line-height:1;letter-spacing:4px;  font-size:clamp(50px, min(8vw, 18vh), 180px);}.dispChips{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;max-width:80vw}.dispChips .chip{  font-family:'JetBrains Mono',monospace;font-weight:700;  color:var(--cream);background:rgba(201,169,97,.15);  border:1px solid rgba(201,169,97,.4);  padding:6px 16px;border-radius:999px;  font-size:clamp(1rem, min(1.5vw, 3vh), 1.5rem);}.dispChips .chip.fired{opacity:.35;text-decoration:line-through}.dispBrand{  display:flex;align-items:center;gap:12px;  font-family:'JetBrains Mono',monospace;color:var(--brass);  letter-spacing:3px;  font-size:clamp(1rem, min(1.4vw, 2.5vh), 1.5rem);}.dispBrand .anchor{color:var(--brass);font-size:1.4em}.dispBrand .ver{color:var(--cream);background:rgba(246,239,224,.10);border:1px solid rgba(246,239,224,.3);padding:3px 12px;border-radius:6px;font-weight:700}/* --- GO overlay: popper op n\u00e5r rel\u00e6et er aktivt --- */.goOverlay{  position:fixed;inset:0;z-index:300;pointer-events:none;  display:flex;align-items:center;justify-content:center;  background:radial-gradient(circle at center, rgba(46,204,113,.28), transparent 65%);  opacity:0;visibility:hidden;transition:opacity .35s, visibility .35s;}.goOverlay.show{opacity:1;visibility:visible}.goBadge{  background:linear-gradient(180deg, #2ecc71, #27ae60);  color:#fff;font-family:'Playfair Display',serif;font-weight:900;  font-size:clamp(120px, min(28vw, 55vh), 560px);  line-height:1;  padding:3vh 10vw;border-radius:32px;  letter-spacing:2vw;  text-shadow:0 6px 16px rgba(0,0,0,.35);  border:5px solid rgba(255,255,255,.45);  box-shadow:    0 25px 80px rgba(0,0,0,.5),    0 0 100px rgba(46,204,113,.7),    inset 0 4px 12px rgba(255,255,255,.25);  transform:scale(.5);opacity:0;}.goOverlay.show .goBadge{  animation:goPulse .45s cubic-bezier(.34,1.56,.64,1) forwards;}@keyframes goPulse{  0%   {transform:scale(.5);opacity:0}  60%  {transform:scale(1.08);opacity:1}  100% {transform:scale(1);opacity:1}}.dispBrand .anchor{color:var(--brass);font-size:1.4em}.dispBrand .ver{color:var(--cream);background:rgba(246,239,224,.10);border:1px solid rgba(246,239,224,.3);padding:2px 10px;border-radius:6px;font-weight:700}/* --- Login overlay --- */.navlinks a.login,.navlinks a.logout{  color:var(--brass2);border-left:1px solid rgba(201,169,97,.3);  padding-left:22px;margin-left:22px;}.loginOverlay{  position:fixed;inset:0;background:rgba(7,27,46,.78);  display:none;align-items:center;justify-content:center;z-index:200;  backdrop-filter:blur(4px);padding:20px;}.loginOverlay.show{display:flex}.loginBox{  background:var(--paper);border:1px solid var(--line);border-radius:16px;  padding:28px 30px;box-shadow:0 20px 60px rgba(0,0,0,.4);  width:min(380px,100%);}.loginBox h2{font-family:'Playfair Display',serif;color:var(--navy);font-size:1.5rem;margin:0 0 6px;display:flex;align-items:center;gap:10px}.loginBox h2 .glyph{color:var(--brass)}.loginBox p{color:var(--muted);font-size:.9rem;margin:0 0 18px}.loginBox .field{margin-bottom:14px}.loginBox .err{color:var(--bad);font-size:.85rem;min-height:1.1em;margin:-6px 0 10px}.loginBox .row{display:flex;gap:10px;justify-content:flex-end;margin-top:6px}/* --- Firmware / toast --- */.progress{  height:8px;background:#efe6d0;border-radius:6px;overflow:hidden;margin:8px 0 4px;}.progress > span{display:block;height:100%;background:var(--brass);width:0%;transition:width .3s}.toast{  position:fixed;bottom:26px;left:50%;transform:translateX(-50%);  background:var(--navy);color:var(--cream);padding:12px 20px;  border-radius:10px;box-shadow:var(--shadow);  border:1px solid var(--brass);z-index:100;  opacity:0;pointer-events:none;transition:opacity .25s;}.toast.show{opacity:1}/* --- Footer --- */.foot{margin-top:60px}.rope{  display:block;height:6px;  background:    repeating-linear-gradient(45deg,#c9a961 0 8px,#b08968 8px 16px);  opacity:.7;}.footinner{  display:flex;align-items:center;justify-content:space-between;gap:14px;  padding:14px 28px;color:var(--cream);font-size:.9rem;  background:rgba(7,27,46,.6);}.footinner .anchor{color:var(--brass)}/* --- Bolge deco --- */.wave{  position:absolute;left:0;right:0;bottom:0;height:16px;pointer-events:none;  background:    radial-gradient(6px 6px at 10px 0, var(--brass) 98%, transparent 100%),    radial-gradient(6px 6px at 30px 0, var(--brass) 98%, transparent 100%),    radial-gradient(6px 6px at 50px 0, var(--brass) 98%, transparent 100%);  background-repeat:repeat-x;background-size:40px 12px;opacity:.35;})HTML";// ==================== DASHBOARD (info-display / forside) ====================const char PAGE_DASHBOARD[] PROGMEM = R"HTML(<div class="display">  <div class="dispZone">    <div class="dispLbl">Lokal Tid</div>    <div class="dispClock" id="dispClock">--:--:--</div>    <div class="dispDate" id="dispDate">--</div>  </div>  <div class="dispZone">    <div class="dispLbl">N&aelig;ste start</div>    <div class="nextRow">      <div class="nextItem">        <div class="dispNext" id="dispNext">--:--:--</div>      </div>      <div class="nextItem">        <div class="dispLbl" id="dispCountLbl">Om</div>        <div class="dispCount" id="dispCount">--:--:--</div>      </div>    </div>  </div>  <div class="dispZone">    <div class="dispChips" id="dispChips"></div>    <div class="dispBrand">      <span class="ver" id="dispVer">v1.1</span>    </div>  </div></div><div class="goOverlay" id="goOverlay">  <div class="goBadge">GO</div></div><script>(function(){  var pad2 = function(n){return (n<10?'0':'')+n;};  var fmtSec = function(s){    if (s == null || s < 0) return '--:--:--';    var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;    return pad2(h)+':'+pad2(m)+':'+pad2(ss);  };  var lastRelay = false;  var render = function(s){    if (!s) return;    document.getElementById('dispClock').textContent = s.time || '--:--:--';    document.getElementById('dispDate').textContent  = s.date || '--';    var nextEl  = document.getElementById('dispNext');    var countEl = document.getElementById('dispCount');    var countLbl = document.getElementById('dispCountLbl');    if (s.complete) {      nextEl.textContent = 'F\u00e6rdig';      nextEl.classList.add('done');      countEl.textContent = '';      if (countLbl) countLbl.style.visibility = 'hidden';    } else {      nextEl.textContent = s.nextLabel || '--:--:--';      nextEl.classList.remove('done');      countEl.textContent = fmtSec(s.secToNext);      if (countLbl) countLbl.style.visibility = 'visible';    }    document.getElementById('dispVer').textContent = s.fw || 'v1.1';    var wrap = document.getElementById('dispChips');    wrap.innerHTML = '';    var fired = s.fired || [];    (s.times || []).forEach(function(t, i){      var el = document.createElement('span');      el.className = 'chip' + (fired[i] ? ' fired' : '');      el.textContent = t;      wrap.appendChild(el);    });    // GO-overlay: vises n\u00e5r rel\u00e6et er aktivt    var overlay = document.getElementById('goOverlay');    if (s.relay && !lastRelay) {      overlay.classList.add('show');    } else if (!s.relay && lastRelay) {      overlay.classList.remove('show');    } else if (s.relay) {      overlay.classList.add('show');    }    lastRelay = !!s.relay;  };  var tick = function(){    fetch('/api/status').then(function(r){return r.json();}).then(render).catch(function(){});  };  tick();  // Under aktiv puls poller vi hurtigere for at fange rel\u00e6-slut hurtigt  var slowInt = setInterval(tick, 1000);})();</script>)HTML";// ==================== KONTROL ====================const char PAGE_CONTROL[] PROGMEM = R"HTML(<section class="card clockCard">  <div class="clockDate" id="dDate">--</div>  <div class="clockDisplay" id="dClock">--:--:--</div>  <div class="clockDeco">&#9875; &#9784; &#9875;</div></section><section class="card">  <h2><span class="glyph">&#9201;</span> N&aelig;ste trigger</h2>  <div class="next">    <div class="box">      <div class="lbl">N&aelig;ste tid</div>      <div class="val" id="dNext">--:--</div>    </div>    <div class="box">      <div class="lbl">Nedt&aelig;lling</div>      <div class="val brass" id="dCountdown">--:--:--</div>    </div>  </div>  <div class="chips" id="dTimesChips"></div></section><section class="card authRequired" id="dPulseCard" hidden style="text-align:center">  <h2 style="justify-content:center"><span class="glyph">&#9889;</span> Manuel puls</h2>  <p style="color:var(--muted);margin:0 0 16px">Aktiverer rel&aelig;et p&aring; GPIO 19 i det antal sekunder du har valgt.</p>  <button class="btn primary big" id="btnPulse">&#9889; Send puls nu</button>  <div style="margin-top:14px">    <span class="lbl" style="font-size:.78rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted)">Rel&aelig;</span>    <span id="dRelay" class="badge off" style="margin-left:8px">INAKTIV</span>    <span class="chip" style="margin-left:8px" id="dPulseInfo">-- sek</span>    <span class="chip" style="margin-left:8px" id="dTimesCount">0 tider</span>  </div></section><div class="toast" id="toast"></div><script>(function(){  var lastData = null;  var toast = function(msg){    var t = document.getElementById('toast');    t.textContent = msg; t.classList.add('show');    setTimeout(function(){ t.classList.remove('show'); }, 2200);  };  var fmtSec = function(s){    if (s < 0) return '--:--:--';    var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;    var p = function(n){ return (n<10?'0':'')+n; };    return p(h)+':'+p(m)+':'+p(sec);  };  var render = function(s){    lastData = s;    document.getElementById('dClock').textContent = s.time || '--:--:--';    document.getElementById('dDate').textContent  = s.date || '--';    document.getElementById('dNext').textContent  = s.nextLabel || '--:--';    document.getElementById('dCountdown').textContent =      s.complete ? 'F\u00e6rdig' : fmtSec(s.secToNext);    document.getElementById('dPulseInfo').textContent = (s.pulse || 0) + ' sek';    var n = s.numTimes || 0;    var fired = s.fired || [];    var remain = 0;    for (var k = 0; k < n; k++) if (!fired[k]) remain++;    var count = document.getElementById('dTimesCount');    if (s.complete)      count.textContent = 'F\u00e6rdig \u00b7 ' + n + ' tider';    else if (n === 0)    count.textContent = 'Ingen tider';    else                 count.textContent = remain + ' af ' + n + ' tilbage';    var r = document.getElementById('dRelay');    if (s.relay){ r.className='badge on'; r.textContent='AKTIV'; }    else        { r.className='badge off'; r.textContent='INAKTIV'; }    // Nedt'aellings-farve: bl'ass n'aar f'aerdig    var cd = document.getElementById('dCountdown');    cd.style.color = s.complete ? 'var(--muted)' : '';    var wrap = document.getElementById('dTimesChips');    wrap.innerHTML = '';    (s.times || []).forEach(function(t, i){      var el = document.createElement('span');      el.className = 'chip';      if (fired[i]) {        el.style.opacity = '.45';        el.style.textDecoration = 'line-through';      }      el.textContent = (i+1) + ' \u00b7 ' + t;      wrap.appendChild(el);    });  };  var refresh = function(){    fetch('/api/status').then(function(r){return r.json();}).then(render).catch(function(){});  };  refresh();  setInterval(refresh, 1000);  document.getElementById('btnPulse').addEventListener('click', function(){    fetch('/api/pulse', {method:'POST'}).then(function(r){return r.json();}).then(function(j){      if (j.ok) toast('Puls sendt \u26a1');      refresh();    }).catch(function(){ toast('Fejl'); });  });})();</script>)HTML";// ==================== SETTINGS ====================const char PAGE_SETTINGS[] PROGMEM = R"HTML(<section class="card">  <h2><span class="glyph">&#9200;</span> Trigger-tider</h2>  <label class="field">    <span>Antal tider</span>    <input type="number" id="sNum" min="0" max="24" value="1">  </label>  <div class="hint">Skriv hvor mange trigger-tider du vil have. Felterne genereres nedenfor.</div>  <div class="timesList" id="sTimes"></div></section><section class="card">  <h2><span class="glyph">&#9200;</span> Puls-varighed</h2>  <label class="field">    <span>Sekunder rel&aelig;et er aktivt</span>    <input type="number" id="sPulse" min="1" max="600" value="3">  </label></section><section class="card">  <h2><span class="glyph">&#9728;</span> Tid &amp; NTP</h2>  <div class="row2">    <label class="field">      <span>NTP-server (IP eller navn)</span>      <input type="text" id="sNtp" placeholder="pool.ntp.org">    </label>    <label class="field">      <span>Enhedens navn (mDNS)</span>      <input type="text" id="sDev" placeholder="pulsetimer">    </label>  </div>  <div class="check">    <input type="checkbox" id="sSummer">    <label for="sSummer">Sommertid aktiv (CEST +2). Ellers CET +1.</label>  </div>  <div class="actions">    <button class="btn ghost" id="btnSync">&#8635; Synk NTP nu</button>    <span class="chip" id="sClock">--:--:--</span>  </div></section><section class="card">  <h2><span class="glyph">&#128273;</span> WiFi</h2>  <div style="margin-bottom:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">    <span style="font-size:.78rem;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);font-weight:600">Aktuelt gemt</span>    <span class="chip" id="sCurSSID">-</span>  </div>  <div class="actions" style="margin-bottom:10px">    <button class="btn ghost" id="btnScan" type="button">&#128270; Scan efter netv&aelig;rk</button>    <span class="hint" id="sScanHint" style="margin:0">Klik for at s&oslash;ge synlige netv&aelig;rk.</span>  </div>  <div id="sScanList" class="scanList"></div>  <div class="row2" style="margin-top:14px">    <label class="field">      <span>Nyt SSID</span>      <input type="text" id="sSSID" placeholder="V&aelig;lg fra listen eller skriv manuelt">    </label>    <label class="field">      <span>Adgangskode</span>      <input type="password" id="sPass" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;">    </label>  </div>  <div class="hint">WiFi tr&aelig;der i kraft efter genstart. Adgangskoden gemmes p&aring; ESP'en (NVS + LittleFS).</div>  <div class="actions">    <button class="btn navy" id="btnWifi">Gem WiFi</button>    <button class="btn danger" id="btnRestart">&#8634; Genstart enhed</button>  </div></section><section class="card">  <h2><span class="glyph">&#128273;</span> Adgangskode</h2>  <div class="row2">    <label class="field">      <span>Nuv&aelig;rende</span>      <input type="password" id="sPwCur" autocomplete="current-password">    </label>    <label class="field">      <span>Ny (tom = deaktiver)</span>      <input type="password" id="sPwNew" autocomplete="new-password">    </label>  </div>  <div class="hint">Lad "Ny" v&aelig;re tom for at fjerne login-kravet. Skifter du kode, bliver andre sessioner logget ud.</div>  <div class="actions">    <button class="btn navy" id="btnPw">Skift adgangskode</button>  </div></section><div class="actions" style="margin-top:6px">  <button class="btn primary big wide" id="btnSave">&#128190; Gem indstillinger</button></div><div class="toast" id="toast"></div><script>(function(){  var toast = function(msg){    var t = document.getElementById('toast');    t.textContent = msg; t.classList.add('show');    setTimeout(function(){ t.classList.remove('show'); }, 2200);  };  var pad2 = function(n){ return (n<10?'0':'')+n; };  var clamp = function(v, max){    var n = parseInt(v, 10);    if (isNaN(n) || n < 0) n = 0;    if (n > max) n = max;    return pad2(n);  };  var parseTime = function(v){    // "HH:MM" eller "HH:MM:SS" -> {h,m,s} (default 00)    var p = (v || '').split(':');    return {      h: pad2(parseInt(p[0], 10) || 0),      m: pad2(parseInt(p[1], 10) || 0),      s: pad2(parseInt(p[2], 10) || 0)    };  };  var collectTimes = function(){    var out = [];    document.querySelectorAll('#sTimes .tItem').forEach(function(item){      var idx = parseInt(item.dataset.idx, 10);      var h = clamp(item.querySelector('.hh').value, 23);      var m = clamp(item.querySelector('.mm').value, 59);      var s = clamp(item.querySelector('.ss').value, 59);      out[idx] = h + ':' + m + ':' + s;    });    return out;  };  var renderTimes = function(n, values){    var wrap = document.getElementById('sTimes');    wrap.innerHTML = '';    for (var i = 0; i < n; i++){      var t = parseTime((values && values[i]) || '00:00:00');      var row = document.createElement('div');      row.className = 'tItem';      row.dataset.idx = i;      row.innerHTML =        '<span class="idx">' + (i+1) + '</span>' +        '<div class="timeInput">' +          '<input class="hh" type="text" inputmode="numeric" pattern="\\d*" maxlength="2" value="' + t.h + '">' +          '<span class="sep">:</span>' +          '<input class="mm" type="text" inputmode="numeric" pattern="\\d*" maxlength="2" value="' + t.m + '">' +          '<span class="sep">:</span>' +          '<input class="ss" type="text" inputmode="numeric" pattern="\\d*" maxlength="2" value="' + t.s + '">' +        '</div>';      wrap.appendChild(row);    }    // Auto-advance ved 2 cifre + kun tal + fokus-select    wrap.querySelectorAll('.timeInput input').forEach(function(el){      el.addEventListener('focus', function(){ el.select(); });      el.addEventListener('input', function(){        el.value = el.value.replace(/\D/g, '').slice(0, 2);        if (el.value.length >= 2) {          var next = el.parentNode.querySelectorAll('input');          for (var k = 0; k < next.length; k++) {            if (next[k] === el && k + 1 < next.length) { next[k+1].focus(); break; }          }        }      });      el.addEventListener('blur', function(){        var max = el.classList.contains('hh') ? 23 : 59;        el.value = clamp(el.value, max);      });    });  };  var loadCfg = function(){    fetch('/api/config').then(function(r){      if (!r.ok) return null;   // 401 = ikke logget ind      return r.json();    }).then(function(c){      if (!c) return;      document.getElementById('sNum').value    = c.numTimes;      document.getElementById('sPulse').value  = c.pulse;      document.getElementById('sNtp').value    = c.ntp;      document.getElementById('sDev').value    = c.device;      document.getElementById('sSummer').checked = !!c.summer;      document.getElementById('sSSID').value   = c.ssid;      var cur = document.getElementById('sCurSSID');      if (cur) cur.textContent = c.ssid ? c.ssid : '(intet gemt)';      renderTimes(c.numTimes, c.times);    });  };  // ---- WiFi scan ----  var bars = function(rssi){    if (rssi >= -50) return 4;    if (rssi >= -62) return 3;    if (rssi >= -74) return 2;    return 1;  };  var renderScan = function(nets){    var wrap = document.getElementById('sScanList');    wrap.innerHTML = '';    if (!nets || nets.length === 0){      var e = document.createElement('div');      e.className = 'scanEmpty';      e.textContent = 'Ingen netv\u00e6rk fundet.';      wrap.appendChild(e); return;    }    // Sort'er efter signal (st'aerkeste f'orst) og dedup'er p'aa SSID    nets.sort(function(a,b){ return b.rssi - a.rssi; });    var seen = {};    nets.forEach(function(net){      if (!net.ssid || seen[net.ssid]) return;      seen[net.ssid] = 1;      var row = document.createElement('div');      row.className = 'scanRow';      var b = bars(net.rssi);      row.innerHTML =        '<span class="scanBars b' + b + '"><i></i><i></i><i></i><i></i></span>' +        '<span class="scanSSID"></span>' +        '<span class="scanMeta">' + net.rssi + ' dBm</span>' +        (net.lock ? '<span class="scanLock">&#128274;</span>' : '');      row.querySelector('.scanSSID').textContent = net.ssid;      row.addEventListener('click', function(){        document.getElementById('sSSID').value = net.ssid;        document.querySelectorAll('#sScanList .scanRow').forEach(function(el){          el.classList.remove('picked');        });        row.classList.add('picked');        var passInput = document.getElementById('sPass');        if (passInput) passInput.focus();      });      wrap.appendChild(row);    });  };  document.getElementById('btnScan').addEventListener('click', function(){    var hint = document.getElementById('sScanHint');    var btn = this;    btn.disabled = true;    hint.textContent = 'Scanner \u2026 (tager 2\u20134 sek.)';    fetch('/api/wifi/scan').then(function(r){return r.json();}).then(function(j){      renderScan(j.nets || []);      hint.textContent = (j.nets && j.nets.length ? j.nets.length : 0) + ' netv\u00e6rk fundet. Klik for at v\u00e6lge.';    }).catch(function(){      hint.textContent = 'Fejl under scanning.';    }).then(function(){ btn.disabled = false; });  });  document.getElementById('sNum').addEventListener('input', function(){    var n = parseInt(this.value, 10) || 0;    if (n < 0) n = 0; if (n > 24) n = 24;    var cur = collectTimes();    renderTimes(n, cur);  });  document.getElementById('btnSave').addEventListener('click', function(){    var n = parseInt(document.getElementById('sNum').value, 10) || 0;    var payload = {      numTimes: n,      pulse:    parseInt(document.getElementById('sPulse').value, 10) || 3,      ntp:      document.getElementById('sNtp').value,      device:   document.getElementById('sDev').value,      summer:   document.getElementById('sSummer').checked    };    var times = collectTimes();    for (var i = 0; i < n; i++) payload['t' + i] = times[i] || '00:00:00';    fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)})      .then(function(r){return r.json();}).then(function(j){        if (!j.ok)      toast('Fejl');        else if (j.reset) toast('Gemt \u2713 - sekvens nulstillet');        else            toast('Gemt \u2713');      }).catch(function(){ toast('Fejl'); });  });  document.getElementById('btnWifi').addEventListener('click', function(){    var payload = {      ssid: document.getElementById('sSSID').value,      pass: document.getElementById('sPass').value    };    if (!payload.ssid){ toast('Angiv SSID'); return; }    fetch('/api/wifi', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)})      .then(function(r){return r.json();}).then(function(j){        toast(j.ok ? 'WiFi gemt \u2713 - genstart' : 'Fejl');      });  });  document.getElementById('btnSync').addEventListener('click', function(){    toast('Synkroniserer ...');    fetch('/api/sync', {method:'POST'}).then(function(r){return r.json();}).then(function(j){      toast(j.ok ? 'NTP synkroniseret \u2713' : 'NTP fejl');    });  });  document.getElementById('btnRestart').addEventListener('click', function(){    if (!confirm('Genstart enheden nu?')) return;    fetch('/api/restart', {method:'POST'});    toast('Genstarter ...');  });  var btnPw = document.getElementById('btnPw');  if (btnPw) btnPw.addEventListener('click', function(){    var cur = document.getElementById('sPwCur').value;    var nw  = document.getElementById('sPwNew').value;    fetch('/api/password', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({current:cur, new:nw})})      .then(function(r){return r.json().then(function(j){return {ok:r.ok, j:j};});})      .then(function(res){        if (res.ok && res.j.ok){          document.getElementById('sPwCur').value = '';          document.getElementById('sPwNew').value = '';          toast(nw ? 'Adgangskode skiftet \u2713' : 'Login-krav deaktiveret \u2713');        } else {          toast((res.j && res.j.err) ? res.j.err : 'Fejl');        }      }).catch(function(){ toast('Fejl'); });  });  var updClock = function(){    fetch('/api/status').then(function(r){return r.json();}).then(function(s){      document.getElementById('sClock').textContent = s.time || '--:--:--';    }).catch(function(){});  };  updClock(); setInterval(updClock, 1000);  loadCfg();})();</script>)HTML";// ==================== FIRMWARE ====================const char PAGE_FIRMWARE[] PROGMEM = R"HTML(<section class="card">  <h2><span class="glyph">&#9881;</span> Firmware</h2>  <div class="row3">    <div class="box" style="padding:10px 14px;background:#fff;border:1px solid var(--line);border-radius:10px">      <div class="lbl" style="font-size:.78rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted)">Version</div>      <div style="font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--navy);font-size:1.2rem" id="fVer">-</div>    </div>    <div class="box" style="padding:10px 14px;background:#fff;border:1px solid var(--line);border-radius:10px">      <div class="lbl" style="font-size:.78rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted)">Enhed</div>      <div style="font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--navy);font-size:1.2rem" id="fDev">-</div>    </div>    <div class="box" style="padding:10px 14px;background:#fff;border:1px solid var(--line);border-radius:10px">      <div class="lbl" style="font-size:.78rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted)">IP</div>      <div style="font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--navy);font-size:1.2rem" id="fIp">-</div>    </div>  </div></section><section class="card">  <h2><span class="glyph">&#128227;</span> OTA-opdatering</h2>  <p style="color:var(--muted);margin:0 0 12px">    Henter seneste binary fra    <code style="background:var(--cream);padding:2px 8px;border-radius:6px">datahome.bredahl.dk</code>    og flasher enheden. Enheden genstarter automatisk efter succes.  </p>  <div class="progress"><span id="fBar"></span></div>  <div class="hint" id="fMsg" style="min-height:1.2em">Klar.</div>  <div class="actions">    <button class="btn primary big" id="btnOta">&#8595; Opdater firmware</button>    <button class="btn ghost" id="btnRefresh">Genindl&aelig;s status</button>  </div></section><section class="card">  <h2><span class="glyph">&#128220;</span> Firmware-log</h2>  <ul class="changelog">    <li>      <div class="v">        v1.1        <span class="date">25. sep 2026</span>        <span class="pill-current">AKTUEL</span>      </div>      <div class="notes">        Login med adgangskode, sekunder p&aring; trigger-tider (HH:MM:SS),        WiFi-scan i indstillinger, captive portal i AP-mode,        automatisk stop efter sidste trigger, opdateret footer,        Dashboard som info-display + separat Kontrol-side.      </div>    </li>    <li class="old">      <div class="v">        v1.0        <span class="date">14. sep 2026</span>      </div>      <div class="notes">        F&oslash;rste version - grundl&aelig;ggende trigger-tider p&aring; minut-niveau, manuel puls,        WiFi-konfiguration og OTA.      </div>    </li>  </ul>  <div class="hint" style="margin-top:14px">Viser de sidste 5 versioner. &AElig;ldre versioner falder af listen efterh&aring;nden som nye tilf&oslash;jes.</div></section><div class="toast" id="toast"></div><script>(function(){  var toast = function(msg){    var t = document.getElementById('toast');    t.textContent = msg; t.classList.add('show');    setTimeout(function(){ t.classList.remove('show'); }, 2200);  };  var loadStatus = function(){    fetch('/api/status').then(function(r){return r.json();}).then(function(s){      document.getElementById('fVer').textContent = s.fw || '-';      document.getElementById('fDev').textContent = s.device || '-';      document.getElementById('fIp').textContent  = s.ip || '-';    });  };  loadStatus();  document.getElementById('btnRefresh').addEventListener('click', loadStatus);  document.getElementById('btnOta').addEventListener('click', function(){    if (!confirm('Start firmware-opdatering?\nEnheden genstarter n\u00e5r den er f\u00e6rdig.')) return;    var bar = document.getElementById('fBar');    var msg = document.getElementById('fMsg');    bar.style.width = '15%';    msg.textContent = 'Kontakter server ...';    fetch('/api/ota', {method:'POST'}).then(function(r){return r.json();}).then(function(j){      bar.style.width = '55%';      msg.textContent = 'Downloader og flasher ... enheden genstarter selv.';    }).catch(function(){      msg.textContent = 'Enheden svarer ikke - m\u00e5ske allerede ved at genstarte.';      bar.style.width = '90%';    });    // Prov at hente status igen efter et stykke tid    var tries = 0;    var ping = setInterval(function(){      tries++;      fetch('/api/status').then(function(r){return r.json();}).then(function(s){        bar.style.width = '100%';        msg.textContent = 'Klar - kore version ' + (s.fw || '?');        document.getElementById('fVer').textContent = s.fw || '-';        clearInterval(ping);      }).catch(function(){});      if (tries > 40) clearInterval(ping);    }, 3000);  });})();</script>)HTML";// ==================== WEB PAGES (forward decls til PROGMEM-strenge) ====================extern const char PAGE_HEAD[]      PROGMEM;extern const char PAGE_FOOT[]      PROGMEM;extern const char PAGE_CONTROL[] PROGMEM;extern const char PAGE_DASHBOARD[]   PROGMEM;extern const char PAGE_SETTINGS[]  PROGMEM;extern const char PAGE_FIRMWARE[]  PROGMEM;extern const char SHARED_CSS[]     PROGMEM;// ==================== WEB HANDLERS ====================void sendPage(const char* title, const char* activePage, const char* bodyProgmem) {  String html;  html.reserve(22000);  html += FPSTR(PAGE_HEAD);  html += FPSTR(SHARED_CSS);  // Efter head: title + nav (JS s'aetter body.authed baseret p'aa /api/status)  html += "</style></head><body class=\"page-";  html += activePage;  html += "\"><nav class=\"topnav\"><div class=\"brand\">";  html += "<span class=\"anchor\">&#9875;</span> Dronningborg Sejlklub <em>";  html += cfg.deviceName;  html += "</em></div><div class=\"navlinks\">";  auto navLink = [&](const char* href, const char* label, const char* key, const char* extraCls, bool hiddenInit) {    html += "<a href=\"";    html += href;    html += "\" class=\"";    if (extraCls) { html += extraCls; html += " "; }    if (strcmp(key, activePage) == 0) html += "active";    html += "\"";    if (hiddenInit) html += " hidden";    html += ">";    html += label;    html += "</a>";  };  navLink("/",          "Dashboard",    "dash", nullptr,        false);  navLink("/kontrol",   "Kontrol",      "ctrl", "authRequired", true);  navLink("/settings",  "Indstillinger","set",  "authRequired", true);  navLink("/firmware",  "Firmware",     "fw",   "authRequired", true);  // Login/logout - JS aabner overlayet  html += "<a href=\"#\" class=\"login authGuest\" hidden onclick=\"event.preventDefault();showLogin();\">Log ind</a>";  html += "<a href=\"#\" class=\"logout authRequired\" hidden onclick=\"event.preventDefault();doLogout();\">Log ud</a>";  html += "</div></nav>";  // Login overlay (delt p'aa alle sider)  html += R"HTML(<div class="loginOverlay" id="loginOverlay">  <div class="loginBox">    <h2><span class="glyph">&#128273;</span> Log ind</h2>    <p>Puls-Timer er beskyttet. Indtast adgangskoden for at f&aring; adgang til indstillinger og manuel puls.</p>    <label class="field">      <span style="display:block;font-size:.75rem;text-transform:uppercase;letter-spacing:1.5px;color:var(--navy);margin-bottom:6px;font-weight:600">Adgangskode</span>      <input type="password" id="loginPw" autocomplete="current-password">    </label>    <div class="err" id="loginErr"></div>    <div class="row">      <button class="btn ghost" id="btnLoginCancel" type="button">Annuller</button>      <button class="btn primary" id="btnLoginSubmit" type="button">Log ind</button>    </div>  </div></div>)HTML";  html += "<main class=\"wrap\">";  if (title[0] != '\0') {    html += "<h1 class=\"pageTitle\">";    html += title;    html += "</h1>";  }  html += FPSTR(bodyProgmem);  html += FPSTR(PAGE_FOOT);  server.sendHeader("Cache-Control", "no-store");  server.send(200, "text/html", html);}void handleControl()  { sendPage("Kontrol",        "ctrl", PAGE_CONTROL); }void handleDashboard(){ sendPage("",               "dash", PAGE_DASHBOARD); }void handleSettings() { sendPage("Indstillinger",  "set",  PAGE_SETTINGS);  }void handleFirmware() { sendPage("Firmware",       "fw",   PAGE_FIRMWARE);  }// ---- API: status ----void handleApiStatus() {  struct tm t;  bool timeOk = getLocalNow(t);  char nowStr[32] = "--:--:--";  char dateStr[32] = "--";  if (timeOk) {    snprintf(nowStr,  sizeof(nowStr),  "%02d:%02d:%02d", t.tm_hour, t.tm_min, t.tm_sec);    snprintf(dateStr, sizeof(dateStr), "%04d-%02d-%02d", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday);  }  long secs = secondsToNext();  String nextLabel = nextTimeLabel();  String json = "{";  json += "\"time\":\""    + String(nowStr)  + "\",";  json += "\"date\":\""    + String(dateStr) + "\",";  json += "\"timeOk\":"    + String(timeOk ? "true" : "false") + ",";  json += "\"nextLabel\":\""+ jsonEscape(nextLabel) + "\",";  json += "\"secToNext\":" + String(secs) + ",";  json += "\"numTimes\":"  + String(cfg.numTimes) + ",";  json += "\"relay\":"     + String(relayActive ? "true" : "false") + ",";  json += "\"pulse\":"     + String(cfg.pulseSeconds) + ",";  json += "\"summer\":"    + String(cfg.summerTime ? "true" : "false") + ",";  json += "\"apMode\":"    + String(apMode ? "true" : "false") + ",";  json += "\"ip\":\""      + (apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString()) + "\",";  json += "\"rssi\":"      + String(WiFi.RSSI()) + ",";  json += "\"fw\":\""      + String(FW_VERSION) + "\",";  json += "\"device\":\""  + jsonEscape(cfg.deviceName) + "\",";  json += "\"authed\":"    + String(isAuthed() ? "true" : "false") + ",";  json += "\"authReq\":"   + String(cfg.adminPass.length() > 0 ? "true" : "false") + ",";  json += "\"complete\":"  + String(sequenceComplete ? "true" : "false") + ",";  json += "\"fired\":[";  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (i > 0) json += ",";    json += hasFired[i] ? "true" : "false";  }  json += "],";  json += "\"times\":[";  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (i > 0) json += ",";    json += "\"" + jsonEscape(cfg.times[i]) + "\"";  }  json += "]}";  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", json);}// ---- API: config get ----void handleApiConfigGet() {  if (rejectIfUnauthed()) return;  String json = "{";  json += "\"numTimes\":"   + String(cfg.numTimes) + ",";  json += "\"times\":[";  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    if (i > 0) json += ",";    json += "\"" + jsonEscape(cfg.times[i]) + "\"";  }  json += "],";  json += "\"ntp\":\""      + jsonEscape(cfg.ntpServer) + "\",";  json += "\"summer\":"     + String(cfg.summerTime ? "true" : "false") + ",";  json += "\"pulse\":"      + String(cfg.pulseSeconds) + ",";  json += "\"ssid\":\""     + jsonEscape(cfg.wifiSSID) + "\",";  json += "\"device\":\""   + jsonEscape(cfg.deviceName) + "\"";  json += "}";  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", json);}// ---- API: login ----void handleApiLogin() {  if (!server.hasArg("plain")) { server.send(400, "text/plain", "no body"); return; }  String body = server.arg("plain");  String pw = jsonGetString(body, "password");  if (cfg.adminPass.length() == 0) {    // Ingen kode sat = auth deaktiveret    server.send(200, "application/json", "{\"ok\":true,\"noauth\":true}");    return;  }  if (pw != cfg.adminPass) {    server.send(401, "application/json", "{\"ok\":false,\"err\":\"forkert kode\"}");    return;  }  String token = issueSession();  String cookie = "pt_auth=" + token + "; Path=/; Max-Age=2592000; SameSite=Lax";  server.sendHeader("Set-Cookie", cookie);  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", "{\"ok\":true}");}// ---- API: logout ----void handleApiLogout() {  String token = getCookie("pt_auth");  clearSession(token);  server.sendHeader("Set-Cookie", "pt_auth=; Path=/; Max-Age=0");  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", "{\"ok\":true}");}// ---- API: skift adgangskode ----void handleApiPassword() {  if (rejectIfUnauthed()) return;  if (!server.hasArg("plain")) { server.send(400, "text/plain", "no body"); return; }  String body = server.arg("plain");  String cur  = jsonGetString(body, "current");  String next = jsonGetString(body, "new");  if (cfg.adminPass.length() > 0 && cur != cfg.adminPass) {    server.send(401, "application/json", "{\"ok\":false,\"err\":\"forkert nuv'aerende kode\"}");    return;  }  cfg.adminPass = next;  saveConfig();  // Rens alle andre sessioner - tving genlogin  String myTok = getCookie("pt_auth");  for (int i = 0; i < MAX_SESSIONS; i++) {    if (sessionTokens[i] != myTok) sessionTokens[i] = "";  }  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", "{\"ok\":true}");}// ---- API: config save (POST JSON) ----void handleApiConfigSave() {  if (rejectIfUnauthed()) return;  if (!server.hasArg("plain")) { server.send(400, "text/plain", "no body"); return; }  String body = server.arg("plain");  // Snapshot af nuv'aerende tider - til at opdage om brugeren gemmer NYE tider  int   oldNum = cfg.numTimes;  String oldTimes[MAX_TIMES];  for (int i = 0; i < MAX_TIMES; i++) oldTimes[i] = cfg.times[i];  String v;  v = jsonGetString(body, "numTimes");  if (v.length()) {    int n = v.toInt();    if (n < 0) n = 0;    if (n > MAX_TIMES) n = MAX_TIMES;    cfg.numTimes = n;  }  for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {    String key = "t" + String(i);    v = jsonGetString(body, key);    if (v.length() == 0) v = "00:00";    cfg.times[i] = v;  }  v = jsonGetString(body, "ntp");  if (v.length()) cfg.ntpServer = v;  v = jsonGetString(body, "summer");  if (v.length()) cfg.summerTime = (v == "true" || v == "1");  v = jsonGetString(body, "pulse");  if (v.length()) {    int p = v.toInt();    if (p < 1)   p = 1;    if (p > 600) p = 600;    cfg.pulseSeconds = p;  }  v = jsonGetString(body, "device");  if (v.length()) cfg.deviceName = v;  saveConfig();  applyTimezone();  // Sammenlign gamle og nye tider - nulstil sekvens hvis noget er 'endret  bool timesChanged = (oldNum != cfg.numTimes);  if (!timesChanged) {    for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {      if (oldTimes[i] != cfg.times[i]) { timesChanged = true; break; }    }  }  if (timesChanged) resetSequence();  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json",              timesChanged ? "{\"ok\":true,\"reset\":true}" : "{\"ok\":true}");}// ---- API: WiFi save ----void handleApiWifi() {  if (rejectIfUnauthed()) return;  if (!server.hasArg("plain")) { server.send(400, "text/plain", "no body"); return; }  String body = server.arg("plain");  String s = jsonGetString(body, "ssid");  String p = jsonGetString(body, "pass");  if (s.length() == 0) { server.send(400, "application/json", "{\"ok\":false,\"err\":\"tom ssid\"}"); return; }  cfg.wifiSSID = s;  cfg.wifiPass = p;  saveWifi();  server.send(200, "application/json", "{\"ok\":true,\"msg\":\"Gemt - genstart for at forbinde\"}");}// ---- API: WiFi scan ----void handleApiWifiScan() {  if (rejectIfUnauthed()) return;  // Synkron scan - blokerer i ~2-4 sek. Acceptabelt for et konfig-endpoint.  int n = WiFi.scanNetworks(false, false);  String json = "{\"nets\":[";  for (int i = 0; i < n; i++) {    if (i > 0) json += ",";    json += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",";    json += "\"rssi\":"    + String(WiFi.RSSI(i)) + ",";    json += "\"lock\":"    + String(WiFi.encryptionType(i) != WIFI_AUTH_OPEN ? "true" : "false") + "}";  }  json += "]}";  WiFi.scanDelete();  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", json);}// ---- API: manuel puls ----void handleApiPulse() {  if (rejectIfUnauthed()) return;  startPulse("manuel");  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", "{\"ok\":true}");}// ---- API: restart ----void handleApiRestart() {  if (rejectIfUnauthed()) return;  server.send(200, "application/json", "{\"ok\":true,\"msg\":\"genstarter\"}");  delay(400);  ESP.restart();}// ---- API: NTP sync ----void handleApiSync() {  if (rejectIfUnauthed()) return;  configTime(0, 0, cfg.ntpServer.c_str());  applyTimezone();  unsigned long t0 = millis();  time_t now = 0;  while ((now = time(nullptr)) < 1700000000 && millis() - t0 < 6000) delay(150);  bool ok = (now >= 1700000000);  if (ok) lastNtpSync = now;  String json = String("{\"ok\":") + (ok ? "true" : "false") + "}";  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", json);}// ---- API: OTA ----void handleApiOta() {  if (rejectIfUnauthed()) return;  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", "{\"ok\":true,\"msg\":\"OTA startet\"}");  delay(200);  WiFiClientSecure client;  client.setInsecure();  httpUpdate.setLedPin(-1, LOW);  httpUpdate.rebootOnUpdate(true);  Serial.printf("[OTA] Henter %s\n", OTA_URL);  t_httpUpdate_return ret = httpUpdate.update(client, OTA_URL, FW_VERSION);  switch (ret) {    case HTTP_UPDATE_FAILED:      lastOtaMsg = String("Fejl: ") + httpUpdate.getLastErrorString();      Serial.printf("[OTA] FAILED: %s\n", lastOtaMsg.c_str());      break;    case HTTP_UPDATE_NO_UPDATES:      lastOtaMsg = "Ingen ny version";      Serial.println("[OTA] NO_UPDATES");      break;    case HTTP_UPDATE_OK:      lastOtaMsg = "OK";      Serial.println("[OTA] OK");      break;  }}// ---- API: OTA status ----void handleApiOtaStatus() {  if (rejectIfUnauthed()) return;  String json = "{\"msg\":\"" + jsonEscape(lastOtaMsg) + "\"}";  server.sendHeader("Cache-Control", "no-store");  server.send(200, "application/json", json);}void setupWebServer() {  // Aktiver Cookie-header opsamling (skal ske FOR .begin())  const char* headersToKeep[] = {"Cookie"};  server.collectHeaders(headersToKeep, 1);  server.on("/",              HTTP_GET,  handleDashboard);  server.on("/kontrol",       HTTP_GET,  handleControl);  server.on("/settings",      HTTP_GET,  handleSettings);  server.on("/firmware",      HTTP_GET,  handleFirmware);  server.on("/api/status",    HTTP_GET,  handleApiStatus);  server.on("/api/config",    HTTP_GET,  handleApiConfigGet);  server.on("/api/config",    HTTP_POST, handleApiConfigSave);  server.on("/api/wifi",      HTTP_POST, handleApiWifi);  server.on("/api/wifi/scan", HTTP_GET,  handleApiWifiScan);  server.on("/api/login",     HTTP_POST, handleApiLogin);  server.on("/api/logout",    HTTP_POST, handleApiLogout);  server.on("/api/password",  HTTP_POST, handleApiPassword);  server.on("/api/pulse",     HTTP_POST, handleApiPulse);  server.on("/api/restart",   HTTP_POST, handleApiRestart);  server.on("/api/sync",      HTTP_POST, handleApiSync);  server.on("/api/ota",       HTTP_POST, handleApiOta);  server.on("/api/ota/status",HTTP_GET,  handleApiOtaStatus);  // ---- Captive portal detection endpoints ----  // Android/Chrome tjekker /generate_204. Apple bruger /hotspot-detect.html  // + /library/test/success.html. Windows bruger /ncsi.txt + /connecttest.txt.  // Ubuntu bruger /canonical.html. Vi svarer med dashboard-siden i AP-mode -  // et hvilket som helst svar der IKKE matcher det forventede trigger  // klientens "log ind til netv'aerk"-popup.  auto captiveHandler = []() {    if (apMode) {      handleDashboard();    } else {      server.sendHeader("Location", "/", true);      server.send(302, "text/plain", "");    }  };  server.on("/generate_204",           HTTP_GET, captiveHandler);  server.on("/gen_204",                HTTP_GET, captiveHandler);  server.on("/hotspot-detect.html",    HTTP_GET, captiveHandler);  server.on("/library/test/success.html", HTTP_GET, captiveHandler);  server.on("/ncsi.txt",               HTTP_GET, captiveHandler);  server.on("/connecttest.txt",        HTTP_GET, captiveHandler);  server.on("/canonical.html",         HTTP_GET, captiveHandler);  server.on("/success.txt",            HTTP_GET, captiveHandler);  server.on("/redirect",               HTTP_GET, captiveHandler);  server.onNotFound([]() {    if (apMode) {      // I AP-mode: server dashboardet direkte i stedet for redirect.      // 302 til roden virker ikke som captive-portal-trigger p'aa alle OS'er,      // men et 200-svar med HTML g'or.      handleDashboard();    } else {      server.sendHeader("Location", "/", true);      server.send(302, "text/plain", "");    }  });  server.begin();  Serial.println("[WEB] Server startet p'aa port 80");}// ==================== SETUP / LOOP ====================void setup() {  Serial.begin(115200);  delay(200);  Serial.printf("\n\n=== Puls-Timer %s ===\n", FW_VERSION);  bootMillis = millis();  pinMode(RELAY_PIN, OUTPUT);  digitalWrite(RELAY_PIN, LOW);  loadConfig();  loadSequenceState();  if (cfg.wifiSSID.length() == 0) {    // First boot? Fors'og at restore fra LittleFS-backup    restoreFromFS();  }  setupWiFiSTA();  setupNTP();  // Hvis vi booter INDE i et trigger-sekund, skal vi ikke fyre bagefter.  // Marker det som "allerede fyret" - hvis det ikke allerede er det.  struct tm tNow;  if (getLocalNow(tNow) && !sequenceComplete) {    long nowSecOfDay = (long)tNow.tm_hour * 3600L + (long)tNow.tm_min * 60L + tNow.tm_sec;    bool changed = false;    for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {      int hh, mm, ss;      if (!parseHms(cfg.times[i], hh, mm, ss)) continue;      long trigSec = (long)hh * 3600L + (long)mm * 60L + ss;      // Marker som fyret hvis vi booter meget t'et p'aa trigger-sekundet      long diff = nowSecOfDay - trigSec;      if (diff >= 0 && diff <= 2 && !hasFired[i]) {        hasFired[i] = true;        changed = true;      }    }    if (changed) {      bool allDone = (cfg.numTimes > 0);      for (int i = 0; i < cfg.numTimes && i < MAX_TIMES; i++) {        if (!hasFired[i]) { allDone = false; break; }      }      if (allDone) sequenceComplete = true;      saveSequenceState();    }  }  setupWebServer();  Serial.println("[BOOT] Klar.");}void loop() {  server.handleClient();  if (apMode) dnsServer.processNextRequest();  updateRelay();  static unsigned long lastTrigCheck = 0;  if (millis() - lastTrigCheck > 500) {    lastTrigCheck = millis();    if (!apMode) checkTriggers();  }  // Genskabelse: hvis STA er faldet ud, pr'ov igen efter 30 s  static unsigned long lastWifiCheck = 0;  if (!apMode && millis() - lastWifiCheck > 30000) {    lastWifiCheck = millis();    if (WiFi.status() != WL_CONNECTED) {      Serial.println("[WIFI] Tabt - reconnect");      WiFi.reconnect();    }  }}