Zum Inhalt

energy_charts.tc

energy_charts.tc — die fuenf Webseiten von core2_energy.tc (.76) auf dem P4

Source on GitHub

// @name: Energie-Grafiken (Seiten des Core2, Daten von der SD-Karte)
// ─────────────────────────────────────────────────────────────────────
// energy_charts.tc — die fuenf Webseiten von core2_energy.tc (.76) auf dem P4
// (.118): Tages Verlauf, Letzte Woche, 52 Wochen, Andere Geraete, Heizung
// 31 Tage. Seiten, Auszuege und Diagramme sind wortgleich uebernommen.
//
// Anders ist nur die QUELLE. Der Core2 holt jeden Bereich auf Abruf von .61,
// ueber Port 80 -- genau der Weg, der .61 blockiert. Hier kommt alles von der
// SD-Karte, die energy_backup.tc (Slot 1) jede Nacht um 00:20 fuellt:
//   /energy_m_log.txt   Tagesdatenbank, ganz
//   /e15_JJJJ-MM.txt    15-Minuten-Werte eines Monats, ROH (Ortszeit wie .61,
//                       mit einer Stunde Ueberhang in den Folgemonat)
// Ohne Netz, ohne .61. Fehlt ein Monat (Geschichte noch nicht nachgeholt:
// EBAKALL), sagt die Seite das.
//
// Gleiche Zeilen wie auf dem Core2: .61 liefert einen Bereich OHNE das Ende,
// fileExtract nimmt das Ende MIT. Darum endet jeder Bereich, der auf dem Core2
// "heute 00:00" hiess, hier um 23:59 des Vortags.
//
// Das Live-Diagramm der vier Wechselrichter steht in energy_dashboard.tc
// (Slot 0): die Firmware leitet jeden webOn-Endpunkt an Slot 0 weiter, egal
// wer ihn anmeldet -- /tc_array aus diesem Slot bekam nie eine Antwort.
//
// Nicht uebernommen: Hauptseiten-Tabelle (hat energy_dashboard.tc in Slot 0),
// Core2-Bildschirm, Ton, Daikin-Abfrage.
//
// ⚠️ RINGFORMEL: WebChart liest seit Firmware 24.06.2026 arr[(pos+i) % count].
// Der Core2 legt Tageswerte an [1..count] ab und liest mit pos = count+1 -- das
// letzte Feld [count] ist damit unerreichbar, statt ihm kommt [0]. Darum steht
// nach jedem Auszug der letzte Wert zusaetzlich in [0] (Wochentabelle, Tages-
// bilanz, 52 Wochen, 31 Tage), und die 168-Stunden-Linien lesen ab pos 1.
//
// HEIZUNG: Seit Oktober 2023 heizt die Waermepumpe (HWP), Gas steht auf 0. Die
// Warmwasser macht eine eigene Brauchwasser-WP (BWWP); Gas deckte frueher
// beides. Die Wochenseite zeigt darum neben Gas die SUMME beider als "Waerme"
// (Leistungslinie, Tagesbalken, Tabelle mit "Waerme E" zum Strompreis, das
// Gegenstueck zu "Gas E"). Der HWP-Zaehler meldet im Sommer 0, weil die HWP aus
// ist; sein Stand bleibt erhalten. fileExtract rechnete bei der Rueckkehr den
// ganzen Zaehlerstand als Tagesverbrauch (3498 kWh am 1.10.24). hwpDeltas()
// behandelt 0 als fehlend und rechnet vom letzten gueltigen Stand weiter.
//
// Zwei Fehler des Originals sind behoben: Woche, 52 Wochen und 31 Tage teilen
// sich die w_*-Felder, Tagesbilanz und Woche die wt_*-Felder; wer zuletzt lud,
// stand ungeprueft auf der anderen Seite. Jetzt merkt sich wFor/wtFor, wessen
// Daten gerade darin liegen. Und die Tagesbilanz las /daylog.txt, die es auf
// dem Core2 nie gab.
// ─────────────────────────────────────────────────────────────────────

#define PRICE_GAS_KWH  12     // cents per kWh (gas: cbm * 9.9 = kwh)
#define PRICE_STROM    40     // cents per kWh electricity (wie core2_energy.tc)
#define MLOG  "/energy_m_log.txt"
#define TMP15 "/ch15.txt"

// Schaltuhr Garage + Schlafzimmer G kommen per UDP (Seite "Andere Geraete")
global char tv2_ip[16];
global char sug_ip[16];

// ═══════════════════════════════════════════════════════════════════
// Felder -- wortgleich aus core2_energy.tc
// ═══════════════════════════════════════════════════════════════════
float t_wbx[97];
float t_wr1[97];
float t_wr2[97];
float t_wr3[97];
float t_wr4[97];
float t_hv[97];
float t_atmp[97];
float t_ktmp[97];
float t_ss[97];
float t_pw[97];
float t_hzwp[97];
float t_bwwp[97];
float t_ni[97];
float t_gas[97];
float t_wa[97];
float t_wpx[97];
float t_rain[97];

// ═══════════════════════════════════════════════════════════════════
// TIME-SERIES ARRAYS for weekly charts (168 = hours per week)
// ═══════════════════════════════════════════════════════════════════
float w_atmp[169];
float w_bwp[169];
float w_hwp[169];
float w_gwp[169];
float w_rain[169];
float w_pwl[169];
float w_sin[169];
float w_swa[169];
float w_hv[169];
float w_at[169];
float w_sk[169];
float w_wr1[169];
float w_wr2[169];
float w_wr3[169];
float w_wr4[169];
float w_gas[169];
float w_pgas[169];
float w_wa[169];
float w_avgt[169];
float w_bwwp[169];

// ─── Dummy array for unused fxto columns ───
float a_du[169];

// ═══════════════════════════════════════════════════════════════════
// WEEKLY SUMMARY ARRAYS (7 days)
// ═══════════════════════════════════════════════════════════════════
float wt_hv[8];
float wt_sin1[8];
float wt_sin2[8];
float wt_sin3[8];
float wt_sin4[8];
float wt_he[8];
float wt_wb[8];
float wt_wp[8];
float wt_bwwp[8];
float wt_hwp[8];
float wt_rain[8];
float wt_tmp[8];
float wt_gas[8];
float wt_pgas[8];
float wt_wa[8];
// ═══════════════════════════════════════════════════════════════════
// Zustand
// ═══════════════════════════════════════════════════════════════════
int dotask;        // 1 Woche, 2 Tag, 3 52 Wochen, 4 31 Tage
int dy_loaded;     // Tagesseite geladen
char d_day[32];    // gewaehlter Tag (ISO)
char dy_tag[32];   // fuer diesen Tag geladen
int dy_heute;      // ... an diesem Tag (heuteNr)
int dy_fehlt;      // Monatsdatei fehlt
char dy_datei[40];
int wk_fehlt;
char wk_datei[40];
int wFor;          // w_*  enthaelt: 0 nichts, 1 Woche, 2 52 Wochen, 3 31 Tage
int wtFor;         // wt_* enthaelt: 0 nichts, 1 Woche, 2 Tagesbilanz
int w_heute;       // w_* geladen an diesem Tag
int d52;
int wk_drows;
int d_pos;
int d_cnt;
int db_loaded;
char db_min[32];
char db_max[32];
int db_rows;
int tmp;
int res;
int dres;

char buf[256];
char str[128];
char s1[32];
char s2[32];
char sx1[32];
char sx2[32];
char fnam[48];
char lwstr[80];
char tmin[32];
char tmax[32];
char tval[32];
char dx1[32];

// HWP-Zaehler roh und als Tagesdifferenz (hwpDeltas)
float hraw[400];
float hdel[400];
float wt_phwp[8];
float wt_warm[8];     // HWP + BWWP je Tag (kWh)

// Statuszeile auf der Hauptseite, alle 10 Minuten im TaskLoop neu bestimmt
char stZeile[160] = "Stand wird ermittelt";
int  stNaechste = 0;          // Uptime der naechsten Ermittlung

// Zeilenleser (zum Zusammenfuegen zweier Monate)
char rb[1024]; int rbLen = 0; int rbPos = 0;
char line[800];

// ═══════════════════════════════════════════════════════════════════
// Hilfen
// ═══════════════════════════════════════════════════════════════════
int heuteNr() {
    char t[32];
    timeStamp(t);
    return timeToSecs(t) / 86400;
}

// ISO-Mitternacht -> 23:59 desselben Tages (in place)
void aufTagesende(char t[]) {
    t[11] = '2'; t[12] = '3'; t[14] = '5'; t[15] = '9';
}

// "/e15_JJJJ-MM.txt" zum ISO-Stempel
void monatsDatei(char dst[], char iso[]) {
    char ym[8];
    strSub(ym, iso, 0, 7);
    strcpy(dst, "/e15_");
    strcat(dst, ym);
    strcat(dst, ".txt");
}

// 1, wenn line mit p beginnt (strStartsWith will ein Literal)
int beginntMit(char p[]) {
    int i = 0;
    while (p[i] != 0) { if (line[i] != p[i]) { return 0; } i = i + 1; }
    return 1;
}

int zeileLesen(int h) {
    int n = 0;
    while (1) {
        if (rbPos >= rbLen) {
            rbLen = fileRead(h, rb, 1024); rbPos = 0;
            if (rbLen <= 0) { line[n] = 0; if (n > 0) { return n; } return -1; }
        }
        char c = rb[rbPos]; rbPos = rbPos + 1;
        if (c == '\n') { line[n] = 0; return n; }
        if (c != '\r' && n < 798) { line[n] = c; n = n + 1; }
    }
    return -1;
}

// 15-Minuten-Quelle fuer den Bereich von..bis (ISO) nach fnam. Liegt er in
// einem Monat, ist es dessen Datei. Sonst werden beide Monate nach TMP15
// gefuegt; der Ueberhang des ersten Monats (der 1. des zweiten ab 00:00)
// bleibt dabei weg, sonst kaeme diese Stunde doppelt. 1 = ok, 0 = fehlt
// (fehlende Datei steht dann in fnam).
int quelle15(char von[], char bis[]) {
    char fa[40]; char fb[40];
    monatsDatei(fa, von);
    monatsDatei(fb, bis);
    if (strcmp(fa, fb) == 0) {
        strcpy(fnam, fa);
        return fileExists(fa);
    }
    if (!fileExists(fa)) { strcpy(fnam, fa); return 0; }
    if (!fileExists(fb)) { strcpy(fnam, fb); return 0; }
    // "1.M.JJ " -- so beginnt jede Zeile vom 1. des zweiten Monats
    char mo[4]; char jj[4]; char pref[16];
    strSub(mo, bis, 5, 2);
    strSub(jj, bis, 2, 2);
    sprintf(pref, "1.%d.%s ", strToInt(mo), jj);
    char nl[2] = "\n";
    fileDelete(TMP15);
    int ho = fileOpen(TMP15, "w");
    if (ho < 0) { strcpy(fnam, TMP15); return 0; }
    int hi = fileOpen(fa, "r");
    rbLen = 0; rbPos = 0;
    while (hi >= 0 && zeileLesen(hi) >= 0) {
        if (beginntMit(pref)) { break; }
        fileWrite(ho, line, strlen(line)); fileWrite(ho, nl, 1);
    }
    if (hi >= 0) { fileClose(hi); }
    hi = fileOpen(fb, "r");
    rbLen = 0; rbPos = 0;
    while (hi >= 0 && zeileLesen(hi) >= 0) {
        if (strStartsWith(line, "TSTAMP")) { continue; }
        fileWrite(ho, line, strlen(line)); fileWrite(ho, nl, 1);
    }
    if (hi >= 0) { fileClose(hi); }
    fileClose(ho);
    strcpy(fnam, TMP15);
    return 1;
}

// HWP-Zaehler (Spalte 28 der Tagesdatenbank) roh holen und daraus die
// Differenzen bilden wie fileExtract (hdel[k] = Zeile k - Zeile k-1), aber
// eine 0 ist "HWP aus, kein Wert": sie liefert 0, und der naechste gueltige
// Stand zaehlt ab dem letzten gueltigen. Liefert die Zeilenzahl.
int hwpDeltas(char von[], char bis[]) {
    int fr = fileOpen(MLOG, "r");
    if (fr < 0) { return 0; }
    int rows = fileExtract(fr, von, bis, 28, 1, hraw);
    fileClose(fr);
    float last = 0.0;
    int i = 0;
    while (i < rows) {
        float v = hraw[i];
        float d = 0.0;
        if (v > 0.0) {
            if (last > 0.0 && v >= last) { d = v - last; }
            last = v;
        }
        hdel[i] = d;
        i = i + 1;
    }
    return rows;
}

// Spanne der Tagesdatenbank fuer "Datenbank vom ... bis ..."
void dbSpanne() {
    int fr = fileOpen(MLOG, "r");
    if (fr < 0) { db_loaded = 0; return; }
    db_rows = fileRange(fr, db_min, db_max);
    fileClose(fr);
    timeConvert(db_min, 1);
    strToken(str, db_min, ' ', 1);
    strcpy(db_min, str);
    timeConvert(db_max, 1);
    strToken(str, db_max, ' ', 1);
    strcpy(db_max, str);
    db_loaded = 1;
}

// ═══════════════════════════════════════════════════════════════════
// Laden -- Auszuege wortgleich aus core2_energy.tc, Quelle SD-Karte
// ═══════════════════════════════════════════════════════════════════
void loadDayData() {
    int fr;
    int rows;
    int i;

    if (strlen(d_day) > 0) {
        strcpy(s1, d_day);
    } else {
        timeStamp(s1);
        timeOffset(s1, -1, 1);      // yesterday midnight
    }
    strcpy(s2, s1);
    aufTagesende(s2);               // Core2: .61 bis Folgetag 00:00 ohne Ende

    d_cnt = 0; d_pos = 0;
    dy_fehlt = 0;
    if (quelle15(s1, s2)) {
        fr = fileOpen(fnam, "r");
        if (fr >= 0) {
            // col_offs=1, accum=-1 — starts at WB (first data col in 15-min file)
            rows = fileExtract(fr, s1, s2, 1, -1,
                t_wbx, t_wr1, t_wr2, t_wr3, t_atmp,
                a_du, a_du, a_du, a_du,
                t_ss, t_wr4, t_ni,
                a_du,
                t_wa, t_gas, t_pw,
                a_du, a_du, a_du,
                t_hv,
                a_du, a_du, a_du, t_wpx,
                t_bwwp,
                t_rain, a_du,
                t_hzwp, t_ktmp, a_du);
            fileClose(fr);
            sprintf(buf, "ECH: day rows=%d", rows);
            addLog(buf);

            // Scale counters to watts: value * 4000 (15min → hourly rate)
            d_cnt = rows;
            d_pos = rows;
            i = 1;
            while (i <= rows) {
                t_wbx[i] = t_wbx[i] * 4000.0;
                t_wr1[i] = t_wr1[i] * 4000.0;
                t_wr2[i] = t_wr2[i] * 4000.0;
                t_wr3[i] = t_wr3[i] * 4000.0;
                t_wr4[i] = t_wr4[i] * 4000.0;
                t_ni[i] = t_ni[i] * 4000.0;
                t_wpx[i] = t_wpx[i] * 4000.0;
                t_bwwp[i] = t_bwwp[i] * 4000.0;
                t_hzwp[i] = t_hzwp[i] * 4000.0;
                i = i + 1;
            }
        }
    } else {
        dy_fehlt = 1;
        strcpy(dy_datei, fnam);
    }

    // Energiebilanz des Tages aus der Tagesdatenbank: Zeile des Tages bis
    // Zeile des Folgetags (die Zeile "D 0:00" schreibt .61 um Mitternacht)
    strcpy(sx1, s1);
    strcpy(sx2, sx1);
    timeOffset(sx2, 1, 1);
    fr = fileOpen(MLOG, "r");
    if (fr >= 0) {
        rows = fileExtract(fr, sx1, sx2, 1, -1,
            wt_wb, wt_sin1, wt_sin2, wt_sin3, wt_tmp,
            a_du, a_du, a_du, a_du, a_du,
            wt_sin4, wt_hv, a_du, wt_wa, wt_gas,
            a_du, a_du, a_du, a_du, a_du,
            a_du, a_du, a_du, wt_wp, wt_bwwp, wt_rain, a_du, wt_hwp, a_du, a_du);
        fileClose(fr);
        sprintf(buf, "ECH: bilanz rows=%d", rows);
        addLog(buf);
        if (hwpDeltas(sx1, sx2) > 1) { wt_hwp[1] = hdel[1]; } else { wt_hwp[1] = 0.0; }
        // Ringformel: WebChart(pos 2, count 1) liest [0], der Tag steht in [1]
        wt_hv[0] = wt_hv[1]; wt_wb[0] = wt_wb[1]; wt_sin1[0] = wt_sin1[1];
        wt_sin2[0] = wt_sin2[1]; wt_sin3[0] = wt_sin3[1]; wt_sin4[0] = wt_sin4[1];
        wt_gas[0] = wt_gas[1]; wt_wa[0] = wt_wa[1]; wt_wp[0] = wt_wp[1];
        wt_bwwp[0] = wt_bwwp[1]; wt_hwp[0] = wt_hwp[1]; wt_rain[0] = wt_rain[1];
    }
    wtFor = 2;
    dbSpanne();
}

void loadWeekData() {
    int fr;
    int rows;

    // ── 15-Minuten-Werte, 7 Tage, stuendlich gemittelt ──
    timeStamp(s1);
    timeOffset(s1, -7, 1);       // 7 days ago midnight
    timeStamp(s2);
    timeOffset(s2, -1, 1);
    aufTagesende(s2);            // gestern 23:59
    wk_fehlt = 0;
    dres = 0;
    if (quelle15(s1, s2)) {
        fr = fileOpen(fnam, "r");
        if (fr >= 0) {
            // col_offs=2, accum=-4 (average 4 rows -> hourly)
            rows = fileExtract(fr, s1, s2, 2, -4,
                w_wr1, w_wr2, w_wr3, w_at,
                a_du, a_du, a_du,
                w_sk, w_swa, w_wr4,
                a_du, a_du,
                w_wa, w_gas, w_pwl,
                a_du,
                w_sin,
                a_du,
                w_hv,
                a_du, a_du, a_du, a_du,               // RCAP_a KLI_a PWP_a WP
                w_bwwp,                               // BWWP-Zaehler (x4000 -> W)
                a_du,                                 // RAIN
                w_hwp);                               // HWP_a: Leistung der HWP (W)
            fileClose(fr);
            sprintf(buf, "ECH: 15min rows=%d", rows);
            addLog(buf);

            // Scale solar inverter values: counter increments * 4000 = watts
            int i;
            int maxr;
            maxr = rows;
            if (maxr > 168) { maxr = 168; }
            i = 1;
            while (i <= maxr) {
                w_wr1[i] = w_wr1[i] * 4000.0;
                w_wr2[i] = w_wr2[i] * 4000.0;
                w_wr3[i] = w_wr3[i] * 4000.0;
                w_wr4[i] = w_wr4[i] * 4000.0;
                // Waerme: HWP-Leistung + BWWP (Zaehlerdifferenz -> W) in w_hwp
                w_hwp[i] = w_hwp[i] + w_bwwp[i] * 4000.0;
                i = i + 1;
            }

            // Ringformel: die Linien lesen ab pos 1 -> [1..167], dann [0]. Die
            // 168. Stunde steht in [168]; sie kommt nach [0]. (Der Core2 setzte
            // [0] auf einen Scripter-Zeitversatz und verlor die letzte Stunde.)
            int le;
            le = maxr;
            w_at[0] = w_at[le]; w_sk[0] = w_sk[le]; w_swa[0] = w_swa[le];
            w_pwl[0] = w_pwl[le]; w_hv[0] = w_hv[le]; w_sin[0] = w_sin[le];
            w_wr1[0] = w_wr1[le]; w_wr2[0] = w_wr2[le]; w_wr3[0] = w_wr3[le];
            w_wr4[0] = w_wr4[le]; w_wa[0] = w_wa[le]; w_gas[0] = w_gas[le];
            w_hwp[0] = w_hwp[le];
        }
    } else {
        wk_fehlt = 1;
        strcpy(wk_datei, fnam);
    }

    dbSpanne();

    // Extract weekly data (8 days, delta → 7 rows)
    timeStamp(s1);
    timeOffset(s1, -8, 1);       // 8 days ago midnight
    timeStamp(s2);
    timeOffset(s2, -1, 1);
    aufTagesende(s2);            // gestern 23:59 (Core2: bis heute 00:00 ohne Ende)
    fr = fileOpen(MLOG, "r");
    if (fr >= 0) {
        rows = fileExtract(fr, s1, s2, 1, -1,
            wt_wb, wt_sin1, wt_sin2, wt_sin3, wt_tmp,
            a_du, a_du, a_du, a_du, a_du,
            wt_sin4, wt_hv, a_du, wt_wa, wt_gas,
            a_du, a_du, a_du, a_du, a_du,
            a_du, a_du, a_du, wt_wp, wt_bwwp);
        fileClose(fr);
        wk_drows = rows;
        sprintf(buf, "ECH: daily rows=%d", rows);
        addLog(buf);

        int i;
        i = 1;
        while (i <= rows) {
            wt_pgas[i] = wt_gas[i] * 10.0 * PRICE_GAS_KWH / 100.0;
            i = i + 1;
        }
        // HWP je Tag (Sommer-0 als fehlend) und in Euro zum Strompreis
        int hn;
        hn = hwpDeltas(s1, s2);
        i = 1;
        while (i < 8) {
            wt_hwp[i] = 0.0;
            if (i < hn) { wt_hwp[i] = hdel[i]; }
            wt_warm[i] = wt_hwp[i] + wt_bwwp[i];
            wt_phwp[i] = wt_warm[i] * PRICE_STROM / 100.0;
            i = i + 1;
        }
        // Ringformel: pos 8, count 7 liest 1..6, dann 0 -- der 7. Tag steht in
        // [7] und kommt nach [0] (der Core2 schrieb dort den Wochentag hin)
        if (rows > 1) {
            int le;
            le = rows - 1;
            wt_hv[0] = wt_hv[le]; wt_wb[0] = wt_wb[le]; wt_sin1[0] = wt_sin1[le];
            wt_sin2[0] = wt_sin2[le]; wt_sin3[0] = wt_sin3[le]; wt_sin4[0] = wt_sin4[le];
            wt_wa[0] = wt_wa[le]; wt_gas[0] = wt_gas[le]; wt_wp[0] = wt_wp[le];
            wt_pgas[0] = wt_pgas[le];
            wt_hwp[0] = wt_hwp[le]; wt_phwp[0] = wt_phwp[le];
            wt_bwwp[0] = wt_bwwp[le]; wt_warm[0] = wt_warm[le];
        }
    }
    wFor = 1;
    wtFor = 1;
}

void load52WeekData() {
    int fr;
    int rows;

    fr = fileOpen(MLOG, "r");
    if (fr < 0) { return; }
    fileRange(fr, tmin, tmax);
    fileClose(fr);

    int tmin_s;
    int tmax_s;
    tmin_s = timeToSecs(tmin);
    tmax_s = timeToSecs(tmax);
    int days;
    days = (tmax_s - tmin_s) / 86400;
    days = days - (days % 7);
    d52 = days / 7;
    if (d52 > 52) { d52 = 52; }

    int ndays;
    ndays = d52 * 7 + 1;
    timeStamp(s1);
    timeOffset(s1, -ndays, 1);
    timeStamp(s2);
    timeOffset(s2, -1, 1);
    aufTagesende(s2);

    // Title
    strcpy(dx1, s1);
    timeConvert(dx1, 1);
    sprintf(lwstr, "%d Wochen seit ", d52);
    strcat(lwstr, dx1);

    fr = fileOpen(MLOG, "r");
    if (fr >= 0) {
        rows = fileExtract(fr, s1, s2, 1, -7,
            w_pwl, w_wr1, w_wr2, w_wr3,
            a_du, a_du, a_du, a_du, a_du, a_du,
            w_wr4, w_sin, w_swa, w_wa, w_gas,
            a_du, a_du, a_du, a_du, a_du,
            a_du, a_du, a_du,
            w_at, w_bwwp, w_rain,
            a_du, w_hwp, a_du, w_avgt);
        fileClose(fr);
        d52 = rows;

        // HWP neu, Sommer-0 als fehlend: Wochenmittel wie fileExtract (-7)
        {
            int hn;
            hn = hwpDeltas(s1, s2);
            int j; int k; int c; float sum;
            j = 1; k = 1; c = 0; sum = 0.0;
            while (k < hn) {
                sum = sum + hdel[k]; c = c + 1;
                if (c == 7) { w_hwp[j] = sum / 7.0; j = j + 1; sum = 0.0; c = 0; }
                k = k + 1;
            }
            if (c > 0) { w_hwp[j] = sum / c; }
        }

        int i;
        i = 1;
        while (i < rows) {
            w_pgas[i] = w_gas[i] * 10.0 * PRICE_GAS_KWH / 100.0;
            i = i + 1;
        }
        // Ringformel: pos d52, count d52-1 liest 1..d52-2, dann 0 -- die
        // letzte Woche [d52-1] kommt nach [0] (der Core2 loeschte [0])
        if (d52 > 1) {
            int le;
            le = d52 - 1;
            w_wr1[0] = w_wr1[le]; w_wr2[0] = w_wr2[le]; w_wr3[0] = w_wr3[le]; w_wr4[0] = w_wr4[le];
            w_pwl[0] = w_pwl[le]; w_at[0] = w_at[le]; w_sin[0] = w_sin[le]; w_swa[0] = w_swa[le];
            w_wa[0] = w_wa[le]; w_gas[0] = w_gas[le]; w_bwwp[0] = w_bwwp[le]; w_rain[0] = w_rain[le];
            w_hwp[0] = w_hwp[le]; w_avgt[0] = w_avgt[le]; w_pgas[0] = w_pgas[le];
        }
    }
    wFor = 2;
}

void load31DayData() {
    int fr;
    int rows;

    fr = fileOpen(MLOG, "r");
    if (fr < 0) { return; }
    fileRange(fr, tmin, tmax);
    fileClose(fr);

    int tmin_s;
    int tmax_s;
    tmin_s = timeToSecs(tmin);
    tmax_s = timeToSecs(tmax);
    int days;
    days = (tmax_s - tmin_s) / 86400;
    if (days > 31) { days = 31; }
    d52 = days;

    int ndays;
    ndays = days + 2;
    timeStamp(s1);
    timeOffset(s1, -ndays, 1);
    timeStamp(s2);
    timeOffset(s2, -1, 1);
    aufTagesende(s2);

    strcpy(dx1, s1);
    timeConvert(dx1, 1);
    sprintf(lwstr, "%d Tage seit ", days);
    strcat(lwstr, dx1);

    fr = fileOpen(MLOG, "r");
    if (fr >= 0) {
        rows = fileExtract(fr, s1, s2, 1, -1,
            w_pwl, w_wr1, w_wr2, w_wr3,
            a_du, a_du, a_du, a_du, a_du, a_du,
            w_wr4, w_sin, w_swa, w_wa, w_gas,
            a_du, a_du, a_du, a_du, a_du,
            a_du, a_du, a_du,
            w_at, w_bwwp, w_rain,
            a_du, w_hwp, a_du, w_atmp);
        fileClose(fr);
        d52 = rows;
        if (d52 > days + 1) { d52 = days + 1; }

        // HWP neu, Sommer-0 als fehlend (vor w_gwp = BWWP + HWP)
        {
            int hn;
            hn = hwpDeltas(s1, s2);
            int k;
            k = 1;
            while (k < rows) {
                w_hwp[k] = 0.0;
                if (k < hn) { w_hwp[k] = hdel[k]; }
                k = k + 1;
            }
        }

        w_wr1[0] = 0.0; w_wr2[0] = 0.0; w_wr3[0] = 0.0; w_wr4[0] = 0.0;
        w_pwl[0] = 0.0; w_at[0] = 0.0; w_sin[0] = 0.0; w_swa[0] = 0.0;
        w_wa[0] = 0.0; w_gas[0] = 0.0; w_bwwp[0] = 0.0; w_rain[0] = 0.0;
        w_hwp[0] = 0.0; w_atmp[0] = 0.0; w_gwp[0] = 0.0; w_sk[0] = 0.0;

        int i;
        i = 1;
        while (i < rows) {
            w_gwp[i] = w_bwwp[i] + w_hwp[i];
            w_sk[i] = w_wr1[i] + w_wr2[i] + w_wr3[i];
            i = i + 1;
        }
        // Die JUENGSTEN Tage zeigen: der Auszug liefert einen Tag mehr, als
        // die Seite zeigt, und die Kappung oben schnitt das Ende ab -- auf dem
        // Core2 fehlte so immer der Vortag. Das Fenster rueckt ans Ende.
        int off;
        off = rows - d52;
        if (off > 0) {
            int k;
            k = 1;
            while (k < d52) {
                int q;
                q = k + off;
                w_wr1[k] = w_wr1[q]; w_wr2[k] = w_wr2[q]; w_wr3[k] = w_wr3[q]; w_wr4[k] = w_wr4[q];
                w_pwl[k] = w_pwl[q]; w_at[k] = w_at[q]; w_sin[k] = w_sin[q]; w_swa[k] = w_swa[q];
                w_wa[k] = w_wa[q]; w_gas[k] = w_gas[q]; w_bwwp[k] = w_bwwp[q]; w_rain[k] = w_rain[q];
                w_hwp[k] = w_hwp[q]; w_atmp[k] = w_atmp[q]; w_gwp[k] = w_gwp[q]; w_sk[k] = w_sk[q];
                k = k + 1;
            }
            strcpy(dx1, s1);
            timeOffset(dx1, off, 1);
            timeConvert(dx1, 1);
            sprintf(lwstr, "%d Tage seit ", days);
            strcat(lwstr, dx1);
        }
        // Ringformel: letzter Tag [d52-1] zusaetzlich nach [0]
        if (d52 > 1) {
            int le;
            le = d52 - 1;
            w_wr1[0] = w_wr1[le]; w_wr2[0] = w_wr2[le]; w_wr3[0] = w_wr3[le]; w_wr4[0] = w_wr4[le];
            w_pwl[0] = w_pwl[le]; w_at[0] = w_at[le]; w_sin[0] = w_sin[le]; w_swa[0] = w_swa[le];
            w_wa[0] = w_wa[le]; w_gas[0] = w_gas[le]; w_bwwp[0] = w_bwwp[le]; w_rain[0] = w_rain[le];
            w_hwp[0] = w_hwp[le]; w_atmp[0] = w_atmp[le]; w_gwp[0] = w_gwp[le]; w_sk[0] = w_sk[le];
        }
    }
    wFor = 3;
}

// ═══════════════════════════════════════════════════════════════════
// TaskLoop -- Dateiarbeit nicht im Webaufruf
// ═══════════════════════════════════════════════════════════════════
// Wie weit reicht die Tagesdatenbank, wie viele Monate liegen lokal?
// (auch der Fortschritt von EBAKALL). Liest nur Verzeichniseintraege und
// einmal die Spanne der Tagesdatenbank.
void statusErmitteln() {
    char ts[24]; timeStamp(ts);
    int yj = (ts[0]-'0')*1000 + (ts[1]-'0')*100 + (ts[2]-'0')*10 + (ts[3]-'0');
    int ym = (ts[5]-'0')*10 + (ts[6]-'0');
    int j = 2021; int m = 12; int da = 0; int alle = 0;
    char f[40];
    while (j < yj || (j == yj && m <= ym)) {
        sprintf(f, "/e15_%04d-%02d.txt", j, m);
        if (fileExists(f)) { da = da + 1; }
        alle = alle + 1;
        m = m + 1; if (m > 12) { m = 1; j = j + 1; }
    }
    dbSpanne();
    if (db_loaded == 1) {
        sprintf(stZeile, "Tagesdatenbank %s bis %s &middot; 15-Minuten-Monate lokal %d von %d", db_min, db_max, da, alle);
    } else {
        sprintf(stZeile, "Tagesdatenbank fehlt &middot; 15-Minuten-Monate lokal %d von %d", da, alle);
    }
}

void TaskLoop() {
    while (1) {
        if (dotask == 0 && tasm_uptime >= stNaechste) {
            statusErmitteln();
            stNaechste = tasm_uptime + 600;
        }
        if (dotask == 1) {
            loadWeekData();
            w_heute = heuteNr();
            dotask = 0;
        }
        if (dotask == 2) {
            loadDayData();
            strcpy(dy_tag, d_day);
            dy_heute = heuteNr();
            dy_loaded = 1;
            dotask = 0;
        }
        if (dotask == 3) {
            load52WeekData();
            w_heute = heuteNr();
            dotask = 0;
        }
        if (dotask == 4) {
            load31DayData();
            w_heute = heuteNr();
            dotask = 0;
        }
        delay(500);
    }
}

// ═══════════════════════════════════════════════════════════════════
// WEB — Chart pages (WebUI callback, rendered at /tc_ui?p=N)
// ═══════════════════════════════════════════════════════════════════
void WebUI() {
    int pg;
    pg = webPage();

    // Guard: if TaskLoop is loading data, don't touch shared buffers
    if (dotask != 0) {
        webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';</script>");
        webSend("<h3>Daten werden geladen...</h3>");
        return;
    }

    if (pg == 0) {
        // ─── Page 0: Daily charts (data from collector DB) ───

        // Check for date parameter from date picker
        res = webArg("d", tval);
        if (res > 0) {
            strToken(str, tval, 'T', 1);
            strcpy(buf, str);
            buf += "T00:00:00";
            if (strcmp(buf, d_day) != 0) {
                strcpy(d_day, buf);
                dy_loaded = 0;
                dotask = 2;
                webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';</script>");
                webSend("<h3>Daten werden geladen...</h3>");
                return;
            }
        }

        // If no day selected yet, default to yesterday
        if (strlen(d_day) == 0) {
            timeStamp(d_day);
            timeOffset(d_day, -1, 1);
        }
        // Veraltet? (anderer Tag, Bilanzfelder von der Woche belegt, oder
        // seit dem Laden ist Mitternacht vorbei und das Backup brachte Neues)
        if (dy_loaded == 1 && (strcmp(d_day, dy_tag) != 0 || wtFor != 2 || dy_heute != heuteNr())) {
            dy_loaded = 0;
        }

        webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';</script>");
        webSend("<h3>Tages Verlauf</h3>");
        webSend("<center>");
        // Date picker first
        strToken(buf, d_day, 'T', 1);
        webSend("<label><b>Tag: </b><input type='date' value='");
        webSend(buf);
        webSend("' style='width:200px' onchange='window.location=\"/tc_ui?p=0&d=\"+this.value+\"T00:00\"'>");
        webSend("</label><br><br>");
        // Database range
        if (db_loaded == 1) {
            webSend("Datenbank vom ");
            webSend(db_min);
            webSend(" bis ");
            webSend(db_max);
            webSend("<br><br>");
        }
        // Selected date
        strcpy(str, d_day);
        timeConvert(str, 1);
        strToken(buf, str, ' ', 1);
        webSend("<b>Daten vom ");
        webSend(buf);
        webSend("</b>");
        webSend("</center><br>");

        if (dy_loaded == 1) {
            // Stop AJAX polling — charts are ready
            webSend("<script>rfsh=0;clearTimeout(lt);</script>");
            if (dy_fehlt == 1) {
                webSend("<center><b style='color:orange'>F&uuml;r diesen Tag liegen auf der SD-Karte noch keine 15-Minuten-Werte (");
                webSend(dy_datei);
                webSend(") &ndash; der Monat ist noch nicht gesichert.</b></center><br>");
            }
            // Set time base so x-axis shows 0:00-24:00 of the selected day
            // time_base = (d_day_midnight - now_epoch) / 60 + (count-1)*15
            timeStamp(sx1);  // now
            int now_s;
            int day_s;
            now_s = timeToSecs(sx1);
            day_s = timeToSecs(d_day);
            int tbase;
            tbase = (day_s - now_s) / 60 + (d_cnt - 1) * 15;
            WebChartTimeBase(tbase);
            // Chart 1: PV power — decimals bit3=smooth (0+8=8)
            WebChart('l', "Photovoltaik - kWh", "Dach|W", 0x0000FF, d_pos, d_cnt, t_wr4, 8, 15, 0.0, 4000.0);
            WebChart('l', "", "Garage|W", 0xFF0000, d_pos, d_cnt, t_wr1, 8, 15, 0.0, 4000.0);
            WebChart('l', "", "Garten Haus|W", 0xFF8800, d_pos, d_cnt, t_wr2, 8, 15, 0.0, 4000.0);
            WebChart('l', "", "Garten|W", 0x00AA00, d_pos, d_cnt, t_wr3, 8, 15, 0.0, 4000.0);
            // Chart 2: Grid + House
            WebChart('l', "Verbrauch - kWh", "Netz|W", 0x0088FF, d_pos, d_cnt, t_ni, 8, 15, 0.0, 5000.0);
            WebChart('l', "", "Haus|W", 0xFF0000, d_pos, d_cnt, t_hv, 8, 15, 0.0, 5000.0);
            // Chart 3: Powerwall charge
            WebChart('l', "Powerwall - %", "Powerwall|%", 0x00CC00, d_pos, d_cnt, t_pw, 8, 15, 0.0, 100.0);
            // Chart 4: Temperatures (1 decimal + smooth = 9)
            WebChart('l', "Solarthermie - C", "Aussen Temperatur|C", 0x0088FF, d_pos, d_cnt, t_atmp, 9, 15, 0.0, 60.0);
            WebChart('l', "", "Solar Speicher|C", 0xCC0000, d_pos, d_cnt, t_ss, 9, 15, 0.0, 60.0);
            WebChart('l', "", "Keller Temperatur|C", 0xFF8800, d_pos, d_cnt, t_ktmp, 9, 15, 0.0, 60.0);
            // Chart 5: Gas / Wasser (2 decimals + smooth = 10)
            WebChart('l', "Gas - Wasser - m3", "Gas|m3", 0xFF0000, d_pos, d_cnt, t_gas, 10, 15, 0.0, 0.3);
            WebChart('l', "", "Wasser|m3", 0x0088FF, d_pos, d_cnt, t_wa, 10, 15, 0.0, 0.3);
            // Chart 6: Heizung / Brauchwasser WP (smooth)
            WebChart('l', "Heizung - Brauchwasser - WP - W", "HWP|W", 0x0088FF, d_pos, d_cnt, t_hzwp, 8, 15, 0.0, 3000.0);
            WebChart('l', "", "BWWP|W", 0xFF0000, d_pos, d_cnt, t_bwwp, 8, 15, 0.0, 3000.0);
            WebChartTimeBase(0);  // reset for non-time charts
            // Energiebilanz — from daily log (wt_* arrays, matching Scripter #gload)
            // wt_hv[1]=Haus(ZWZI), wt_wb[1]=Wallbox, wt_sin1[1]=Garage,
            // wt_sin2[1]=G-Haus, wt_sin3[1]=Garten (missing, use 0),
            // wt_sin4[1]=Dach, wt_gas[1]=Gas, wt_wa[1]=Wasser,
            // wt_wp[1]=Klima, wt_bwwp[1]=BWWP, wt_hwp[1]=HWP, wt_rain[1]=Regen
            // Gas € = Gas * 10 * PRICE_GAS_KWH / 100
            // Column chart — uses wt_* arrays directly, pos=2 count=1 reads [1]
            WebChart('c', "Energiebilanz", "Haus", 0x0000FF, 2, 1, wt_hv, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Wallbox", 0x8800FF, 2, 1, wt_wb, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Garage", 0xFF0000, 2, 1, wt_sin1, 2, 0, 0.0, 0.0);
            WebChart('c', "", "G-Haus", 0xFF8800, 2, 1, wt_sin2, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Garten", 0x00AA00, 2, 1, wt_sin3, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Dach", 0x00CCFF, 2, 1, wt_sin4, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Gas", 0xFF00FF, 2, 1, wt_gas, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Wasser", 0x0088FF, 2, 1, wt_wa, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Klima", 0x888800, 2, 1, wt_wp, 2, 0, 0.0, 0.0);
            WebChart('c', "", "BWWP", 0xCC0000, 2, 1, wt_bwwp, 2, 0, 0.0, 0.0);
            WebChart('c', "", "HWP", 0x0088AA, 2, 1, wt_hwp, 2, 0, 0.0, 0.0);
            WebChart('c', "", "Regen", 0x00AA88, 2, 1, wt_rain, 2, 0, 0.0, 0.0);
            // Table — use a_du for transposed layout (copy wt values + Gas €)
            a_du[1] = wt_hv[1];
            a_du[2] = wt_wb[1];
            a_du[3] = wt_sin1[1];
            a_du[4] = wt_sin2[1];
            a_du[5] = wt_sin3[1];
            a_du[6] = wt_sin4[1];
            a_du[7] = wt_gas[1];
            a_du[8] = wt_gas[1] * 10.0 * PRICE_GAS_KWH / 100.0;
            a_du[9] = wt_wa[1];
            a_du[10] = wt_wp[1];
            a_du[11] = wt_bwwp[1];
            a_du[12] = wt_hwp[1];
            a_du[13] = wt_rain[1];
            a_du[0] = a_du[13];     // Ringformel: pos 14, count 13 liest 1..12, dann 0
            WebChart('t', "Energiebilanz|Haus|Wallbox|Garage|G-Haus|Garten|Dach|Gas|Gas E|Wasser|Klima|BWWP|HWP|Regen", "Bilanz", 0, 14, 13, a_du, 2, 0, 0.0, 0.0);
        } else {
            dotask = 2;
            webSend("<p>Daten werden geladen...</p>");
        }
    }
    if (pg == 1) {
        // ─── Page 1: Letzte Woche (7-day hourly line charts) ───
        webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';rfsh=0;clearTimeout(lt);</script>");
        webSend("<h3>Letzte Woche</h3>");
        if (db_loaded == 1) {
            webSend("<center>Datenbank vom ");
            webSend(db_min);
            webSend(" bis ");
            webSend(db_max);
            webSend("</center><br>");
        }
        if (wFor == 1 && wtFor == 1 && w_heute == heuteNr() && dotask == 0) {
            // Chart 1: Energy balance column chart
            if (wk_fehlt == 1) {
                webSend("<center><b style='color:orange'>15-Minuten-Werte der Woche fehlen auf der SD-Karte (");
                webSend(wk_datei);
                webSend(").</b></center><br>");
            }
            // pos=wk_drows reads last 7 deltas, tbase=-1440 shifts labels to yesterday
            WebChartTimeBase(-1440);
            WebChart('c', "Energie Bilanz", "Netz", 0xFF0000, wk_drows, 7, wt_hv, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "Wallbox", 0x0088FF, wk_drows, 7, wt_wb, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "Garage", 0x00AA00, wk_drows, 7, wt_sin1, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "G-Haus", 0x008800, wk_drows, 7, wt_sin2, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "Garten", 0x888800, wk_drows, 7, wt_sin3, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "Dach", 0xFF8800, wk_drows, 7, wt_sin4, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "WP", 0x8800FF, wk_drows, 7, wt_wp, 1, 1440, 0.0, 0.0);
            WebChart('c', "", "Wärme", 0xCC0000, wk_drows, 7, wt_warm, 1, 1440, 0.0, 0.0);
            WebChartTimeBase(0);
            // Chart 2: Powerwall & Temperatures (dual y-axis: 0-100% left, 0-60°C right)
            // decimals: 0+8=8 (smooth), 1+8=9 (1 decimal + smooth)
            WebChart('l', "Powerwall / Temperaturen", "Powerwall|%", 0x00CC00, 1, 168, w_pwl, 8, 60, 0.0, 100.0);
            WebChart('l', "", "Aussentemperatur|C", 0xFF6600, 1, 168, w_at, 9, 60, 0.0, 60.0);
            WebChart('l', "", "Kollektortemperatur|C", 0x0000FF, 1, 168, w_sk, 9, 60, 0.0, 60.0);
            WebChart('l', "", "Solarspeicher|C", 0xCC0000, 1, 168, w_swa, 9, 60, 0.0, 60.0);
            // Chart 3: Power consumption & solar yield (smooth)
            WebChart('l', "Netzverbrauch / Solarertrag", "Netz|W", 0xFF0000, 1, 168, w_hv, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Solar Input|W", 0x00AA00, 1, 168, w_sin, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Garage|W", 0xFF8800, 1, 168, w_wr1, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "GHaus|W", 0x0000FF, 1, 168, w_wr2, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Garten|W", 0x888800, 1, 168, w_wr3, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Dach|W", 0x8800FF, 1, 168, w_wr4, 8, 60, 0.0, 8000.0);
            // Chart 4: Gas & Water (2 decimals + smooth = 10), dazu die HWP-Leistung
            // auf eigener Achse (anderer Bereich = zweite y-Achse)
            WebChart('l', "Gas / Wasser / Wärmepumpen", "Gas|m3", 0xFF0000, 1, 168, w_gas, 10, 60, 0.0, 0.3);
            WebChart('l', "", "Wasser|m3", 0x0088FF, 1, 168, w_wa, 10, 60, 0.0, 0.3);
            WebChart('l', "", "Wärmepumpen|W", 0xFF8800, 1, 168, w_hwp, 8, 60, 0.0, 3000.0);
            // Chart 5: Energy balance table (dynamic weekday labels via JS rotation)
            WebChart('t', "Tag|So|Mo|Di|Mi|Do|Fr|Sa", "Haus", 0x000000, wk_drows, 7, wt_hv, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wallbox", 0x000000, wk_drows, 7, wt_wb, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Garage", 0x000000, wk_drows, 7, wt_sin1, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gartenhaus", 0x000000, wk_drows, 7, wt_sin2, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Garten", 0x000000, wk_drows, 7, wt_sin3, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Dach", 0x000000, wk_drows, 7, wt_sin4, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gas", 0x000000, wk_drows, 7, wt_gas, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gas E", 0x000000, wk_drows, 7, wt_pgas, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wasser", 0x000000, wk_drows, 7, wt_wa, 2, 0, 0.0, 0.0);
            WebChart('t', "", "WP", 0x000000, wk_drows, 7, wt_wp, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wärme", 0x000000, wk_drows, 7, wt_warm, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wärme E", 0x000000, wk_drows, 7, wt_phwp, 2, 0, 0.0, 0.0);
            // Rotate table weekday labels to start from today's weekday
            // c.lb = [So,Mo,Di,Mi,Do,Fr,Sa] → rotate by getDay() positions
            webSend("<script>(function(){var c=_tcC[_tcC.length-1];if(c&&c.lb){var w=new Date().getDay();var o=c.lb.slice();for(var i=0;i<7;i++)c.lb[i]=o[(w+i)%7];}})()</script>");
        } else {
            dotask = 1;
            // wie 52 Wochen / 31 Tage: neu laden, sonst bleibt die Seite hier
            // stehen (oben wurde das Nachladen schon abgeschaltet)
            webSend("<meta http-equiv='refresh' content='3'>");
            webSend("<p>Daten werden geladen...</p>");
        }
    }
    if (pg == 2) {
        // ─── Page 2: 52 Wochen (yearly overview, weekly aggregated) ───
        webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';rfsh=0;clearTimeout(lt);</script>");
        webSend("<h3>52 Wochen</h3>");
        if (wFor == 2 && w_heute == heuteNr() && dotask == 0) {
            // Title with date range
            webSend("<center>");
            webSend(lwstr);
            webSend("</center><br>");

            // Chart 1: Solar inverters (stacked column) — Garage, G-Haus, Garten, Dach
            // pos=d52, count=d52-1: skip [0] (accumulated start value), show [1..d52-1]
            WebChart('s', "Solar Ertrag kWh", "Garage", 0x4285F4, d52, d52-1, w_wr1, 1, 10080, 0.0, 80.0);
            WebChart('s', "", "G-Haus", 0xEA4335, d52, d52-1, w_wr2, 1, 10080, 0.0, 80.0);
            WebChart('s', "", "Garten", 0xFBBC04, d52, d52-1, w_wr3, 1, 10080, 0.0, 80.0);
            WebChart('s', "", "Dach", 0x34A853, d52, d52-1, w_wr4, 1, 10080, 0.0, 80.0);

            // Chart 2: Wallbox, WPumpen, HWP
            WebChart('s', "Wallbox / WPumpen kWh", "WBox", 0x4285F4, d52, d52-1, w_pwl, 1, 10080, 0.0, 30.0);
            WebChart('s', "", "Wpumpe", 0xEA4335, d52, d52-1, w_at, 1, 10080, 0.0, 30.0);
            WebChart('s', "", "HWP", 0xFBBC04, d52, d52-1, w_hwp, 1, 10080, 0.0, 30.0);

            // Chart 3: Verbrauch / Einspeisung
            WebChart('s', "Verbrauch / Einspeisung kWh", "Verbrauch", 0x4285F4, d52, d52-1, w_sin, 1, 10080, 0.0, 60.0);
            WebChart('s', "", "Einsp", 0x34A853, d52, d52-1, w_swa, 1, 10080, 0.0, 60.0);

            // Chart 4: Gas, Wasser, HWP (line chart)
            WebChart('l', "Gas / Wasser / HWP", "Gas", 0x4285F4, d52, d52-1, w_gas, 8, 10080, 0.0, 40.0);
            WebChart('l', "", "Wasser", 0xEA4335, d52, d52-1, w_wa, 8, 10080, 0.0, 40.0);
            WebChart('l', "", "HWP", 0xFBBC04, d52, d52-1, w_hwp, 8, 10080, 0.0, 40.0);

            // Table: all weekly data (skip [0])
            WebChart('t', "Woche", "Garage", 0x000000, d52, d52-1, w_wr1, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gartenhaus", 0x000000, d52, d52-1, w_wr2, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Garten", 0x000000, d52, d52-1, w_wr3, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Dach", 0x000000, d52, d52-1, w_wr4, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wallbox", 0x000000, d52, d52-1, w_pwl, 2, 0, 0.0, 0.0);
            WebChart('t', "", "WPumpen", 0x000000, d52, d52-1, w_at, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Verbrauch", 0x000000, d52, d52-1, w_sin, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Einspeisung", 0x000000, d52, d52-1, w_swa, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gas", 0x000000, d52, d52-1, w_gas, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gas E", 0x000000, d52, d52-1, w_pgas, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wasser", 0x000000, d52, d52-1, w_wa, 2, 0, 0.0, 0.0);
            WebChart('t', "", "D-temp", 0x000000, d52, d52-1, w_avgt, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Regen", 0x000000, d52, d52-1, w_rain, 2, 0, 0.0, 0.0);
            WebChart('t', "", "HWP", 0x000000, d52, d52-1, w_hwp, 2, 0, 0.0, 0.0);
            WebChart('t', "", "BWWP", 0x000000, d52, d52-1, w_bwwp, 2, 0, 0.0, 0.0);

            // Replace table first-column labels with week numbers (0,1,2...)
            webSend("<script>(function(){var c=_tcC[_tcC.length-1];if(c&&c.lb){for(var i=0;i<c.lb.length;i++)c.lb[i]=i;}})()</script>");
        } else {
            dotask = 3;
            webSend("<meta http-equiv='refresh' content='3'>");
            webSend("<p>Daten werden geladen...</p>");
        }
    }
    if (pg == 3) {
        // ─── Page 3: Andere Geräte ───
        webSend("<style>button{border:0;border-radius:0.3rem;background:#1fa3ec;color:white;line-height:1.4rem;font-size:1.2rem;width:400px;transition-duration:0.4s;cursor:pointer;height:45px;}</style>");
        webSend("<div style='text-align:center'>");
        webSend("<h3>Andere Ger&auml;te</h3>");
        // Rolladen (hardcoded static IPs)
        webSend("<p><form action='http://192.168.188.22' method='get'><button>Rolladen Wohnzimmer Veranda</button></form></p>");
        webSend("<p><form action='http://192.168.188.29' method='get'><button>Rolladen Wohnzimmer vorne</button></form></p>");
        webSend("<p><form action='http://192.168.188.35' method='get'><button>Rolladen AZ Heidrun</button></form></p>");
        webSend("<p><form action='http://192.168.188.40' method='get'><button>Rolladen K&uuml;che</button></form></p>");
        webSend("<p><form action='http://192.168.188.41' method='get'><button>Rolladen Flur</button></form></p>");
        // Dynamic IPs from UDP
        webSend("<p><form action='http://");
        webSend(sug_ip);
        webSend("' method='get'><button>Schaltuhr Garage</button></form></p>");
        webSend("<p><form action='http://192.168.188.33' method='get'><button>Netstream Power</button></form></p>");
        // Dynamic IP from UDP
        webSend("<p><form action='http://");
        webSend(tv2_ip);
        webSend("' method='get'><button>Schlafzimmer G</button></form></p>");
        webSend("</div>");
    }
    if (pg == 4) {
        // ─── Page 4: Heizung Verlauf letzte 31 Tage ───
        webSend("<script>var tt=document.querySelector(\"div[style*='c_ttl']\");if(tt)tt.style.display='none';rfsh=0;clearTimeout(lt);</script>");
        webSend("<h3>Heizung Verlauf</h3>");
        if (wFor == 3 && w_heute == heuteNr() && dotask == 0) {
            // Title with date range
            webSend("<center>");
            webSend(lwstr);
            webSend("</center><br>");
            // Wrap chart+table in consistent-width container
            webSend("<div style='max-width:960px;margin:0 auto'>");

            // Chart: dual-axis line — D-Temp (left) + HWP (right)
            // Different min/max triggers dual y-axis in WebChart
            WebChart('l', "D-Temp / Heizung WP", "D-TMP", 0x4285F4, d52, d52-1, w_atmp, 8, 1440, -10.0, 35.0);
            WebChart('l', "", "HWP", 0xFF0000, d52, d52-1, w_gwp, 8, 1440, 0.0, 50.0);

            // Table: 14 columns, skip [0]
            WebChart('t', "Tag", "Garage", 0x000000, d52, d52-1, w_wr1, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gartenhaus", 0x000000, d52, d52-1, w_wr2, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Garten", 0x000000, d52, d52-1, w_wr3, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Dach", 0x000000, d52, d52-1, w_wr4, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wallbox", 0x000000, d52, d52-1, w_pwl, 2, 0, 0.0, 0.0);
            WebChart('t', "", "WPumpen", 0x000000, d52, d52-1, w_at, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Summe WR", 0x000000, d52, d52-1, w_sk, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Verbrauch", 0x000000, d52, d52-1, w_sin, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Einspeisung", 0x000000, d52, d52-1, w_swa, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Wasser", 0x000000, d52, d52-1, w_wa, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Gas", 0x000000, d52, d52-1, w_gas, 2, 0, 0.0, 0.0);
            WebChart('t', "", "D-Temp", 0x000000, d52, d52-1, w_atmp, 2, 0, 0.0, 0.0);
            WebChart('t', "", "HWP", 0x000000, d52, d52-1, w_gwp, 2, 0, 0.0, 0.0);
            WebChart('t', "", "Regen", 0x000000, d52, d52-1, w_rain, 2, 0, 0.0, 0.0);

            // Counter-based row labels (0, 1, 2, ...)
            webSend("<script>(function(){var c=_tcC[_tcC.length-1];if(c&&c.lb){for(var i=0;i<c.lb.length;i++)c.lb[i]=i;}})()</script>");
            webSend("</div>");
        } else {
            dotask = 4;
            webSend("<meta http-equiv='refresh' content='3'>");
            webSend("<p>Daten werden geladen...</p>");
        }
    }
}
// Statuszeile auf der Hauptseite
void WebCall() {
    webSend("{s}Energie-Grafiken{m}");
    webSend(stZeile);
    webSend("{e}");
}

int main() {
    dotask = 0;
    dy_loaded = 0;
    wFor = 0;
    wtFor = 0;
    d52 = 0;
    d_pos = 0;
    d_cnt = 0;
    db_loaded = 0;

    webPageLabel(0, "Tages Verlauf");
    webPageLabel(1, "Letzte Woche");
    webPageLabel(2, "52 Wochen");
    webPageLabel(3, "Andere Ger&auml;te");
    webPageLabel(4, "Heizung 31 Tage");
    return 0;
}