Kode – Elpris monitor
main.ino · 649 linjer
Download
/* * ============================================================ * ElPris Monitor — ESP32 * Af: Bredahl / bredahl.dk * ============================================================ */#include <WiFi.h>#include <WiFiClientSecure.h>#include <HTTPClient.h>#include <ArduinoJson.h>#include <ESPAsyncWebServer.h>#include <Preferences.h>#include <time.h>// ── PIN-definitioner ──────────────────────────────────────────#define PIN_LED_GREEN 26#define PIN_LED_RED 27// ── Captive Portal / AP ───────────────────────────────────────const char* AP_SSID = "ElPris-Setup";const char* AP_PASSWORD = "12345678"; // Min 8 tegn – sæt til "" for åbent netværk// ── Webserver ─────────────────────────────────────────────────AsyncWebServer server(80);// ── Persistent opbevaring ─────────────────────────────────────Preferences prefs;// ── Globale tilstandsvariabler ────────────────────────────────String g_ssid = "";String g_password = "";String g_address = "";String g_company = "";String g_priceArea = "DK1"; // DK1 (Vestdanmark) eller DK2 (Østdanmark)float g_maxPrice = 2.0f; // kr/kWh inkl. moms — brugerens grænsefloat g_prices[24]; // Priser de næste 24 timer (kr/kWh inkl. moms)String g_hours[24]; // Tilhørende tidspunkter (HH:00)int g_priceCount = 0;float g_currentPrice = -1.0f;bool g_apMode = false;unsigned long g_lastFetch = 0;bool g_hasFetched = false;int g_fetchHour = -1; // time (0-23) for seneste vellykkede hentningconst unsigned long FETCH_INTERVAL = 60UL * 60UL * 1000UL; // max 60 min mellem hentningerconst unsigned long RETRY_INTERVAL = 5UL * 60UL * 1000UL; // 5 min pause efter fejl (fx 429)unsigned long g_nextFetchDelay = 0;String g_lastStatus = "Henter priser..."; // vises på siden ved fejl // 0 = hent straks// ─────────────────────────────────────────────────────────────// Hjælper: HTML-escape af brugerindtastede værdier// ─────────────────────────────────────────────────────────────String esc(const String& s) { String o; o.reserve(s.length() + 8); for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '&') o += "&"; else if (c == '<') o += "<"; else if (c == '>') o += ">"; else if (c == '"') o += """; else if (c == '\'') o += "'"; else o += c; } return o;}// ─────────────────────────────────────────────────────────────// Indlæs indstillinger fra flash// ─────────────────────────────────────────────────────────────void loadSettings() { prefs.begin("elpris", true); g_ssid = prefs.getString("ssid", ""); g_password = prefs.getString("password", ""); g_address = prefs.getString("address", ""); g_company = prefs.getString("company", ""); g_priceArea = prefs.getString("pricearea", "DK1"); g_maxPrice = prefs.getFloat ("maxprice", 2.0f); prefs.end();}// ─────────────────────────────────────────────────────────────// Gem indstillinger til flash// ─────────────────────────────────────────────────────────────void saveSettings() { prefs.begin("elpris", false); prefs.putString("ssid", g_ssid); prefs.putString("password", g_password); prefs.putString("address", g_address); prefs.putString("company", g_company); prefs.putString("pricearea", g_priceArea); prefs.putFloat ("maxprice", g_maxPrice); prefs.end();}// ─────────────────────────────────────────────────────────────// Hent elpriser fra Energi Data Service (DayAheadPrices, 15-min)// Kvartersværdier middelværdi-aggregeres til timepriser.// ─────────────────────────────────────────────────────────────void fetchPrices() { if (WiFi.status() != WL_CONNECTED) return; // Sæt altid tidsstempel FØR kaldet, så en fejl aldrig giver retry hvert sekund g_lastFetch = millis(); g_hasFetched = true; g_nextFetchDelay = RETRY_INTERVAL; // overskrives ved succes // Start ved begyndelsen af nuværende time (dansk tid) hvis NTP er klar struct tm tmNow; String startParam; bool timeOk = getLocalTime(&tmNow, 100); if (timeOk) { char buf[20]; snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:00", tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday, tmNow.tm_hour); startParam = String(buf); } else { startParam = "now-PT1H"; } String url = "https://api.energidataservice.dk/dataset/DayAheadPrices" "?columns=TimeDK,DayAheadPriceDKK" "&start=" + startParam + "&sort=TimeDK%20asc&limit=120" "&filter=%7B%22PriceArea%22%3A%5B%22" + g_priceArea + "%22%5D%7D"; WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.setTimeout(10000); http.begin(client, url); http.addHeader("Accept", "application/json"); int httpCode = http.GET(); if (httpCode != 200) { Serial.printf("[Fetch] HTTP fejl: %d — prøver igen om %lu min\n", httpCode, RETRY_INTERVAL / 60000UL); g_lastStatus = "HTTP " + String(httpCode) + (httpCode == 429 ? " (for mange forespørgsler)" : "") + " — prøver igen om 5 min"; http.end(); return; } String payload = http.getString(); http.end(); DynamicJsonDocument doc(24576); DeserializationError err = deserializeJson(doc, payload); if (err) { Serial.printf("[Fetch] JSON fejl: %s\n", err.c_str()); g_lastStatus = String("JSON-fejl: ") + err.c_str() + " — prøver igen om 5 min"; return; } JsonArray records = doc["records"].as<JsonArray>(); float sum[24]; int cnt[24]; String hrs[24]; int hourCount = 0; String curHour = ""; for (JsonObject rec : records) { String t = rec["TimeDK"].as<String>(); // "2026-09-20T14:15:00" if (t.length() < 13) continue; String h = t.substring(11, 13); // "14" if (h != curHour) { if (hourCount >= 24) break; curHour = h; hrs[hourCount] = h + ":00"; sum[hourCount] = 0; cnt[hourCount] = 0; hourCount++; } float dkkMwh = rec["DayAheadPriceDKK"].as<float>(); // DKK/MWh ekskl. moms sum[hourCount - 1] += dkkMwh; cnt[hourCount - 1]++; } if (hourCount == 0) { Serial.println("[Fetch] Ingen prisdata i svar"); Serial.println(payload.substring(0, 300)); g_lastStatus = "Svar uden prisdata — prøver igen om 5 min"; return; } g_priceCount = hourCount; for (int i = 0; i < hourCount; i++) { float avg = sum[i] / (float)cnt[i]; g_prices[i] = (avg / 1000.0f) * 1.25f; // kr/kWh inkl. 25% moms g_hours[i] = hrs[i]; } g_currentPrice = g_prices[0]; g_lastStatus = "OK"; g_fetchHour = timeOk ? tmNow.tm_hour : -1; g_nextFetchDelay = FETCH_INTERVAL; Serial.printf("[Fetch] Hentet %d timer. Aktuel: %.4f kr/kWh\n", g_priceCount, g_currentPrice);}// ─────────────────────────────────────────────────────────────// Opdater LED-status// ─────────────────────────────────────────────────────────────void updateLEDs() { if (g_currentPrice < 0) { digitalWrite(PIN_LED_GREEN, LOW); digitalWrite(PIN_LED_RED, LOW); return; } if (g_currentPrice <= g_maxPrice) { digitalWrite(PIN_LED_GREEN, HIGH); digitalWrite(PIN_LED_RED, LOW); } else { digitalWrite(PIN_LED_GREEN, LOW); digitalWrite(PIN_LED_RED, HIGH); }}// ─────────────────────────────────────────────────────────────// Byg HTML-side (dashboard der fylder én skærm)// ─────────────────────────────────────────────────────────────String buildHTML(bool apMode) { // JSON-arrays til Chart.js String labelsJson = "["; String dataJson = "["; for (int i = 0; i < g_priceCount; i++) { labelsJson += "\"" + g_hours[i] + "\""; dataJson += String(g_prices[i], 4); if (i < g_priceCount - 1) { labelsJson += ","; dataJson += ","; } } labelsJson += "]"; dataJson += "]"; // Status String statusClass = "grey"; String statusText = "⚪ Ingen prisdata"; String priceClass = ""; if (g_currentPrice >= 0) { if (g_currentPrice <= g_maxPrice) { statusClass = "green"; statusText = "🟢 Under max-pris"; priceClass = "ok"; } else { statusClass = "red"; statusText = "🔴 Over max-pris"; priceClass = "over"; } } String html = R"rawliteral(<!DOCTYPE html><html lang="da"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ElPris Monitor</title><script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script><style> :root { --bg:#0b0e14; --card:#151925; --card2:#1b2030; --border:#262b3d; --text:#e6eaf2; --muted:#8b95ad; --accent:#38bdf8; --green:#22c55e; --red:#ef4444; --yellow:#f59e0b; } * { box-sizing:border-box; margin:0; padding:0; } html, body { height:100%; } body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; } .app { height:100dvh; display:grid; grid-template-rows:auto 1fr auto; gap:12px; padding:14px 18px; } /* Header */ header { display:flex; align-items:center; justify-content:space-between; gap:12px; } .brand { display:flex; align-items:baseline; gap:12px; } .brand h1 { font-size:1.35rem; font-weight:700; color:var(--accent); letter-spacing:.01em; } .brand span { color:var(--muted); font-size:.85rem; } .head-right { display:flex; align-items:center; gap:10px; } #clock { font-variant-numeric:tabular-nums; font-size:1.05rem; color:var(--muted); } .btn { background:var(--card2); color:var(--text); border:1px solid var(--border); border-radius:10px; padding:8px 14px; font-size:.85rem; cursor:pointer; } .btn:hover { border-color:var(--accent); color:var(--accent); } /* Layout */ .main { display:grid; grid-template-columns:320px 1fr; gap:12px; min-height:0; } .side { display:grid; grid-template-rows:auto auto 1fr; gap:12px; min-height:0; } .card { background:var(--card); border:1px solid var(--border); border-radius:14px; padding:16px 18px; } .card h2 { font-size:.7rem; text-transform:uppercase; letter-spacing:.1em; color:var(--muted); font-weight:600; margin-bottom:10px; } .price-big { font-size:3.6rem; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; } .price-big.ok { color:var(--green); } .price-big.over { color:var(--red); } .price-big.none { font-size:1.6rem; color:var(--muted); } .price-unit { font-size:.85rem; color:var(--muted); margin-top:4px; } .badge { display:inline-block; padding:5px 12px; border-radius:999px; font-size:.8rem; font-weight:600; margin-top:12px; } .badge.green { background:#12391f; color:var(--green); } .badge.red { background:#3f0d0d; color:var(--red); } .badge.grey { background:#1e2536; color:var(--muted); } .max-val { font-size:2rem; font-weight:700; font-variant-numeric:tabular-nums; } .meta { margin-top:8px; font-size:.78rem; color:var(--muted); line-height:1.6; } .meta b { color:var(--text); font-weight:600; } .stats { display:flex; flex-direction:column; gap:0; } .stat { display:flex; justify-content:space-between; align-items:baseline; padding:10px 0; border-bottom:1px solid var(--border); } .stat:last-child { border-bottom:none; } .stat .k { font-size:.8rem; color:var(--muted); } .stat .v { font-size:1rem; font-weight:600; font-variant-numeric:tabular-nums; } .stat .v small { color:var(--muted); font-weight:400; font-size:.75rem; margin-left:4px; } /* Graf */ .chartcard { display:flex; flex-direction:column; min-height:0; min-width:0; } .chartbox { position:relative; flex:1; min-height:0; } .nodata { display:flex; align-items:center; justify-content:center; height:100%; color:var(--muted); } footer { display:flex; gap:8px; flex-wrap:wrap; font-size:.75rem; color:var(--muted); } footer span { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:3px 10px; } .alert { background:#221c00; border:1px solid var(--yellow); border-radius:10px; padding:8px 14px; color:var(--yellow); font-size:.82rem; } /* Indstillinger (pop-up) */ dialog { margin:auto; background:var(--card); color:var(--text); border:1px solid var(--border); border-radius:16px; padding:0; width:min(560px, 94vw); } dialog::backdrop { background:rgba(0,0,0,.65); backdrop-filter:blur(3px); } dialog form { padding:22px; } dialog h2 { font-size:1.05rem; margin-bottom:6px; } dialog h3 { font-size:.85rem; margin:18px 0 0; color:var(--accent); } label { display:block; font-size:.75rem; color:var(--muted); margin:12px 0 4px; } input, select { width:100%; padding:9px 12px; border-radius:8px; border:1px solid var(--border); background:var(--bg); color:var(--text); font-size:.9rem; } input:focus, select:focus { outline:2px solid var(--accent); border-color:transparent; } .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .actions { display:flex; gap:10px; margin-top:20px; } .actions button { flex:1; padding:10px; border-radius:8px; font-size:.9rem; font-weight:700; cursor:pointer; border:1px solid var(--border); } .actions .save { background:var(--accent); color:#0b0e14; border:none; } .actions .cancel { background:transparent; color:var(--muted); } /* Mobil / smal skærm: stakkes og kan scrolles */ @media (max-width:820px) { body { overflow:auto; } .app { height:auto; } .main { grid-template-columns:1fr; } .side { grid-template-rows:none; } .chartbox { flex:none; height:300px; } .price-big { font-size:3rem; } .row { grid-template-columns:1fr; } .brand span { display:none; } }</style></head><body><div class="app"><header> <div class="brand"><h1>⚡ ElPris Monitor</h1><span>Dansk elspotpris · realtid</span></div> <div class="head-right"> <span id="clock">--:--</span> <button class="btn" onclick="document.getElementById('dlg').showModal()">⚙️ Indstillinger</button> </div></header><div class="main"><div class="side">)rawliteral"; // ── Aktuel pris ── html += "<div class='card'><h2>Aktuel spotpris</h2>"; if (g_currentPrice >= 0) { html += "<div class='price-big " + priceClass + "'>" + String(g_currentPrice, 2) + "</div>"; html += "<div class='price-unit'>kr/kWh inkl. moms</div>"; } else { html += "<div class='price-big none'>Ingen data</div>"; html += "<div class='price-unit'>" + esc(g_lastStatus) + "</div>"; } html += "<div class='badge " + statusClass + "'>" + statusText + "</div></div>"; // ── Max-pris ── html += "<div class='card'><h2>Max-pris grænse</h2>"; html += "<div class='max-val'>" + String(g_maxPrice, 2) + " <span class='price-unit'>kr/kWh</span></div>"; html += "<div class='meta'>Zone: <b>" + esc(g_priceArea) + "</b>"; if (g_company.length()) html += " · <b>" + esc(g_company) + "</b>"; if (g_address.length()) html += "<br>" + esc(g_address); html += "</div></div>"; // ── Nøgletal (udfyldes af JS) ── html += R"rawliteral(<div class='card'><h2>Næste 24 timer</h2><div class='stats'> <div class='stat'><span class='k'>Billigste time</span><span class='v' id='sMin'>–</span></div> <div class='stat'><span class='k'>Dyreste time</span><span class='v' id='sMax'>–</span></div> <div class='stat'><span class='k'>Gennemsnit</span><span class='v' id='sAvg'>–</span></div> <div class='stat'><span class='k'>Timer under grænse</span><span class='v' id='sOk'>–</span></div></div></div></div>)rawliteral"; // ── Graf ── html += "<div class='card chartcard'><h2>Spotpriser (kr/kWh inkl. moms)</h2><div class='chartbox'>"; if (g_priceCount > 0) html += "<canvas id='priceChart'></canvas>"; else html += "<div class='nodata'>Ingen prisdata endnu — " + esc(g_lastStatus) + "</div>"; html += "</div></div></div>"; // ── Footer ── if (apMode) { html += "<div class='alert'>⚠️ <strong>Opsætningstilstand:</strong> Åbn Indstillinger og udfyld WiFi for at forbinde.</div>"; } else { html += "<footer><span>🕐 Opdateres hver time</span><span>Kilde: Energi Data Service</span><span>Ekskl. nettarif & afgifter</span></footer>"; } html += "</div>"; // ── Indstillinger (dialog) ── html += R"rawliteral(<dialog id="dlg"><form method="POST" action="/save"><h2>⚙️ Indstillinger</h2><div class="row"> <div><label>Din adresse</label> <input name="address" placeholder="Vejnavn 1, 8000 Aarhus" value=")rawliteral"; html += esc(g_address); html += R"rawliteral("></div> <div><label>Elselskab</label> <input name="company" placeholder="f.eks. Vindstød, Norlys..." value=")rawliteral"; html += esc(g_company); html += R"rawliteral("></div></div><div class="row"> <div><label>Priszone</label> <select name="pricearea"> <option value="DK1")rawliteral"; html += (g_priceArea == "DK1" ? " selected" : ""); html += R"rawliteral(>DK1 — Vestdanmark</option> <option value="DK2")rawliteral"; html += (g_priceArea == "DK2" ? " selected" : ""); html += R"rawliteral(>DK2 — Østdanmark</option> </select></div> <div><label>Max-pris (kr/kWh inkl. moms)</label> <input name="maxprice" type="number" step="0.01" min="0" value=")rawliteral"; html += String(g_maxPrice, 2); html += R"rawliteral("></div></div><h3>📶 WiFi</h3><div class="row"> <div><label>SSID</label> <input name="ssid" placeholder="Dit netværksnavn" value=")rawliteral"; html += esc(g_ssid); html += R"rawliteral("></div> <div><label>Adgangskode</label> <input name="wifipassword" type="password" placeholder="Uændret hvis tom"></div></div><div class="actions"> <button type="button" class="cancel" onclick="document.getElementById('dlg').close()">Annuller</button> <button type="submit" class="save">💾 Gem & genstart</button></div></form></dialog><script>)rawliteral"; html += "const apMode=" + String(apMode ? "true" : "false") + ";"; html += "const labels=" + labelsJson + ";"; html += "const data=" + dataJson + ";"; html += "const maxP=" + String(g_maxPrice, 4) + ";"; html += R"js(// Urfunction tick(){ const d=new Date(); document.getElementById('clock').textContent= d.toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}); }tick(); setInterval(tick,15000);// Genindlæs hvert 30 sek. så længe der ingen data er (og ikke i opsætning)if (!data.length && !apMode) setTimeout(()=>location.reload(), 30000);// Åbn indstillinger automatisk i opsætningstilstandif (apMode) document.getElementById('dlg').showModal();// Nøgletalif (data.length) { const f = v => v.toFixed(2); let iMin=0, iMax=0, sum=0, ok=0; data.forEach((v,i)=>{ if(v<data[iMin])iMin=i; if(v>data[iMax])iMax=i; sum+=v; if(v<=maxP)ok++; }); document.getElementById('sMin').innerHTML = f(data[iMin]) + '<small>kl. ' + labels[iMin] + '</small>'; document.getElementById('sMax').innerHTML = f(data[iMax]) + '<small>kl. ' + labels[iMax] + '</small>'; document.getElementById('sAvg').innerHTML = f(sum/data.length) + '<small>kr/kWh</small>'; document.getElementById('sOk').innerHTML = ok + ' / ' + data.length; // Graf — første søjle = nuværende time (fremhævet) const colors = data.map(v => v > maxP ? 'rgba(239,68,68,0.85)' : 'rgba(34,197,94,0.85)'); const borders = data.map((v,i) => i===0 ? '#ffffff' : 'transparent'); new Chart(document.getElementById('priceChart').getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [{ label: 'Spotpris', data: data, backgroundColor: colors, borderColor: borders, borderWidth: 2, borderRadius: 5, borderSkipped: false },{ label: 'Max-pris', data: Array(data.length).fill(maxP), type: 'line', borderColor: '#f59e0b', borderDash: [6,4], borderWidth: 2, pointRadius: 0, fill: false }] }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { labels: { color: '#8b95ad', font: { size: 11 }, boxWidth: 12 } }, tooltip: { callbacks: { label: c => c.dataset.label + ': ' + c.parsed.y.toFixed(3) + ' kr/kWh' } } }, scales: { x: { ticks: { color: '#8b95ad', maxRotation: 0, autoSkip: true }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: '#8b95ad', callback: v => v.toFixed(1) }, grid: { color: '#262b3d' } } } } });}</script></body></html>)js"; return html;}// ─────────────────────────────────────────────────────────────// Opret webserver-routes// ─────────────────────────────────────────────────────────────void setupRoutes() { server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) { req->send(200, "text/html", buildHTML(g_apMode)); }); server.on("/save", HTTP_POST, [](AsyncWebServerRequest* req) { if (req->hasParam("address", true)) g_address = req->getParam("address", true)->value(); if (req->hasParam("company", true)) g_company = req->getParam("company", true)->value(); if (req->hasParam("pricearea", true)) g_priceArea = req->getParam("pricearea", true)->value(); if (req->hasParam("maxprice", true)) g_maxPrice = req->getParam("maxprice", true)->value().toFloat(); if (req->hasParam("ssid", true) && req->getParam("ssid", true)->value().length() > 0) g_ssid = req->getParam("ssid", true)->value(); if (req->hasParam("wifipassword", true) && req->getParam("wifipassword", true)->value().length() > 0) g_password = req->getParam("wifipassword", true)->value(); saveSettings(); String resp = "<html><body style='background:#0b0e14;color:#e6eaf2;font-family:sans-serif;padding:30px;'>" "<h2 style='color:#38bdf8'>✅ Gemt! Genstarter om 3 sekunder...</h2>" "<script>setTimeout(()=>location.href='/',5000)</script></body></html>"; req->send(200, "text/html", resp); delay(1000); ESP.restart(); }); // Captive portal redirect (Android/iOS/Windows) server.on("/generate_204", HTTP_GET, [](AsyncWebServerRequest* r){ r->redirect("/"); }); server.on("/hotspot-detect.html", HTTP_GET, [](AsyncWebServerRequest* r){ r->redirect("/"); }); server.on("/ncsi.txt", HTTP_GET, [](AsyncWebServerRequest* r){ r->redirect("/"); }); server.onNotFound([](AsyncWebServerRequest* r){ r->redirect("/"); }); server.begin();}// ─────────────────────────────────────────────────────────────// SETUP// ─────────────────────────────────────────────────────────────void setup() { Serial.begin(115200); delay(500); Serial.println("\n=== ElPris Monitor ESP32 ==="); pinMode(PIN_LED_GREEN, OUTPUT); pinMode(PIN_LED_RED, OUTPUT); digitalWrite(PIN_LED_GREEN, LOW); digitalWrite(PIN_LED_RED, LOW); for (int i = 0; i < 3; i++) { digitalWrite(PIN_LED_GREEN, HIGH); digitalWrite(PIN_LED_RED, HIGH); delay(150); digitalWrite(PIN_LED_GREEN, LOW); digitalWrite(PIN_LED_RED, LOW); delay(150); } loadSettings(); if (g_ssid.length() > 0) { Serial.printf("[WiFi] Forbinder til '%s'...\n", g_ssid.c_str()); WiFi.mode(WIFI_STA); WiFi.begin(g_ssid.c_str(), g_password.c_str()); unsigned long t = millis(); while (WiFi.status() != WL_CONNECTED && millis() - t < 15000) { delay(500); Serial.print("."); } Serial.println(); } if (WiFi.status() == WL_CONNECTED) { g_apMode = false; Serial.printf("[WiFi] Forbundet! IP: %s\n", WiFi.localIP().toString().c_str()); configTime(3600, 3600, "pool.ntp.org", "time.google.com"); Serial.println("[NTP] Synkroniserer tid..."); struct tm t; int tries = 0; while (!getLocalTime(&t) && tries++ < 10) delay(1000); fetchPrices(); } else { g_apMode = true; WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID, AP_PASSWORD); Serial.printf("[AP] Captive Portal startet: SSID='%s' IP=%s\n", AP_SSID, WiFi.softAPIP().toString().c_str()); for (int i = 0; i < 5; i++) { digitalWrite(PIN_LED_RED, HIGH); delay(400); digitalWrite(PIN_LED_RED, LOW); delay(400); } } updateLEDs(); setupRoutes();}// ─────────────────────────────────────────────────────────────// LOOP// ─────────────────────────────────────────────────────────────void loop() { if (!g_apMode && WiFi.status() == WL_CONNECTED) { bool due = !g_hasFetched || (millis() - g_lastFetch >= g_nextFetchDelay); // Timeskift: hent straks nye priser når klokken passerer en hel time struct tm t; if (!due && g_fetchHour >= 0 && getLocalTime(&t, 50) && t.tm_hour != g_fetchHour) due = true; if (due) { fetchPrices(); updateLEDs(); } // Ingen prisdata endnu: rød LED blinker langsomt (1 sek. tændt/slukket) // — så kan du se forskel på "ingen data" og "slukket". if (g_currentPrice < 0) { static bool blinkOn = false; blinkOn = !blinkOn; digitalWrite(PIN_LED_GREEN, LOW); digitalWrite(PIN_LED_RED, blinkOn ? HIGH : LOW); } } delay(1000);}