Zum Inhalt

core2_energy.tc

Core2 Energy Monitoring System — PHASE 5: global keyword

Source on GitHub

// ═══════════════════════════════════════════════════════════════════
// Core2 Energy Monitoring System — PHASE 5: global keyword
// Converted from Tasmota Scripter: core2_script_dev.txt
// Uses 'global float' for automatic UDP variable updates (V5 binary)
// ═══════════════════════════════════════════════════════════════════

// ─── Energy price constants ───
#define PRICE_GAS_KWH  12     // cents per kWh (gas: cbm * 9.9 = kwh)
#define PRICE_STROM    40     // cents per kWh electricity

// ─── Display colors (RGB565) ───
#define COL_BLACK   0x0000
#define COL_WHITE   0xFFFF
#define COL_RED     0xF800
#define COL_GREEN   0x07E0
#define COL_BLUE    0x001F
#define COL_YELLOW  0xFFE0
#define COL_CYAN    0x07FF
#define COL_MAGENTA 0xF81F

// ═══════════════════════════════════════════════════════════════════
// GLOBAL VARIABLES — UDP auto-update (received from other Tasmota devices)
// 'global float' vars are automatically updated by the VM when UDP packets arrive
// ═══════════════════════════════════════════════════════════════════

// Solar totals and current power
global float sedt;
global float sedc;
global float atmp;
global float scol;
global float ssp;
global float wrgh;
global float wrgg;
global float wrga;
global float auto;
global float t_ga;
global float t_gh;
global float t_gg;
global float t_wb;
global float t_ws;
global float t_gs;
int bw_ww;
global float zwzi;
global float zwzo;
global float zwzc;
global float pwl;
global float sip;
global float sop;
global float bip;
global float hip;
global float tcap;
global float rcap;
global float rper;
global float wtemp;
global float whumi;
global float wtvoc;
global float wco2;
global float aztemp;
global float azhumi;
global float aztvoc;
global float azeco2;
global float bpress;
global float btemp;
global float bhumi;
global float klima;
global float pwp;
global float t_kpwp;
global float bwwp;
global float bwtwp;
global float hwp;
global float t_hwp;
global float train;
global float hrain;
global float avgt;
global float ktmp;
global float shtemp;
global float phs1;
global float phs2;
global float phs3;
global float gerr;

// ─── Device IP strings ───
// Only tv2_ip and sug_ip arrive via UDP; rest are hardcoded (static IPs)
global char tv2_ip[16];
global char sug_ip[16];

// ═══════════════════════════════════════════════════════════════════
// PERSIST VARIABLES — survive reboot
// ═══════════════════════════════════════════════════════════════════
persist int hwp_m;
persist int zrz_m;
persist int auto_m;
persist int gas_m;
persist int was_m;
persist int rain_m;
persist int audvol;   // audio volume 0-100, survives reboot

// ═══════════════════════════════════════════════════════════════════
// TIME-SERIES ARRAYS for daily charts (96 = 15-min intervals per day)
// Must be float[] — WebChart reads array elements as IEEE-754 floats
// ═══════════════════════════════════════════════════════════════════
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];

// ─── Live streaming array ───
int array[5];

// ─── Heat pump average tracking ───
int hwp_avg[61];

// ═══════════════════════════════════════════════════════════════════
// LOCAL STATE VARIABLES
// ═══════════════════════════════════════════════════════════════════
int tmp;
int res;
int cnt;
int hr;
int fr;
int fsiz;
int dotask;
int wk_loaded;
int dy_loaded;
int dl_done;       // files downloaded flag (skip re-download)
int tim;
int mn;
int extra;
int once;
int bflg;
int tdur;
int xtime;
int dres;
int dr;
int start;
int stop;
int d52;
int hwp_alarm;
int alo;
int batt;
int stemp;
int sens;
int alarm;
int batt_prev;    // previous battery % for change detection
int pw_lost;      // powerwall connection lost flag
int gas_c;
int was_c;
int zrz_o;
int oavgt;
int ix;
int wk_drows;     // daily extraction output row count (for pos in WebChart)

// ─── Persist variable shadows for auto-save ───
int sv_hwp_m;
int sv_zrz_m;
int sv_auto_m;
int sv_gas_m;
int sv_was_m;

// ─── Timer counters ───
int udp_timer;
int pw_timer;

// ─── Daily chart tracking ───
int d_pos;         // ring buffer write position (0-95)
int d_cnt;         // 15-min sample count (total written)
char d_day[32];    // selected day for Tages Verlauf (ISO format)
char d_day_cached[32];  // day currently in /tmplog.txt
int db_loaded;     // database range loaded flag
char db_min[32];   // first timestamp in database
char db_max[32];   // last timestamp in database
int db_rows;       // total rows in database

// ─── String buffers ───
char buf[256];
char str[128];
// Weekday/month lookup strings (German)
// wdays: 3 chars each (2-char name + separator), indexed by (wday-1)*3, extract 2
// mons: 3 chars each, indexed by (month-1)*3, extract 3
char wdays[22];  // "So Mo Di Mi Do Fr Sa"
char mons[37];   // "JanFebMrzAprMaiJunJulAugSepOktNovDez"
char s1[32];
char s2[32];
char sx1[32];
char sx2[32];
char fnam[80];
char hdr_s[64];
char hstr[80];
char lwstr[80];
char tmin[32];
char tmax[32];
char tval[32];
char oval[32];
char min_s[32];
char max_s[32];
char wstr[128];
char url[128];
char dx1[32];
char dx2[32];

// ═══════════════════════════════════════════════════════════════════
// DISPLAY
// ═══════════════════════════════════════════════════════════════════

// ─── Draw static display elements (called once from main) ───
void drawStatic() {
    dspText("[Bi0D0z]");   // clear display
    // Separator lines
    dspText("[x0y45h320]");
    dspText("[x0y90h320]");
    // Labels (Ci5=yellow)
    dspText("[Ci5f2s1x5y100]Netz:");
    dspText("[x5y130]Solar:");
    dspText("[x5y160]Batt:");
    dspText("[x5y190]Haus:");
    dspText("[x5y220]HWP:");
    dspText("[D1]");
    dspUpdate();
}

// ─── Update time display (every second) ───
// Uses native display commands: Ci3=green, f4=font4, T=time, tS=seconds
void updateTime() {
    dspText("[Ci3f4x5y10T]");
    dspText("[f4x155y10tS]");
}

// ─── Update sensor values (every 5 seconds) ───
// Uses dspText for values, sprintf to build value strings
void updateValues() {
    // Temperature (orange, Ci6) + battery level
    sprintf(buf, "[f2s1Ci6x5y60p-8]%.1f C", atmp);
    dspText(buf);

    sprintf(buf, "[x140y60p-10]%.1f %", pwl);
    dspText(buf);

    // Power values (cyan, Ci7)
    sprintf(buf, "[Ci7x150y100p-10]%d W", sip);
    dspText(buf);

    sprintf(buf, "[x150y130p-10]%d W", sop);
    dspText(buf);

    sprintf(buf, "[x150y160p-10]%d W", bip);
    dspText(buf);

    sprintf(buf, "[x150y190p-10]%d W", hip);
    dspText(buf);

    // HWP
    sprintf(buf, "[x150y220p-10]%d W", hwp);
    dspText(buf);
}

// ═══════════════════════════════════════════════════════════════════
// DATA COLLECTION — 15-minute samples into daily ring buffers
// ═══════════════════════════════════════════════════════════════════
void collectDaily() {
    // Store current values into daily arrays (all float)
    t_wr4[d_pos] = sedc;               // Dach (roof PV)
    t_wr1[d_pos] = 0.0 - wrga;         // Garage PV
    t_wr2[d_pos] = 0.0 - wrgh;         // Gartenhaus PV
    t_wr3[d_pos] = 0.0 - wrgg;         // Garten PV
    t_hv[d_pos]  = hip;                // Haus (house consumption)
    t_ni[d_pos]  = sip;                // Netz (grid import)
    t_pw[d_pos]  = pwl;                // Powerwall %
    t_atmp[d_pos] = atmp;              // Aussentemperatur
    t_ktmp[d_pos] = ktmp;              // Kellertemperatur
    t_ss[d_pos]  = ssp;                // Solarspeicher
    d_pos = d_pos + 1;
    if (d_pos >= 96) { d_pos = 0; }
    if (d_cnt < 96) { d_cnt = d_cnt + 1; }
}

// ═══════════════════════════════════════════════════════════════════
// WEB — Main page sensor display (WebPage callback)
// ═══════════════════════════════════════════════════════════════════
// ─── WebCall: AJAX refresh callback (runs every ~2 seconds) ───
void WebCall() {
    int sr;
    int ss;
    int dl;
    sr = tasm_sunrise;
    ss = tasm_sunset;
    dl = ss - sr;

    // ─── Clock / Date / Sunrise header (as table row) ───
    webSend("<tr><td colspan=2 style='text-align:center;background:#333;padding:8px;border-radius:8px'>");
    webSend("<span style='color:green;font-size:40px;font-weight:bold'>");
    sprintf(buf, "%02d:", tasm_hour);
    webSend(buf);
    sprintf(buf, "%02d:", tasm_minute);
    webSend(buf);
    sprintf(buf, "%02d", tasm_second);
    webSend(buf);
    webSend("</span><br>");
    // Date: weekday day. month year
    strSub(s1, wdays, (tasm_wday - 1) * 3, 2);
    strSub(s2, mons, (tasm_month - 1) * 3, 3);
    sprintf(buf, "%s %d. %s %d", s1, tasm_day, s2, tasm_year);
    webSend(buf);
    webSend("<br>");
    // Sunrise --- daylight duration --- Sunset
    webSend("&#127774; ");
    sprintf(buf, "%02d:", sr / 60);
    webSend(buf);
    sprintf(buf, "%02d", sr % 60);
    webSend(buf);
    webSend(" &lt;--- ");
    sprintf(buf, "%d:", dl / 60);
    webSend(buf);
    sprintf(buf, "%02d", dl % 60);
    webSend(buf);
    webSend(" ---&gt; ");
    sprintf(buf, "%02d:", ss / 60);
    webSend(buf);
    sprintf(buf, "%02d", ss % 60);
    webSend(buf);
    webSend(" &#127769;");
    webSend("</td></tr>");

    // ═══════════════════════════════════════════════════════════════
    // Sensor rows — use {s}/{m}/{e} tags for Tasmota table integration
    // Client JS replaces: {s}→<tr><th>, {m}→</th><td...>, {e}→</td></tr>
    // ═══════════════════════════════════════════════════════════════

    // ─── Wasser ───
    // NOTE: sprintf has 64-byte internal buffer limit.
    // Split: webSend("{s}LABEL{m}<span style='color:COLOR'>") then sprintf for value only.
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Wasser</b>{m}{e}");
    webSend("{s}Heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f l</span>{e}", (t_ws - was_m) * 1000);
    webSend(buf);
    webSend("{s}Z&auml;hlerstand{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f &#13221;</span>{e}", t_ws);
    webSend(buf);

    // ─── Solarthermie ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solarthermie</b>{m}{e}");
    webSend("{s}Au&szlig;entemperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.1f &#8451;</span>{e}", atmp);
    webSend(buf);
    webSend("{s}Durchschnittstemperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", avgt);
    webSend(buf);
    webSend("{s}Kellertemperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", ktmp);
    webSend(buf);
    webSend("{s}Solarkollektor{m}<span style='color:yellow'>");
    sprintf(buf, "%.1f &#8451;</span>{e}", scol);
    webSend(buf);
    webSend("{s}Solarspeicher{m}<span style='color:yellow'>");
    sprintf(buf, "%.1f &#8451;</span>{e}", ssp);
    webSend(buf);

    // ─── Regen ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Regen</b>{m}{e}");
    webSend("{s}Heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f mm</span>{e}", train - rain_m);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f mm</span>{e}", train);
    webSend(buf);

    // ─── Haupt Hauszähler ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Haupt Hausz&auml;hler</b>{m}{e}");
    webSend("{s}Verbrauch{m}<span style='color:yellow'>");
    sprintf(buf, "%.4f KWh</span>{e}", zwzi);
    webSend(buf);
    webSend("{s}Verbrauch heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.4f KWh</span>{e}", zwzi - zrz_m);
    webSend(buf);
    webSend("{s}Einspeisung{m}<span style='color:yellow'>");
    sprintf(buf, "%.4f KWh</span>{e}", zwzo);
    webSend(buf);
    webSend("{s}aktueller Verbrauch{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", zwzc);
    webSend(buf);

    // ─── Wallbox ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Wallbox</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", auto);
    webSend(buf);
    webSend("{s}heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_wb - auto_m);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_wb);
    webSend(buf);

    // ─── Klima-Anlage ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Klima-Anlage</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - klima);
    webSend(buf);
    webSend("{s}Total (+Pool){m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_kpwp);
    webSend(buf);

    // ─── Pool-WP ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Pool-WP</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - pwp);
    webSend(buf);

    // ─── Brauchwasser-WP ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Brauchwasser-WP</b>{m}{e}");
    webSend("{s}Heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f KWh</span>{e}", bwtwp);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f KWh</span>{e}", bwwp);
    webSend(buf);

    // ─── Heizungs-WP ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Heizungs-WP</b>{m}{e}");
    webSend("{s}Heute{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f KWh</span>{e}", t_hwp - hwp_m);
    webSend(buf);
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", hwp);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f KWh</span>{e}", t_hwp);
    webSend(buf);

    // ─── Solar Hausdach ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solar Hausdach, 5.25 KW</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", sedc);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f KWh</span>{e}", sedt);
    webSend(buf);

    // ─── Solar Garage ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solar Garage, 2.42/3.45 KW</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - wrga);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_ga);
    webSend(buf);

    // ─── Solar Gartenhaus ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solar Gartenhaus, 1.96/2.8 KW</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - wrgh);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_gh);
    webSend(buf);

    // ─── Solar Garten ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solar Garten, 2.87/4.1 KW</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - wrgg);
    webSend(buf);
    webSend("{s}Total{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", t_gg);
    webSend(buf);

    // ─── Solar Gesamt ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Solar Gesamt, 7.25/10.35 KW</b>{m}{e}");
    webSend("{s}aktuell{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", 0.0 - (wrga + wrgh + wrgg));
    webSend(buf);

    // ─── Powerwall (mixed colors) ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Powerwall</b>{m}{e}");
    webSend("{s}Batterie F&uuml;llstand{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f %% (", pwl);
    webSend(buf);
    sprintf(buf, "%.2f kWh)</span>{e}", pwl / 100.0 * 13.5);
    webSend(buf);
    webSend("{s}Netz{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f W</span>{e}", sip);
    webSend(buf);
    webSend("{s}Solar{m}<span style='color:green'>");
    sprintf(buf, "%.2f W</span>{e}", sop);
    webSend(buf);
    webSend("{s}Batterie{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f W</span>{e}", bip);
    webSend(buf);
    webSend("{s}Haus{m}<span style='color:red'>");
    sprintf(buf, "%.2f W</span>{e}", hip);
    webSend(buf);
    webSend("{s}Gesamt{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", tcap / 1000.0);
    webSend(buf);
    webSend("{s}Verbleibend{m}<span style='color:yellow'>");
    sprintf(buf, "%.3f kWh</span>{e}", rcap / 1000.0);
    webSend(buf);
    webSend("{s}Rcap{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f %%</span>{e}", rper);
    webSend(buf);

    // ─── Büro ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>B&uuml;ro</b>{m}{e}");
    webSend("{s}Temperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", btemp);
    webSend(buf);
    webSend("{s}Feuchte{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f %%</span>{e}", bhumi);
    webSend(buf);
    webSend("{s}Luftdruck{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f hPa</span>{e}", bpress);
    webSend(buf);

    // ─── Schlafzimmer ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Schlafzimmer</b>{m}{e}");
    webSend("{s}Temperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", shtemp);
    webSend(buf);

    // ─── Schlafzimmer G ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Schlafzimmer G</b>{m}{e}");
    webSend("{s}Temperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", aztemp);
    webSend(buf);
    webSend("{s}Feuchte{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f %%</span>{e}", azhumi);
    webSend(buf);
    webSend("{s}TVOC{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f ppb</span>{e}", aztvoc);
    webSend(buf);
    webSend("{s}eCO2{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f ppm</span>{e}", azeco2);
    webSend(buf);

    // ─── Wohnzimmer ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Wohnzimmer</b>{m}{e}");
    webSend("{s}Temperatur{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f &#8451;</span>{e}", wtemp);
    webSend(buf);
    webSend("{s}Feuchte{m}<span style='color:yellow'>");
    sprintf(buf, "%.2f %%</span>{e}", whumi);
    webSend(buf);
    webSend("{s}TVOC{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f ppb</span>{e}", wtvoc);
    webSend(buf);
    webSend("{s}CO2{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f ppm</span>{e}", wco2);
    webSend(buf);

    // ─── Phasen ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>Phasen</b>{m}{e}");
    webSend("{s}Phase 1{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", phs1);
    webSend(buf);
    webSend("{s}Phase 2{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", phs2);
    webSend(buf);
    webSend("{s}Phase 3{m}<span style='color:yellow'>");
    sprintf(buf, "%.0f W</span>{e}", phs3);
    webSend(buf);

    // ─── System ───
    webSend("{s}<hr>{m}<hr>{e}{s}<b style='color:magenta'>System</b>{m}{e}");
    sprintf(buf, "{s}Heap{m}<span style='color:yellow'>%d kB</span>{e}", tasm_heap / 1000);
    webSend(buf);

    // ─── Audio Volume slider ───
    webSend("{s}Lautst&auml;rke{m}");
    sprintf(buf, "<input type='range' min='0' max='100' value='%d'", audvol);
    webSend(buf);
    webSend(" oninput='this.nextSibling.textContent=this.value'");
    webSend(" onchange='fetch(\"/cm?cmnd=ENGSetvol%20\"+this.value)'");
    webSend(" style='width:150px'>");
    sprintf(buf, "<span>%d</span>{e}", audvol);
    webSend(buf);

    webSend("{s}<hr>{m}<hr>{e}");
}

void WebPage() {
    // ─── Live streaming chart: 4 solar inverters (Chart.js) ───
    webSend("<div style='position:relative;width:640px;height:300px;'><canvas id='schart' width='640' height='300'></canvas></div>");
    webSendFile("stream_tc.js");
}

// ═══════════════════════════════════════════════════════════════════
// DATA LOADING — download log databases from data collector
// ═══════════════════════════════════════════════════════════════════

// Build partial download URL with date range suffix
// Data collector runs Scripter — @date filter needs German format: D.M.YY-H:MM
// Uses s1/s2 ISO timestamps, converts to German for URL, keeps s1/s2 intact
void buildUrl(int is15min) {
    // Copy ISO timestamps and convert to German format
    strcpy(dx1, s1);
    timeConvert(dx1, 1);    // "2026-02-20T00:00:00" → "20.2.26 0:00"
    strcpy(dx2, s2);
    timeConvert(dx2, 1);    // "2026-02-28T00:00:00" → "28.2.26 0:00"

    // Replace space with dash for URL: "20.2.26 0:00" → "20.2.26-0:00"
    // strToken is 1-based, delimiter must be char literal (not string)
    strToken(str, dx1, ' ', 1);
    strToken(buf, dx1, ' ', 2);
    dx1 = str;
    dx1 += "-";
    dx1 += buf;

    strToken(str, dx2, ' ', 1);
    strToken(buf, dx2, ' ', 2);
    dx2 = str;
    dx2 += "-";
    dx2 += buf;

    if (is15min == 0) {
        url = "http://192.168.188.61/ufs/energy_m_log.txt@";
    } else {
        url = "http://192.168.188.61/ufs/energy_15_log.txt@";
    }
    url += dx1;
    url += "_";
    url += dx2;
}

// Load day data — download today's 15-min data from collector
void loadDayData() {
    int fr;
    int rows;
    int i;

    addLog("TCC: loading day data...");
    if (strlen(d_day) > 0) {
        // Use selected day
        strcpy(s1, d_day);
        strcpy(s2, d_day);
        timeOffset(s2, 1, 1);       // selected day + 1 day midnight
    } else {
        // Default: yesterday
        timeStamp(s1);
        timeOffset(s1, -1, 1);      // yesterday midnight
        timeStamp(s2);
        timeOffset(s2, 0, 1);       // today midnight
    }

    // Skip download if /tmplog.txt already has this day's data
    if (strcmp(s1, d_day_cached) == 0) {
        addLog("TCC: day data cached, skip download");
        dres = 200;
    } else {
        buildUrl(1);                      // builds into 'url'
        strcpy(wstr, url);                // copy to wstr (not shared with main thread)
        dres = fileDownload("/tmplog.txt", wstr);
        sprintf(wstr, "TCC: day HTTP=%d", dres);
        addLog(wstr);
        if (dres == 200) {
            strcpy(d_day_cached, s1);     // remember what's cached
        }
    }

    if (dres == 200) {
        fr = fileOpen("/tmplog.txt", "r");
        if (fr >= 0) {
            // col_offs=1, accum=-1 — starts at WB (first data col in 15-min file)
            // WB WR1 WR2 WR3 ATMP_a WWTMP_a BTMP_a RTMP_a SKTMP_a SSTMP_a
            // SD ZWZI ZWZO H20 GAS PWL_a SIP_a SOP_a BIP_a HIP_a
            // RCAP_a KLI_a PWP_a WP BWWP RAIN HWP_a HWP KTMP_a AVTMP_a
            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, "TCC: day rows=%d", rows);
            addLog(buf);

            // Scale counters to watts: value * 4000 (15min → hourly rate)
            // _a columns (t_atmp, t_ss, t_pw, t_hv, t_ktmp) are already in real units
            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;
            }
        }
    }

    // ── Extract Energiebilanz from daily log (matching Scripter #gload) ──
    // /daylog.txt has full daily log; extract selected day's delta
    if (strlen(d_day) > 0) {
        strcpy(sx1, d_day);
    } else {
        timeStamp(sx1);
        timeOffset(sx1, -1, 1);
    }
    strcpy(sx2, sx1);
    timeOffset(sx2, 1, 1);
    fr = fileOpen("/daylog.txt", "r");
    if (fr >= 0) {
        // col_offs=1, accum=-1 — same as Scripter #gload line 390
        // Reuse wt_* arrays (same as Scripter does)
        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, "TCC: bilanz rows=%d", rows);
        addLog(buf);
    }
}

// Load week data — download and extract both databases
void loadWeekData() {
    int fr;
    int rows;

    // ── Load 15-minute data (7 days, averaged to hourly) ──
    if (dl_done == 0) {
        addLog("TCC: loading 15min data...");
        timeStamp(s1);
        timeOffset(s1, -7, 1);       // 7 days ago midnight
        timeStamp(s2);
        timeOffset(s2, 0, 1);        // today midnight

        d_day_cached[0] = 0;          // week download overwrites /tmplog.txt
        buildUrl(1);                  // 15-min DB URL with @date
        strcpy(wstr, url);            // copy to wstr (not shared with main thread)
        dres = fileDownload("/tmplog.txt", wstr);
        sprintf(wstr, "TCC: 15min HTTP=%d", dres);
        addLog(wstr);
    } else {
        addLog("TCC: 15min data reusing local file");
        dres = 200;
    }

    if (dres == 200) {
        fr = fileOpen("/tmplog.txt", "r");
        if (fr >= 0) {
            // col_offs=2 (skip WB+WR1..no, skip counter+WB), accum=-4 (average 4 rows -> hourly)
            // Matches Scripter: fxt(fr s1 s2 2 -4 w_wr1 w_wr2 w_wr3 w_at ...)
            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);
            fileClose(fr);
            sprintf(buf, "TCC: 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;
                i = i + 1;
            }

            // Set [0] time offset: (wday-1)*24+hours (matching Scripter)
            tmp = (tasm_wday - 1) * 24 + tasm_hour;
            w_at[0] = tmp;
            w_sk[0] = tmp;
            w_swa[0] = tmp;
            w_pwl[0] = tmp;
            w_hv[0] = tmp;
            w_sin[0] = tmp;
            w_wr1[0] = tmp;
            w_wr2[0] = tmp;
            w_wr3[0] = tmp;
            w_wr4[0] = tmp;
            w_wa[0] = tmp;
            w_gas[0] = tmp;
        }
    }

    // ── Download daily log from collector ──
    {
        if (dl_done == 0) {
            timeStamp(s1);
            timeOffset(s1, -366, 1);     // full year for 52-week view
            timeStamp(s2);
            timeOffset(s2, 0, 1);        // today midnight

            buildUrl(0);                 // daily DB URL with @date
            strcpy(wstr, url);
            dres = fileDownload("/wdaylog.txt", wstr);
            sprintf(wstr, "TCC: daily HTTP=%d", dres);
            addLog(wstr);
            dl_done = 1;                 // mark files as downloaded
        } else {
            addLog("TCC: daily data reusing local file");
        }

        // Get database date range from downloaded file
        fr = fileOpen("/wdaylog.txt", "r");
        if (fr >= 0) {
            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;
            sprintf(buf, "TCC: DB range %d rows", db_rows);
            addLog(buf);
        }

        // Extract weekly data (8 days, delta → 7 rows)
        timeStamp(s1);
        timeOffset(s1, -8, 1);       // 8 days ago midnight
        timeStamp(s2);
        timeOffset(s2, 0, 1);        // today midnight
        fr = fileOpen("/wdaylog.txt", "r");
        if (fr >= 0) {
            // col_offs=1 (matching Scripter), accum=-1 (delta)
            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);
            fileClose(fr);
            wk_drows = rows;
            sprintf(buf, "TCC: daily rows=%d", rows);
            addLog(buf);

            // Set [0] to weekday offset (matching Scripter: wday-1)
            tmp = tasm_wday - 1;
            wt_hv[0] = tmp;
            wt_wb[0] = tmp;
            wt_sin1[0] = tmp;
            wt_sin2[0] = tmp;
            wt_sin3[0] = tmp;
            wt_sin4[0] = tmp;
            wt_wa[0] = tmp;
            wt_gas[0] = tmp;
            wt_wp[0] = tmp;

            // Calculate Gas € (gas * 10 * price / 100)
            int i;
            i = 1;
            while (i <= rows) {
                wt_pgas[i] = wt_gas[i] * 10.0 * PRICE_GAS_KWH / 100.0;
                i = i + 1;
            }
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
// LOAD 52-WEEK DATA — weekly aggregated from full daily log
// ═══════════════════════════════════════════════════════════════════
void load52WeekData() {
    int fr;
    int rows;

    // Use already-downloaded daily log from loadWeekData()
    fr = fileOpen("/wdaylog.txt", "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, 0, 1);

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

    // Extract weekly aggregation
    fr = fileOpen("/wdaylog.txt", "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;

        // Clear [0]
        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_avgt[0] = 0.0; w_pgas[0] = 0.0;

        // Gas €
        int i;
        i = 1;
        while (i < rows) {
            w_pgas[i] = w_gas[i] * 10.0 * PRICE_GAS_KWH / 100.0;
            i = i + 1;
        }
    }
    wk_loaded = 0;
}

void load31DayData() {
    int fr;
    int rows;

    // Use already-downloaded daily log from loadWeekData()
    fr = fileOpen("/wdaylog.txt", "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, 0, 1);

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

    // Extract daily deltas (accum=-1)
    fr = fileOpen("/wdaylog.txt", "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;
        // Cap to show exactly 'days' entries (d52-1 used as count)
        if (d52 > days + 1) { d52 = days + 1; }

        // Clear [0] (accumulated start value)
        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;

        // Compute combined columns
        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;
        }
    }
    wk_loaded = 0;
}

// ═══════════════════════════════════════════════════════════════════
// TASK LOOP — background FreeRTOS task for data loading
// ═══════════════════════════════════════════════════════════════════
void TaskLoop() {
    int last_3am;
    last_3am = -1;
    while (1) {
        if (dotask == 1) {
            addLog("TCC: TaskLoop week start");
            loadWeekData();
            wk_loaded = 1;
            d52 = 0;  // w_* arrays overwritten by week data
            dotask = 0;
            addLog("TCC: TaskLoop week done");
        }
        if (dotask == 2) {
            addLog("TCC: TaskLoop day start");
            loadDayData();
            dy_loaded = 1;
            dotask = 0;
            addLog("TCC: TaskLoop day done");
        }
        if (dotask == 3) {
            addLog("TCC: TaskLoop 52wk start");
            load52WeekData();
            dotask = 0;
            addLog("TCC: TaskLoop 52wk done");
        }
        if (dotask == 4) {
            addLog("TCC: TaskLoop 31day start");
            load31DayData();
            dotask = 0;
            addLog("TCC: TaskLoop 31day done");
        }
        if (dotask == 5) {
            // Daikin AC sensor poll — runs in TaskLoop to avoid blocking main loop
            char dkurl[64];
            char dkbuf[128];
            strcpy(dkurl, "http://192.168.188.43/aircon/get_sensor_info");
            int dklen;
            dklen = httpGet(dkurl, dkbuf);
            if (dklen > 0) {
                int hp;
                hp = strFind(dkbuf, "htemp=");
                if (hp >= 0) {
                    char htval[12];
                    strSub(htval, dkbuf, hp + 6, 10);
                    shtemp = atof(htval);
                }
            }
            dotask = 0;
        }
        // Auto-refresh at 3:00 AM daily (like Scripter)
        if (dotask == 0 && tasm_hour == 3 && tasm_minute == 0 && last_3am != tasm_day) {
            last_3am = tasm_day;
            addLog("TCC: 3AM auto-refresh");
            dy_loaded = 0;
            dl_done = 0;          // force re-download from server
            d_day_cached[0] = 0;  // force re-download
            dotask = 1;  // full reload (week + daily log)
        }
        delay(2000);
    }
}

// ═══════════════════════════════════════════════════════════════════
// 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);
        }

        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>");
            // 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];
            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 (wk_loaded == 1 && dotask == 0) {
            // Chart 1: Energy balance column chart
            // 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);
            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, 0, 168, w_pwl, 8, 60, 0.0, 100.0);
            WebChart('l', "", "Aussentemperatur|C", 0xFF6600, 0, 168, w_at, 9, 60, 0.0, 60.0);
            WebChart('l', "", "Kollektortemperatur|C", 0x0000FF, 0, 168, w_sk, 9, 60, 0.0, 60.0);
            WebChart('l', "", "Solarspeicher|C", 0xCC0000, 0, 168, w_swa, 9, 60, 0.0, 60.0);
            // Chart 3: Power consumption & solar yield (smooth)
            WebChart('l', "Netzverbrauch / Solarertrag", "Netz|W", 0xFF0000, 0, 168, w_hv, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Solar Input|W", 0x00AA00, 0, 168, w_sin, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Garage|W", 0xFF8800, 0, 168, w_wr1, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "GHaus|W", 0x0000FF, 0, 168, w_wr2, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Garten|W", 0x888800, 0, 168, w_wr3, 8, 60, 0.0, 8000.0);
            WebChart('l', "", "Dach|W", 0x8800FF, 0, 168, w_wr4, 8, 60, 0.0, 8000.0);
            // Chart 4: Gas & Water (2 decimals + smooth = 10)
            WebChart('l', "Gas / Wasser", "Gas|m3", 0xFF0000, 0, 168, w_gas, 10, 60, 0.0, 0.3);
            WebChart('l', "", "Wasser|m3", 0x0088FF, 0, 168, w_wa, 10, 60, 0.0, 0.3);
            // 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);
            // 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;
            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 (d52 > 0 && 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 (d52 > 0 && 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>");
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
// CALLBACKS
// ═══════════════════════════════════════════════════════════════════

// UDP packet received — reset timeout timer
void UdpCall() {
    udp_timer = 60;
}

void EverySecond() {
    cnt = cnt + 1;

    // ─── UDP timeout — alert if no packets for 60 seconds ───
    if (udp_timer > 0) {
        udp_timer = udp_timer - 1;
    }
    if (udp_timer == 0) {
        udp_timer = 60;
        addLog("TCC: udp timeout");
    }


    // ─── Display update ───
    if (dotask == 0) {
        dspText("[D0]");
        updateTime();
        if (cnt % 5 == 0) {
            updateValues();
        }
        dspText("[D1]");
        dspUpdate();
    }

    // ─── HWP alarm: check every 60s if heating WP is running ───
    if (cnt % 60 == 0) {
        if (hwp < 500.0) {
            hwp_alarm = hwp_alarm + 1;
        } else {
            hwp_alarm = 0;
        }
    }
    // After 60 consecutive low readings (1 hour), alert every 30s
    if (hwp_alarm >= 60 && cnt % 30 == 0) {
        audioSay("heizung alarm");
        audioPlay("/alarm.mp3");
    }

    // ─── Battery full: trigger once when pwl reaches 100% ───
    if (pwl >= 100.0 && batt_prev < 100) {
        audioSay("battery full");
        audioPlay("/doorbell.mp3");
    }
    // ─── Battery low: repeat every 5 min while pwl < 3% (offset +5s) ───
    if (pwl < 3.0 && cnt % 300 == 5) {
        audioPlay("/Done.mp3");
    }
    batt_prev = pwl;

    // ─── Energy collector fatal error: repeat every 5 min while gerr > 0 (offset +10s) ───
    if (gerr > 0.0 && cnt % 300 == 10) {
        audioPlay("/Classic.mp3");
    }

    // ─── Power loss: UDP data collector unreachable ───
    if (udp_timer <= 0 && pw_lost == 0) {
        pw_lost = 1;
        audioSay("powerwall lost");
        audioPlay("/alarm.mp3");
    }
    if (udp_timer > 0 && pw_lost == 1) {
        pw_lost = 0;
        audioPlay("/Done.mp3");
    }

    // ─── Daikin AC sensor poll every 5 min (offset +15s) ───
    // Trigger TaskLoop to do the httpGet (avoids blocking main loop)
    if (cnt % 300 == 15 && dotask == 0 && tasm_wifi == 1) {
        dotask = 5;
    }

    // ─── Midnight reference store ───
    if (tasm_hour == 0 && tasm_minute == 0 && mn != tasm_day) {
        mn = tasm_day;
        gas_m = t_gs;
        was_m = t_ws;
        hwp_m = t_hwp;
        zrz_m = zwzi;
        auto_m = t_wb;
        rain_m = train;
        saveVars();
        addLog("TCC: midnight persist saved");
    }

    // ─── 15-minute data collection (offset +20s) ───
    if (cnt % 900 == 20) {
        collectDaily();
    }

}

void WebOn() {
    int h;
    h = webHandler();
    if (h == 1) {
        // /tc_array → JSON with 4 PV inverter watts
        // TODO: use webSendJsonArray after firmware rebuild
        sprintf(buf, "{\"array\":[%d,", sedc);
        webSend(buf);
        sprintf(buf, "%d,", 0 - wrga);
        webSend(buf);
        sprintf(buf, "%d,", 0 - wrgh);
        webSend(buf);
        sprintf(buf, "%d]}", 0 - wrgg);
        webSend(buf);
    }
}

// ─── Command handler: ENGSetref [name value] ───
void Command(char cmd[]) {
    char arg[64];
    char resp[96];
    char tmp[32];

    if (strFind(cmd, "SETREF") == 0) {
        if (strlen(cmd) > 7) {
            // "SETREF gas_m 123.45" — set individual value
            strSub(arg, cmd, 7, 0);
            int sp;
            sp = strFind(arg, " ");
            if (sp > 0) {
                char name[16];
                char valstr[16];
                strSub(name, arg, 0, sp);
                strSub(valstr, arg, sp + 1, 0);
                float val;
                val = atof(valstr);
                int found;
                found = 0;
                if (strFind(name, "gas") == 0) { gas_m = val; found = 1; }
                if (strFind(name, "was") == 0) { was_m = val; found = 1; }
                if (strFind(name, "hwp") == 0) { hwp_m = val; found = 1; }
                if (strFind(name, "zrz") == 0) { zrz_m = val; found = 1; }
                if (strFind(name, "auto") == 0) { auto_m = val; found = 1; }
                if (strFind(name, "rain") == 0) { rain_m = val; found = 1; }
                if (found == 1) {
                    saveVars();
                    strcpy(resp, "{\"ENGSetref\":\"");
                    strcat(resp, name);
                    sprintf(tmp, " = %.4f saved\"}", val);
                    strcat(resp, tmp);
                    responseCmnd(resp);
                } else {
                    strcpy(resp, "{\"ENGSetref\":\"unknown var, use: gas was hwp zrz auto rain\"}");
                    responseCmnd(resp);
                }
            } else {
                strcpy(resp, "{\"ENGSetref\":\"usage: ENGSetref name value\"}");
                responseCmnd(resp);
            }
        } else {
            // No args: set all from current meter readings
            gas_m = t_gs;
            was_m = t_ws;
            hwp_m = t_hwp;
            zrz_m = zwzi;
            auto_m = t_wb;
            rain_m = train;
            saveVars();
            strcpy(resp, "{\"ENGSetref\":\"all midnight refs set\"}");
            responseCmnd(resp);
        }
    } else if (strFind(cmd, "SETVOL") == 0) {
        // ENGSetvol 50 — set audio volume
        if (strlen(cmd) > 7) {
            strSub(arg, cmd, 7, 0);
            int vol;
            vol = atoi(arg);
            if (vol < 0) { vol = 0; }
            if (vol > 100) { vol = 100; }
            audvol = vol;
            audioVol(audvol);
            saveVars();
            sprintf(resp, "{\"ENGSetvol\":\"%d\"}", audvol);
            responseCmnd(resp);
        } else {
            sprintf(resp, "{\"ENGSetvol\":\"%d\"}", audvol);
            responseCmnd(resp);
        }
    } else {
        strcpy(resp, "{\"ENG\":\"cmds: Setref Setvol\"}");
        responseCmnd(resp);
    }
}

void OnExit() {
    print("Core2 Energy shutdown\n");
}

// ═══════════════════════════════════════════════════════════════════
// MAIN: Initialization
// ═══════════════════════════════════════════════════════════════════
int main() {
    // Initialize state
    dotask = 0;
    wk_loaded = 0;
    dy_loaded = 0;
    dl_done = 0;
    once = 0;
    bflg = 0;
    alarm = 0;
    hwp_alarm = 0;
    batt_prev = 0;
    pw_lost = 0;
    d52 = 52;
    zrz_o = 21399;
    cnt = 0;

    // Timer init
    udp_timer = 30;
    pw_timer = 300;

    // Initialize shadow copies
    sv_hwp_m = hwp_m;
    sv_zrz_m = zrz_m;
    sv_auto_m = auto_m;
    sv_gas_m = gas_m;
    sv_was_m = was_m;
    mn = -1;
    d_pos = 0;
    d_cnt = 0;
    db_loaded = 0;

    d_day_cached[0] = 0;  // no cached day data yet

    // Weekday names: 3 chars each (2-letter + space), tasm_wday 1=Sun..7=Sat
    wdays = "So Mo Di Mi Do Fr Sa";
    // Month names: 3 chars each, tasm_month 1-12
    mons = "JanFebMrzAprMaiJunJulAugSepOktNovDez";

    // Register web chart pages (buttons appear on main page)
    webPageLabel(0, "Tages Verlauf");
    webPageLabel(1, "Letzte Woche");
    webPageLabel(2, "52 Wochen");
    webPageLabel(3, "Andere Ger&auml;te");
    webPageLabel(4, "Heizung 31 Tage");

    // Register streaming chart data endpoint
    webOn(1, "/tc_array");

    // Register console command prefix: ENGMidnight
    addCommand("ENG");

    // Draw initial display
    drawStatic();

    // Audio init: restore volume from persist, play startup sound
    if (audvol == 0) { audvol = 50; }  // default volume on first boot
    audioVol(audvol);
    audioPlay("/Startup.mp3");

    // Weekly data load deferred to OnInit (needs WiFi for httpGet)
    // dotask = 1 is set there

    // Daily data now loaded from collector DB on demand (dotask=2)

    print("Phase 5: global keyword started\n");
    sprintf(buf, "heap: %d\n", tasm_heap);
    print(buf);

    return 0;
}

// ─── OnInit: called when WiFi is up — safe to do network operations ───
void OnInit() {
    addLog("TCC: WiFi up, loading week data");
    dotask = 1;
}