Zum Inhalt

energy_dashboard.tc

energy_dashboard.tc — house-wide energy / climate dashboard

Source on GitHub

// ─────────────────────────────────────────────────────────────────────
// energy_dashboard.tc — house-wide energy / climate dashboard
//
// Port of a long-running Scripter program (>D 70, IP=.71). Receives
// ~30 named values via UDP broadcast from other Tasmota nodes on the
// LAN (solar inverters, Powerwall gateway, smart meter, weather and
// air-quality sensors, water/gas/heat-pump meters), keeps running
// daily/historical buffers, and renders a big sensor table on the
// Tasmota main page (`/`) plus the SENSOR JSON for MQTT telemetry.
//
// Scope of this port:
//   - Faithful port of the Scripter program's ACTIVE features only.
//     Commented-out paths in the original (telegram, MP3 audio,
//     Daikin HTTP poll) are dropped — this board has no audio.
//   - Sub-pages (24h chart / 31-day chart / Rolläden links) are NOT
//     ported here — keeping the .tcb small for a 320 KB filesystem.
//     They can be added later as a separate slot or via WebUI() pages.
//
// Sister script: examples/pool_pump.tc (Tuya local-protocol pool
// heater). Designed to coexist in a separate VM slot — different
// concerns, different runtime characteristics, and a crash in one
// doesn't take out the other.
// ─────────────────────────────────────────────────────────────────────

// ── UDP-broadcast globals ────────────────────────────────────────────
// `global` makes a scalar receive UDP-broadcast updates by name. The
// firmware's UDP receiver stores incoming values as float bits, so all
// these MUST be `global float` — even values used as integers
// (Watts, percent, ppm). Cast at display time.
global float sedt   = 0.0;     // total Einspeisung (kWh)
global float sedc   = 0.0;     // momentane Einspeisung (W)
global float atmp   = 0.0;     // Aussentemperatur (°C)
global float scol   = 0.0;     // Solarkollektor (°C)
global float ssp    = 0.0;     // Solarspeicher (°C)
global float wrgh   = 0.0;     // Solar Gartenhaus (W, negative = export)
global float wrgg   = 0.0;     // Solar Garten (W)
global float wrga   = 0.0;     // Solar Garage (W)
global float auto  = 0.0;     // Wallbox (W) — original was `auto`, reserved
global float t_ga   = 0.0;     // Solar Garage total (kWh)
global float t_gh   = 0.0;     // Solar Gartenhaus total (kWh)
global float t_gg   = 0.0;     // Solar Garten total (kWh)
global float t_wb   = 0.0;     // Wallbox total (kWh)
global float t_ws   = 0.0;     // Wasser total (m³)
global float avgt   = 0.0;     // gleitender Tagesmittel (°C)
global float zwzi   = 0.0;     // 2-Richtungszähler in (kWh)
global float zwzo   = 0.0;     // 2-Richtungszähler out (kWh)
global float zwzc   = 0.0;     // 2-Richtungszähler current (W)
global float pwl    = 0.0;     // Powerwall battery %
global float sip    = 0.0;     // PW Netz (W)
global float sop    = 0.0;     // PW Solar (W)
global float bip    = 0.0;     // PW Batterie (W)
global float hip    = 0.0;     // PW Haus (W)
global float tcap   = 0.0;     // PW total capacity (Wh)
global float rcap   = 0.0;     // PW remaining capacity (Wh)
global float rper   = 0.0;     // PW remaining %
global float wtemp  = 0.0;     // Wohnzimmer Temp (°C)
global float whumi  = 0.0;     // Wohnzimmer Feuchte (%)
global float wtvoc  = 0.0;     // Wohnzimmer TVOC (ppb)
global float wco2   = 0.0;     // Wohnzimmer CO2 (ppm)
global float aztemp = 0.0;     // SZ G Temp (°C)
global float azhumi = 0.0;     // SZ G Feuchte (%)
global float aztvoc = 0.0;     // SZ G TVOC (ppb)
global float azeco2 = 0.0;     // SZ G eCO2 (ppm)
global float bpress = 0.0;     // Büro Luftdruck (hPa)
global float btemp  = 0.0;     // Büro Temperatur (°C)
global float bhumi  = 0.0;     // Büro Feuchte (%)
global float klima  = 0.0;     // Klima-Anlage (W, negative = consume)
global float pwp    = 0.0;     // Pool-WP (W)
global float t_kpwp = 0.0;     // Klima+Pool total (kWh)
global float bwwp   = 0.0;     // Brauchwasser-WP total (kWh)
global float bwtwp  = 0.0;     // Brauchwasser-WP today (kWh)
global float ktmp   = 0.0;     // Kellertemperatur (°C)
global float hwp    = 0.0;     // Heizungs-WP current (W)
global float t_hwp  = 0.0;     // Heizungs-WP total (kWh)
global float train  = 0.0;     // Regen total (mm)
global float hrain  = 0.0;     // Regen heute (mm)
global float shtemp = 0.0;     // Schlafzimmer Temp via UDP
global float phs1   = 0.0;     // Phase 1 (W)
global float phs2   = 0.0;     // Phase 2 (W)
global float phs3   = 0.0;     // Phase 3 (W)
global float gerr   = 0.0;     // energy manager error flag

// Heat-pump live values — broadcast by examples/heatpump_map.tc on
// the device that runs the Modbus client. Pulled via UDP global so
// the dashboard can show pump status without each device talking
// Modbus to the heat pump itself.
global float hp_run = 0.0;     // 1.0 = running, 0.0 = off (r217 enum)
global float hp_in  = 0.0;     // Puffer / return water (°C, r188)
global float hp_out = 0.0;     // Ausgangstemp / supply water (°C, r191)
global float hp_at  = 0.0;     // HP's own outside-air sensor (°C, r190)

// ── Persist (snapshot at midnight, used to compute "today" deltas) ──
// Scalar baselines, NOT chart data — scalar persist (.pvs) is reliable,
// so these stay `persist`.
persist float gas_m   = 0.0;
persist float was_m   = 0.0;
persist float hwp_m   = 0.0;
persist float zrz_m   = 0.0;     // not float-y but kept for symmetry
persist float auto_m  = 0.0;

// ── Chart arrays — stored in a dedicated binary file, NOT in .pvs ───
// The four rolling chart arrays below (dvals / days_w / days_t /
// fast_h) and their ring cursors are persisted to
// /energy_dashboard.bin via energy_dash_save()/energy_dash_load() —
// the same fileWriteBin/fileReadBin approach the sml_chart family uses
// (see sml_chart_common.tc). They are deliberately NOT `persist`:
// heap-backed persist arrays proved unreliable across reboot and device
// migration, so chart state lives in one self-contained file we load
// explicitly in main() (after the arrays are allocated) and flush on
// data-commit + teardown. Because the ring cursors travel in the file
// header too, the whole chart history transfers by copying this single
// file — no .pvs dependency.

// 24h heat-pump kWh trace, 15-minute resolution. Plain ring buffer:
// slot N = kWh consumed during 15-min window N. dvals_pos = current
// write cursor (0..95). The Scripter version used dvals[0] as the
// cursor and 1..96 as data; we use a separate scalar instead so the
// array lays out cleanly for WebChart() consumption.
float dvals[96];
int   dvals_pos = 0;

// Display-only scratch — 24 hourly averages computed from dvals[]
// for the "Heizungs-WP 24h" chart. Each entry is the sum of 4
// consecutive 15-min kWh slots, which equals the average kW over
// that hour (since 1 h = 4 × 15 min and ∑kWh / 1h = kW). Filled
// fresh each WebPage() render. NOT persisted.
float dvals_kw[24];

// Display-only scratch — fast_h[] is W; chart shows kW. Conversion
// is fast_h[i] / 1000.0. Filled fresh each WebPage() render.
float fast_h_kw[60];

// 31-day historical: parallel arrays of (heat-pump kWh, average temp).
float days_w[31];
float days_t[31];
int   days_pos  = 0;

// Per-minute heat-pump current trace (last 60 minutes).
float fast_h[60];
int   fast_pos  = 0;

// Vattenfall meter offset captured 2023-09-01 — fixed reference,
// never overwritten. Kept in source for a public example; if the
// real value should be private, move to /energy.cfg.
int zrz_o = 21399;

// Gas/water price ceilings (EUR/cent). Used only for display rows.
int   gpe = 12;       // gas cent/kWh
float spe = 0.30;     // strom EUR/kWh (Vattenfall reference)

// ── Plain scalars (not persisted, not UDP) ─────────────────────────
int   udp_timer    = 60;        // counts down in EverySecond; reset by UdpReady
int   boot_logged  = 0;         // first-tick log marker
int   last_hour    = -1;        // for hour-change detection
int   last_m15     = -1;        // for 15-minute slot transition
int   last_minute  = -1;        // for per-minute fast trace
float oavgt        = 0.0;       // averaged temp snapshot (computed every 10s)
float dsel         = 0.0;       // last t_hwp seen at slot rollover (for delta calc)
float c_hwp        = 0.0;       // 15-minute heat-pump kWh delta
float gas_c        = 0.0;       // gas today (m³)
float was_c        = 0.0;       // water today (m³)
char  scratch[256];             // sprintf workspace — sized for the
                                // longest row in WebPage's 31-day table
                                // (~169 chars/row with inline styles)

// ── Life indicator ───────────────────────────────────────────────
// Increments every WebCall (~every 2 s, on the AJAX `/?m=1` poll
// that drives the live block re-render). Rendered next to the
// clock so a stuck WebUI is visually obvious — clock alone isn't
// enough since time-of-day can jump backwards or stall on RTC drift.
int   web_tick = 0;

// ── Helpers ────────────────────────────────────────────────────────
//
// Append a single line to /log.txt with a timestamp. The original
// Scripter script appends without bound — on a 320 KB filesystem we
// can't afford that, so we cap at LOG_MAX bytes and truncate on
// rollover (drop the file, start fresh) instead of partial-trim.
//
// Cap is intentionally low — events are rare (boot, UDP errors,
// midnight rollover) so 4 KB holds many days of history.
void log_evt(char msg[]) {
    if (fileSize("/log.txt") > 4096) {
        fileDelete("/log.txt");
    }
    int h = fileOpen("/log.txt", 2);    // append mode
    if (h < 0) return;
    char tsbuf[32];
    timeStamp(tsbuf);                   // local time, "YYYY-MM-DDTHH:MM:SS"
    // Build the line in a single buffer — fileWrite with a string literal
    // emits SYS_FILE_WRITE_STR (id 276) which has no VM handler yet
    // (compiler/runtime mismatch — see MEMORY.md). Going via a char[]
    // routes through SYS_FILE_WRITE which works.
    char line[160];
    sprintf(line, "%s\t%s\n", tsbuf, msg);
    fileWrite(h, line, strlen(line));
    fileClose(h);
}

// ── Chart-array persistence — dedicated binary file (NOT .pvs) ──────
//
// /energy_dashboard.bin layout (all int32 slots; floats bit-stored,
// exactly as WebChart reads them back):
//   [0..3]  header: magic, dvals_pos, days_pos, fast_pos
//   [4..]   dvals[96], days_w[31], days_t[31], fast_h[60]
//
// energy_dash_load() runs once in main() after the arrays exist;
// energy_dash_save() is called at each data-commit point (15-min slot,
// midnight rollover, console fill/clear) and flushed on teardown.
#define ED_MAGIC 0x45440001          // 'ED' + format version 1

int ed_hdr[4];        // marshalling buffer for the file header
int ed_dirty = 0;     // set on any chart-array change; gates teardown flush

void energy_dash_save() {
    int h = fileOpen("/energy_dashboard.bin", "w");
    if (h < 0) { addLog("ENERGY: chart save fileOpen failed"); return; }
    ed_hdr[0] = ED_MAGIC;
    ed_hdr[1] = dvals_pos;
    ed_hdr[2] = days_pos;
    ed_hdr[3] = fast_pos;
    fileWriteBin(h, ed_hdr, 4);
    fileWriteBin(h, dvals,  96);
    fileWriteBin(h, days_w, 31);
    fileWriteBin(h, days_t, 31);
    fileWriteBin(h, fast_h, 60);
    fileClose(h);
    ed_dirty = 0;     // file now matches the in-RAM arrays
}

void energy_dash_load() {
    ed_dirty = 0;
    int h = fileOpen("/energy_dashboard.bin", "r");
    if (h < 0) return;            // no file yet — arrays stay zero
    fileReadBin(h, ed_hdr, 4);
    if (ed_hdr[0] != ED_MAGIC) {  // unknown/garbage file — ignore it
        fileClose(h);
        addLog("ENERGY: chart file bad magic, ignored");
        return;
    }
    dvals_pos = ed_hdr[1];
    days_pos  = ed_hdr[2];
    fast_pos  = ed_hdr[3];
    fileReadBin(h, dvals,  96);
    fileReadBin(h, days_w, 31);
    fileReadBin(h, days_t, 31);
    fileReadBin(h, fast_h, 60);
    fileClose(h);
    addLog("ENERGY: chart arrays loaded from /energy_dashboard.bin");
}

// Flush chart data when this slot is going away so a post-boot load
// never reads stale data. GUARDED by ed_dirty so a freshly-uploaded
// .bin (import while the slot is STOPPED) is not clobbered by the
// outgoing instance's teardown save — same rule as sml_chart_common.
// => Always upload /energy_dashboard.bin while the slot is STOPPED.
void CleanUp() {     // device Restart / OTA (FUNC_SAVE_BEFORE_RESTART)
    if (ed_dirty) energy_dash_save();
}
void OnExit() {      // this slot stopped / re-run / unlinked
    if (ed_dirty) energy_dash_save();
}

// One sensor section — two rows: a horizontal rule and a magenta
// section heading. Mirrors the Scripter `#head(hdr)` subroutine.
void web_section(char title[]) {
    sprintf(scratch, "{s}<hr>{m}<hr>{e}{s}<span style='color:magenta;'>%s</span>{m}{e}", title);
    webSend(scratch);
}

// Yellow-valued data row helpers, one per output precision. The
// Scripter source uses `%0X`, `%1X`, `%2X`, `%3X`, `%4X` — keeping a
// helper per precision keeps the per-row cost to one sprintf+webSend.
void row_i(char label[], int v, char unit[]) {
    sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%d %s</span>{e}", label, v, unit);
    webSend(scratch);
}
void row_f1(char label[], float v, char unit[]) {
    sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.1f %s</span>{e}", label, v, unit);
    webSend(scratch);
}
void row_f2(char label[], float v, char unit[]) {
    sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.2f %s</span>{e}", label, v, unit);
    webSend(scratch);
}
void row_f3(char label[], float v, char unit[]) {
    sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.3f %s</span>{e}", label, v, unit);
    webSend(scratch);
}
void row_f4(char label[], float v, char unit[]) {
    sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.4f %s</span>{e}", label, v, unit);
    webSend(scratch);
}

// ── EverySecond — cron-ish bookkeeping ────────────────────────────
//
// The original Scripter `>S` block ran every second. This callback
// is the same. Logic preserved 1:1 modulo the dropped audio paths.
void EverySecond() {
    // Skip everything until NTP has set the clock — otherwise the
    // midnight rollover would fire spuriously at boot.
    if (tasm_year < 2025) return;

    // First-tick boot log.
    if (boot_logged == 0) {
        log_evt("boot");
        boot_logged = 1;
    }

    // UDP watchdog — reset to 60 each tick that UdpReady fires
    // (callback below). If 60 ticks elapse without a packet, the
    // sender is probably gone; log and (potentially) re-subscribe.
    if (udp_timer > 0) udp_timer = udp_timer - 1;
    if (udp_timer == 0) {
        log_evt("udp timeout");
        udp_timer = 60;
    }

    // Every-10-seconds: snapshot avgt → oavgt for midnight use.
    if (tasm_uptime % 10 == 0) {
        oavgt = avgt;
    }

    // Hourly trigger.
    int hr = tasm_hour;
    if (hr != last_hour) {
        if (hr == 0 && last_hour >= 0) {
            // Midnight rollover — record yesterday's heat-pump kWh
            // and average outdoor temp into the 31-day rolling buffer.
            days_w[days_pos] = (bwtwp + t_hwp) - hwp_m;
            days_t[days_pos] = oavgt;
            days_pos = days_pos + 1;
            if (days_pos >= 31) days_pos = 0;
            ed_dirty = 1;

            // Snapshot meter values so today-counters reset.
            was_m  = t_ws;
            hwp_m  = t_hwp;
            zrz_m  = zwzi;
            auto_m = t_wb;
            energy_dash_save();   // 31-day arrays → /energy_dashboard.bin
            saveVars();           // scalar baselines → .pvs
            log_evt("midnight rollover");
        }
        last_hour = hr;
    }

    // Today-deltas always recomputed (cheap, used in WebCall).
    was_c = t_ws - was_m;

    // 15-minute slot index 0..95 → daily heat-pump kWh trace.
    // Guarded by `t_hwp > 0.0` — if the heat-pump UDP global hasn't
    // arrived yet (fresh boot, sender device down), we MUST NOT
    // baseline `dsel` to 0, otherwise the next slot rollover does
    // `c_hwp = real_lifetime_kWh - 0` = a huge spurious value (a
    // visible "13 kWh peak" with everything else getting auto-scaled
    // into the noise floor). Wait until t_hwp is real.
    int m15 = (tasm_hour * 60 + tasm_minute) / 15;   // 0..95
    if (m15 != last_m15 && t_hwp > 0.0) {
        if (last_m15 >= 0) {
            // kWh consumed *in this 15-min slot*.
            // Sanity-clamp: heat-pump physical max is ~4 kW, so per
            // 15-min slot can't exceed 1 kWh; we cap at 2 kWh
            // (covers double-pump / future bigger units). Negative
            // (meter wrap / sensor reset) or out-of-range deltas
            // are dropped — the slot keeps its previous value.
            float delta = t_hwp - dsel;
            if (delta >= 0.0 && delta < 2.0) {
                c_hwp = delta;
                dvals[m15] = c_hwp;
                dvals_pos  = m15;
                ed_dirty = 1;
                energy_dash_save();   // 24h array → file (15-min cadence)
            }
            dsel = t_hwp;
        } else {
            // First observation since boot — just establish baseline.
            dsel = t_hwp;
            dvals_pos = m15;
        }
        last_m15 = m15;
    }

    // Per-minute fast trace (heat-pump current power) — sliding window.
    // Instead of a ring buffer (which makes a partially-filled chart
    // appear "centered" because zero-padding lives at indices not yet
    // visited), we shift the array left by one slot every minute and
    // append the latest sample at slot 59. Then slot 0 is always the
    // oldest reading, slot 59 is "now", and pre-fill shows up as
    // zero-padding on the LEFT side of the chart (right-aligned data).
    int mn = tasm_minute;
    if (mn != last_minute) {
        for (int i = 0; i < 59; i = i + 1) fast_h[i] = fast_h[i + 1];
        fast_h[59] = hwp;
        fast_pos = 60;     // sentinel — tells WebChart "render slots 0..59"
        ed_dirty = 1;      // committed at the next 15-min save / teardown
        last_minute = mn;
    }
}

// UDP-receive callback — fires when a known UDP global lands.
// We only need it as a watchdog tickle.
void UdpReady() {
    udp_timer = 60;
}

// ── Main page sensor table — port of >WS + html_sub.txt ───────────
//
// Each `web_section` call emits a horizontal-rule + magenta heading.
// Each `row_*` call emits one yellow-valued row in {s}label{m}val{e}
// format. Rows that need richer markup (Powerwall colored values,
// time block, sunrise/sunset emoji line) build the HTML inline with
// sprintf + webSend.
void WebCall() {
    // ── Section header — frames this slot's output as a visually
    //    distinct block. Pattern lifted from core2_energy.tc:
    //    `<tr><td colspan=2 style='background:#333...'>` rounded card.
    //    Sister script pool_pump.tc emits its own header to keep the
    //    two slots cleanly separated on the shared / page.
    // Section banner — distinct from the Tasmota blue (#1fa3ec)
    // button color so it doesn't read as tappable. Dark slate fill,
    // orange left-accent bar, sharp corners → "section divider", not
    // "button". Same style used in pool_pump.tc.
    webSend("{s}<hr>{m}<hr>{e}<tr><td colspan=2 style='text-align:left;background:#34495e;color:#fff;padding:8px 12px;border-left:6px solid #e67e22;border-radius:2px;font-size:1.4em;font-weight:bold;letter-spacing:0.5px;'>Energie-Dashboard</td></tr>");

    // ── Top: time, weekday, date, sunrise/sunset ─────────────────
    // Day-of-week abbreviation (de). tasm_wday is 1=Sun..7=Sat.
    // Indexed via strToken(dst, src, delim, n) — 1-based lookup,
    // lines up directly with tasm_wday (1=Sun..7=Sat) and tasm_month
    // (1..12). See examples/clock_header.tc for the canonical block.
    char wd_names[] = "So|Mo|Di|Mi|Do|Fr|Sa";
    char wd_label[4];
    strToken(wd_label, wd_names, '|', tasm_wday);

    // Month abbreviation (de) — tasm_month is 1..12.
    char mo_names[] = "Jan|Feb|Mar|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez";
    char mo_label[4];
    strToken(mo_label, mo_names, '|', tasm_month);

    web_tick = web_tick + 1;
    // Single dark-grey rounded card spanning both columns. Two
    // sprintfs because the full card exceeds 256-byte scratch.
    sprintf(scratch, "<tr><td colspan=2 style='text-align:center;background:#333;padding:8px;border-radius:8px'><span style='color:green;font-size:40px;font-weight:bold'>%02d:%02d:%02d</span><br>%s %d. %s %d <span style='font-size:0.7em;color:#888;'>&#9679; %d</span><br>",
            tasm_hour, tasm_minute, tasm_second,
            wd_label, tasm_day, mo_label, tasm_year, web_tick);
    webSend(scratch);

    // Sunrise/sunset row with day-length, then close the card.
    int sr = tasm_sunrise;     // minutes since midnight
    int ss = tasm_sunset;
    int dl = ss - sr;          // day length, minutes
    sprintf(scratch, "&#127774; %02d:%02d <--- %02d:%02d ---> %02d:%02d &#127769;</td></tr>",
            sr / 60, sr % 60,
            dl / 60, dl % 60,
            ss / 60, ss % 60);
    webSend(scratch);

    // ── Wasser ───────────────────────────────────────────────────
    web_section("Wasser");
    row_i("Heute",      (int)(was_c * 1000.0), "l");
    row_f4("Zählerstand", t_ws, "m³");

    // ── Solarthermie ─────────────────────────────────────────────
    web_section("Solarthermie");
    row_f1("Außentemperatur",        atmp, "°C");
    row_f2("Durchschnittstemperatur",     avgt, "°C");
    row_f2("Kellertemperatur",            ktmp, "°C");
    row_f1("Solarkollektor",              scol, "°C");
    row_f1("Solarspeicher",               ssp,  "°C");

    // ── Regen ────────────────────────────────────────────────────
    web_section("Regen");
    row_f2("Heute", hrain, "mm");
    row_i ("Total", (int)train, "mm");

    // ── Haupt Hauszähler ────────────────────────────────────────
    web_section("Haupt Hauszähler");
    row_f4("Verbrauch",            zwzi,           "kWh");
    row_f4("Vattenfall Verbrauch", zwzi - (float)zrz_o, "kWh");
    row_f4("Verbrauch heute",      zwzi - zrz_m,   "kWh");
    row_f4("Einspeisung",          zwzo,           "kWh");
    row_i ("aktueller Verbrauch",  (int)zwzc,      "W");

    // ── Wallbox ──────────────────────────────────────────────────
    web_section("Wallbox");
    row_i ("aktuell", (int)auto,           "W");
    row_f3("heute",   t_wb - auto_m,        "kWh");
    row_f3("Total",   t_wb,                 "kWh");

    // ── Klima-Anlage ────────────────────────────────────────────
    web_section("Klima-Anlage");
    row_i ("aktuell",        (int)(0.0 - klima), "W");
    row_f3("Total (+Pool)",  t_kpwp,              "kWh");

    // ── Pool-WP ──────────────────────────────────────────────────
    web_section("Pool-WP");
    row_i("aktuell", (int)(0.0 - pwp), "W");

    // ── Brauchwasser-WP ─────────────────────────────────────────
    web_section("Brauchwasser-WP");
    row_f2("Heute", bwtwp,  "kWh");
    row_i ("Total", (int)bwwp, "kWh");

    // ── Heizungs-WP ─────────────────────────────────────────────
    // Status / water in / water out / outside come via UDP from the
    // device running examples/heatpump_map.tc (Modbus to the actual
    // heat-pump controller). hp_run is 1.0 when r217 == 1 (running),
    // 0.0 otherwise (booting / remote-off).
    web_section("Heizungs-WP");
    char hp_st_buf[8];
    if (hp_run > 0.5) { strcpy(hp_st_buf, "AN"); }
    else              { strcpy(hp_st_buf, "AUS"); }
    sprintf(scratch, "{s}Status{m}<span style='color:yellow;'>%s</span>{e}", hp_st_buf);
    webSend(scratch);
    row_f1("Wasser ein",  hp_in,  "°C");
    row_f1("Wasser aus",  hp_out, "°C");
    row_f1("Aussen (HP)", hp_at,  "°C");
    row_f2("Heute",   t_hwp - hwp_m, "kWh");
    row_i ("aktuell", (int)hwp,      "W");
    row_f3("Total",   t_hwp,         "kWh");

    // ── Solar Hausdach (5.25 KW) ────────────────────────────────
    web_section("Solar Hausdach, 5.25 KW");
    row_i ("aktuell", (int)sedc, "W");
    row_f3("Total",   sedt,      "kWh");

    // ── Solar Garage (2.42/3.45 KW) ─────────────────────────────
    web_section("Solar Garage, 2.42/3.45 KW");
    row_i ("aktuell", (int)-wrga, "W");
    row_f3("Total",   t_ga,      "kWh");

    // ── Solar Gartenhaus (1.96/2.8 KW) ──────────────────────────
    web_section("Solar Gartenhaus, 1.96/2.8 KW");
    row_i ("aktuell", (int)-wrgh, "W");
    row_f3("Total",   t_gh,      "kWh");

    // ── Solar Garten (2.87/4.1 KW) ──────────────────────────────
    web_section("Solar Garten, 2.87/4.1 KW");
    row_i ("aktuell", (int)-wrgg, "W");
    row_f3("Total",   t_gg,      "kWh");

    // ── Solar Gesamt ────────────────────────────────────────────
    web_section("Solar Gesamt, 7.25/10.35 KW");
    row_i ("aktuell", (int)(0.0 - (wrga + wrgh + wrgg)), "W");

    // ── Powerwall — colored per metric ──────────────────────────
    web_section("Powerwall");
    sprintf(scratch,
            // `&#37;` is HTML for `%`. Workaround for a TinyC firmware
            // bug (fixed in 1.3.22): when `%%` falls inside a float-typed
            // sprintf segment's prefix, `tc_sprintf_float` copied the
            // bytes verbatim instead of unescaping → "85 %% (...)" in
            // the rendered page. The HTML entity sidesteps the
            // formatter and renders as `%` in the browser. Other `%%`
            // sites in the example scripts are unaffected (they fall
            // in the trailing-text or int-typed segments, which
            // already unescape correctly).
            "{s}Batterie Füllstand{m}<span style='color:yellow;'>%d&#37; (%.2f kWh)</span>{e}",
            (int)pwl, pwl / 100.0 * 13.5);
    webSend(scratch);
    sprintf(scratch, "{s}Netz{m}<span style='color:yellow;'>%d W</span>{e}", (int)sip);
    webSend(scratch);
    sprintf(scratch, "{s}Solar{m}<span style='color:green;'>%d W</span>{e}", (int)sop);
    webSend(scratch);
    sprintf(scratch, "{s}Batterie{m}<span style='color:yellow;'>%d W</span>{e}", (int)bip);
    webSend(scratch);
    sprintf(scratch, "{s}Haus{m}<span style='color:red;'>%d W</span>{e}", (int)hip);
    webSend(scratch);
    sprintf(scratch, "{s}Gesamt{m}<span style='color:yellow;'>%.3f kWh</span>{e}", tcap / 1000.0);
    webSend(scratch);
    sprintf(scratch, "{s}Verbleibend{m}<span style='color:yellow;'>%.3f kWh</span>{e}", rcap / 1000.0);
    webSend(scratch);
    sprintf(scratch, "{s}Rcap{m}<span style='color:yellow;'>%d %%</span>{e}", (int)rper);
    webSend(scratch);

    // ── Büro ─────────────────────────────────────────────────────
    web_section("Büro");
    row_f1("Temperatur", btemp,  "°C");
    row_f1("Feuchte",    bhumi,  "%");
    row_f1("Luftdruck",  bpress, "hPa");

    // ── Schlafzimmer ────────────────────────────────────────────
    web_section("Schlafzimmer");
    row_f1("Temperatur", shtemp, "°C");

    // ── Schlafzimmer G ──────────────────────────────────────────
    web_section("Schlafzimmer G");
    row_f1("Temperatur", aztemp, "°C");
    row_f1("Feuchte",    azhumi, "%");
    row_i ("TVOC",       (int)aztvoc, "ppb");
    row_i ("eCO2",       (int)azeco2, "ppm");

    // ── Wohnzimmer ───────────────────────────────────────────────
    web_section("Wohnzimmer");
    row_f1("Temperatur", wtemp, "°C");
    row_f1("Feuchte",    whumi, "%");
    row_i ("TVOC",       (int)wtvoc, "ppb");
    row_i ("CO2",        (int)wco2,  "ppm");

    // ── Phasen ───────────────────────────────────────────────────
    web_section("Phasen");
    row_i("Phase 1", (int)phs1, "W");
    row_i("Phase 2", (int)phs2, "W");
    row_i("Phase 3", (int)phs3, "W");

    // ── System ───────────────────────────────────────────────────
    web_section("System");
    sprintf(scratch, "{s}Filesystem frei{m}<span style='color:yellow;'>%.2f kB</span>{e}",
            (float)fsInfo(1));
    webSend(scratch);
    sprintf(scratch, "{s}System Heap{m}<span style='color:yellow;'>%d kB</span>{e}",
            tasm_heap / 1024);
    webSend(scratch);

    // Reload control — small in-row link instead of a full-width
    // button, less visually intrusive. Triggers location.reload() so
    // WebPage() re-runs and the charts pick up the latest array data.
    webSend("{s}{m}<a href='#' onclick='location.reload();return false;' style='color:#1fa3ec;'>↻ Charts aktualisieren</a>{e}");
}

// ── Charts + 31-day table (rendered once on full page load) ───────
//
// Why this lives in WebPage and NOT in WebCall:
//
// Tasmota's main `/` page fetches `/?m=1` every ~2.3 s and updates
// the live area via `el.innerHTML = response`. Setting innerHTML
// PARSES <script> tags but does NOT execute them — that's a
// browser-side rule. Google Charts requires its setup script to
// run, so chart containers placed in WebCall would render as empty
// boxes. WebPage runs at full page-load (FUNC_WEB_ADD_MAIN_BUTTON)
// where <script> tags ARE executed normally.
//
// Trade-off: WebPage content always lands BELOW everything emitted
// in any slot's WebCall, so the visual order is
//   slot 0 WebCall (energy) → slot 1 WebCall (pool) → WebPage charts
// rather than the more natural energy → charts → pool. To redraw
// with fresh persist data, click the "↻ Charts aktualisieren" link
// in the energy block — it triggers location.reload() so WebPage()
// runs again.
void WebPage() {
    // ottelo's chart-centering compensation (margin-left:-30px wrapper)
    webSend("<div style='margin-left:-30px'>");
    // Order (top→bottom): 60-min chart (W), 24-hour chart (kW),
    // 31-day chart (kWh + avg temp dual-axis), 31-day table.
    // Rationale: shortest timeframe at the top, longest at the
    // bottom; same horizontal scale (640×280) so they line up; the
    // table closes the page as a precise read-out of the same data
    // the dual-axis chart visualizes.
    //
    // ── decimals encoding: low 3 bits = decimal places, bit 3 (=+8)
    //    = enable curve smoothing. So 1 decimal + smoothing = 1|8 = 9.

    // ── 60-minute heat-pump current power (kW, 1-min samples).
    // fast_h[] holds the raw W readings; convert to kW for display
    // so the unit matches the 24-h chart below. fast_pos=60 is a
    // sentinel set by EverySecond's shift-append code; it tells
    // WebChart's ring-unwind to read slots 0..59 in order, so
    // partial-fill leaves zero-padding at the left and real data
    // lines up against the right edge of the chart.
    //
    // y-axis: anchor at 0, top = max(observed) × 1.2, floor 0.1 kW
    // so an idle pump still renders a visible line instead of
    // flat-on-axis. Fixed lower bound prevents Google Charts'
    // smoothing curve from overshooting into negatives.
    float fmax = 0.1;
    for (int i = 0; i < 60; i = i + 1) {
        fast_h_kw[i] = fast_h[i] / 1000.0;
        if (fast_h_kw[i] > fmax) fmax = fast_h_kw[i];
    }
    WebChartSize(640, 280);
    WebChart(0, "Heizungs-WP letzte 60 min", "kW",
             0xe74c3c, fast_pos, 60, fast_h_kw,
             1 | 8, 1, 0.0, fmax * 1.2);

    // ── 24-hour heat-pump average power (kW, hourly averages).
    // Each chart point = average over 1 hour (4 × 15-min slots).
    // The raw kWh in dvals[] sums per hour to exactly the kW for
    // that hour (since 1 h = 4 × ¼-h, ∑kWh ÷ 1 h = kW). 24 smooth
    // data points instead of 96 jagged ones, lines up cleanly with
    // the 60-min view above.
    //
    // Ring-buffer cursor: dvals_pos points at the next 15-min slot
    // to write (0..95); the equivalent hour cursor is dvals_pos/4.
    // The current hour is partial — it averages real new data with
    // old data from this hour 24 hours ago. Self-corrects within an
    // hour as new quarters overwrite old ones.
    // Same physical-max sanity clamp as the writer — drops any
    // pre-existing poisoned slot to 0 at render time so the chart
    // self-heals on the next page reload, not after 24 h. 8 kWh/h
    // ceiling = pump physical max × 2 = generous.
    float kwmax = 1.0;             // floor 1 kW so a low-load day still shows
    for (int i = 0; i < 24; i = i + 1) {
        int b = i * 4;
        float h = dvals[b] + dvals[b + 1] + dvals[b + 2] + dvals[b + 3];
        if (h < 0.0 || h > 8.0) h = 0.0;
        dvals_kw[i] = h;
        if (dvals_kw[i] > kwmax) kwmax = dvals_kw[i];
    }
    WebChartSize(640, 280);
    WebChart(0, "Heizungs-WP 24h", "kW",
             0xe74c3c, dvals_pos / 4, 24, dvals_kw,
             1 | 8, 60, 0.0, kwmax * 1.2);

    // ── 31-day combined: WP kWh + outdoor temp on dual axis.
    // Daily totals so kWh (not kW) is the right unit — each row is
    // an integral over 24 h.
    WebChartSize(640, 280);
    WebChart(0, "Wärmepumpe + Aussen 31 Tage", "kWh",
             0xe74c3c, days_pos, 31, days_w,
             1, 1440, 0.0, 60.0);
    // Second series — empty title means "add to previous chart"
    WebChart(0, "", "°C",
             0x3498db, days_pos, 31, days_t,
             1, 1440, -10.0, 40.0);

    // ── 31-day table — Google Charts Table renderer (`'t'` = type
    //    116). Pattern lifted from core2_energy.tc:
    //      WebChart('t', "ColHeader|Row1|Row2|…", "Series", color, …)
    //    First call sets the column-1 header ("Tag"); subsequent
    //    calls with title="" append columns to the same table. Row
    //    labels in the title are optional — when omitted, rows are
    //    auto-numbered 1..N by the renderer. Note: data is rendered
    //    in physical array-index order, NOT chronological order.
    //    With a ring buffer like ours that means rows 1..days_pos-1
    //    show the most recent days and rows days_pos..30 show the
    //    oldest. Re-ordering would need an extra display buffer; for
    //    now leaving the natural ring-order display.
    WebChart('t', "Tag", "WPV (kWh)", 0xe74c3c,
             days_pos, 31, days_w, 1, 0, 0.0, 0.0);
    WebChart('t', "",    "D-Temp (°C)", 0x3498db,
             days_pos, 31, days_t, 1, 0, 0.0, 0.0);
    webSend("</div>");
}

// ── Test data presets (console-triggered) ───────────────────────────
// Both `dvals[96]` (24-hour 15-min trace) and `days_w[31]`/`days_t[31]`
// (31-day trace) take real-world time to fill — 24 h and 31 days
// respectively. To eyeball chart styling without waiting, the `ED`
// command prefix exposes synthetic-data presets:
//
//   DASHFILL24     synthetic bimodal HP daily-energy pattern in dvals[]
//   DASHFILL31     synthetic warming-month pattern in days_w[]/days_t[]
//   DASHFILLALL    both
//   DASHCLEAR      zero out all three preview arrays
//
// Each preset writes the chart file (energy_dash_save()) so a reload
// preserves the preview data — handy when iterating on chart styles.
// Real readings overwrite the synthetic data on the next 15-min
// boundary / midnight rollover.
//
// Why register these instead of auto-firing in main(): main() runs
// on every boot. Auto-fill would clobber real accumulated data if
// the device reboots after running for a week.
//
// Prefix is `DASH` (NOT `ED`) — Tasmota has built-in `ED…` console
// commands (e.g. EDPlay/EDExtended) that shadow ours, returning
// `{"Command":"Unknown"}` even though our handler ran the matcher.
void Command(char cmd[]) {
    if (strcmp(cmd, "FILL24") == 0) {
        // Bimodal Gaussian: morning peak around 7:00, evening around 19:00.
        // Floor 0.3 kWh (standby), peaks 2.0–2.5 kWh per 15-min slot.
        for (int i = 0; i < 96; i = i + 1) {
            float h = i * 0.25;        // hour-of-day, 0..23.75
            float dm = h - 7.0;        // distance from morning peak
            float de = h - 19.0;       // distance from evening peak
            float morn = 2.5 * exp(-dm * dm / 8.0);
            float even = 2.0 * exp(-de * de / 12.0);
            dvals[i] = 0.3 + morn + even;
        }
        dvals_pos = 95;
        energy_dash_save();
        responseCmnd("ED: 24h chart filled (synthetic bimodal HP day)");
    }
    else if (strcmp(cmd, "FILL31") == 0) {
        // Linear warming month: kWh trends 45 → 15, °C trends -3 → +19.
        // Slight weekly sinusoidal jitter so the line isn't a ruler.
        for (int i = 0; i < 31; i = i + 1) {
            float frac = i / 30.0;
            float wkl  = sin(i * 0.9);  // weekly-ish bumpiness
            days_w[i] = 45.0 - 30.0 * frac + 5.0 * wkl;
            days_t[i] = -3.0 + 22.0 * frac + 2.0 * wkl;
        }
        days_pos = 30;
        energy_dash_save();
        responseCmnd("ED: 31d chart filled (warming month)");
    }
    else if (strcmp(cmd, "FILLALL") == 0) {
        for (int i = 0; i < 96; i = i + 1) {
            float h = i * 0.25;
            float dm = h - 7.0;
            float de = h - 19.0;
            dvals[i] = 0.3 + 2.5 * exp(-dm * dm / 8.0)
                          + 2.0 * exp(-de * de / 12.0);
        }
        dvals_pos = 95;
        for (int i = 0; i < 31; i = i + 1) {
            float frac = i / 30.0;
            float wkl  = sin(i * 0.9);
            days_w[i] = 45.0 - 30.0 * frac + 5.0 * wkl;
            days_t[i] = -3.0 + 22.0 * frac + 2.0 * wkl;
        }
        days_pos = 30;
        energy_dash_save();
        responseCmnd("ED: 24h + 31d charts filled");
    }
    else if (strcmp(cmd, "CLEAR") == 0) {
        for (int i = 0; i < 96; i = i + 1) dvals[i]  = 0.0;
        for (int i = 0; i < 31; i = i + 1) days_w[i] = 0.0;
        for (int i = 0; i < 31; i = i + 1) days_t[i] = 0.0;
        dvals_pos = 0;
        days_pos  = 0;
        energy_dash_save();
        responseCmnd("ED: cleared dvals + days_w + days_t");
    }
    else {
        responseCmnd("ED: FILL24 | FILL31 | FILLALL | CLEAR");
    }
}

int main() {
    addLog("ENERGY: dashboard starting");
    addCommand("DASH");         // DASHFILL24 / DASHFILL31 / DASHFILLALL / DASHCLEAR
    energy_dash_load();         // chart arrays from /energy_dashboard.bin (NOT .pvs)
    return 0;
}