/* * ============================================================ * ElPris Monitor — ESP32 * Af: Bredahl / bredahl.dk * ============================================================ */ #include #include #include #include #include #include #include // ── 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ænse float 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 hentning const unsigned long FETCH_INTERVAL = 60UL * 60UL * 1000UL; // max 60 min mellem hentninger const 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(); float sum[24]; int cnt[24]; String hrs[24]; int hourCount = 0; String curHour = ""; for (JsonObject rec : records) { String t = rec["TimeDK"].as(); // "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(); // 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( ElPris Monitor

⚡ ElPris Monitor

Dansk elspotpris · realtid
--:--
)rawliteral"; // ── Aktuel pris ── html += "

Aktuel spotpris

"; if (g_currentPrice >= 0) { html += "
" + String(g_currentPrice, 2) + "
"; html += "
kr/kWh inkl. moms
"; } else { html += "
Ingen data
"; html += "
" + esc(g_lastStatus) + "
"; } html += "
" + statusText + "
"; // ── Max-pris ── html += "

Max-pris grænse

"; html += "
" + String(g_maxPrice, 2) + " kr/kWh
"; html += "
Zone: " + esc(g_priceArea) + ""; if (g_company.length()) html += " · " + esc(g_company) + ""; if (g_address.length()) html += "
" + esc(g_address); html += "
"; // ── Nøgletal (udfyldes af JS) ── html += R"rawliteral(

Næste 24 timer

Billigste time–
Dyreste time–
Gennemsnit–
Timer under grænse–
)rawliteral"; // ── Graf ── html += "

Spotpriser (kr/kWh inkl. moms)

"; if (g_priceCount > 0) html += ""; else html += "
Ingen prisdata endnu — " + esc(g_lastStatus) + "
"; html += "
"; // ── Footer ── if (apMode) { html += "
⚠️ Opsætningstilstand: Åbn Indstillinger og udfyld WiFi for at forbinde.
"; } else { html += "
🕐 Opdateres hver timeKilde: Energi Data ServiceEkskl. nettarif & afgifter
"; } html += "
"; // ── Indstillinger (dialog) ── html += R"rawliteral(

⚙️ Indstillinger

📶 WiFi

)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 = "" "

✅ Gemt! Genstarter om 3 sekunder...

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