Kode – Puls Timer
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">⚓</span> <span>Puls-Timer · 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æ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">⚓ ☸ ⚓</div></section><section class="card"> <h2><span class="glyph">⏱</span> Næste trigger</h2> <div class="next"> <div class="box"> <div class="lbl">Næste tid</div> <div class="val" id="dNext">--:--</div> </div> <div class="box"> <div class="lbl">Nedtæ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">⚡</span> Manuel puls</h2> <p style="color:var(--muted);margin:0 0 16px">Aktiverer relæet på GPIO 19 i det antal sekunder du har valgt.</p> <button class="btn primary big" id="btnPulse">⚡ 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æ</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">⏰</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">⏰</span> Puls-varighed</h2> <label class="field"> <span>Sekunder relæet er aktivt</span> <input type="number" id="sPulse" min="1" max="600" value="3"> </label></section><section class="card"> <h2><span class="glyph">☀</span> Tid & 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">↻ Synk NTP nu</button> <span class="chip" id="sClock">--:--:--</span> </div></section><section class="card"> <h2><span class="glyph">🔑</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">🔎 Scan efter netværk</button> <span class="hint" id="sScanHint" style="margin:0">Klik for at søge synlige netvæ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ælg fra listen eller skriv manuelt"> </label> <label class="field"> <span>Adgangskode</span> <input type="password" id="sPass" placeholder="••••••"> </label> </div> <div class="hint">WiFi træder i kraft efter genstart. Adgangskoden gemmes på ESP'en (NVS + LittleFS).</div> <div class="actions"> <button class="btn navy" id="btnWifi">Gem WiFi</button> <button class="btn danger" id="btnRestart">↺ Genstart enhed</button> </div></section><section class="card"> <h2><span class="glyph">🔑</span> Adgangskode</h2> <div class="row2"> <label class="field"> <span>Nuvæ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æ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">💾 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">🔒</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">⚙</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">📣</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">↓ Opdater firmware</button> <button class="btn ghost" id="btnRefresh">Genindlæs status</button> </div></section><section class="card"> <h2><span class="glyph">📜</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å 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ørste version - grundlæggende trigger-tider på minut-niveau, manuel puls, WiFi-konfiguration og OTA. </div> </li> </ul> <div class="hint" style="margin-top:14px">Viser de sidste 5 versioner. Ældre versioner falder af listen efterhånden som nye tilfø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\">⚓</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">🔑</span> Log ind</h2> <p>Puls-Timer er beskyttet. Indtast adgangskoden for at få 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(); } }}