/* * 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 #include #include #include #include #include #include #include #include #include #include // ---------- 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 tider time_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( Puls-Timer "; // Login overlay (delt p'aa alle sider) html += R"HTML(

🔑 Log ind

Puls-Timer er beskyttet. Indtast adgangskoden for at få adgang til indstillinger og manuel puls.

)HTML"; html += "
"; if (title[0] != '\0') { html += "

"; html += title; html += "

"; } 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(); } } }