⚙ Bredahl - Udviklingsprojekter

← Elpris monitor

Kode – Elpris monitor

Download alle filer (.zip)
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 += "&amp;";    else if (c == '<')  o += "&lt;";    else if (c == '>')  o += "&gt;";    else if (c == '"')  o += "&quot;";    else if (c == '\'') o += "&#39;";    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 &amp; 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 &amp; 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);}