Skip to content

sml_ebus.tc

SML eBus Solar Monitor with 24h and weekly charts

Source on GitHub

// @name: SML eBus Solar Monitor
// SML eBus Solar Monitor with 24h and weekly charts
// Displays Außentemperatur, Solarspeicher, Kollektortemperatur
// with min/max tracking and Google Charts history

// --- Chart sizes ---
#define DAY_LEN     288       // 24h at 1 sample/5 min
#define DAY_INT     300       // sample interval in seconds (5 min)
#define WEEK_LEN    336       // 7 days at 1 sample/30 min
#define WEEK_INT    1800      // sample interval in seconds (30 min)
#define CHARTFILE   "/sml_ebus.bin"
#define CHART_MAGIC 0x53424532  // "SBE2" — bump to discard an old-layout .bin on load
#define ENERGY_VER  2           // energy arrays now hold AVG POWER (W); bump = one-time clear of old kWh data

// ── Sample encoding: one BYTE per sample, not one float ──────────────────────
// The fourteen ring buffers used to be float[], i.e. 4 bytes for a number the
// chart draws with one decimal: 7*288 + 7*336 = 4368 floats = 17.5 KB of VM
// heap, claimed as ONE contiguous block when the program loads. On the C3 that
// is the whole reason this script would not start next to WiFi and TLS. Packed
// byte[] arrays cost a quarter — 4.4 KB — and WebChartQ() tells the chart what
// a raw 0..255 means.
//
// ⚠️ RAW 0 IS "NO SAMPLE", not zero degrees. The charts rely on it: the
// WebChartJS snippet at the bottom turns un-recorded slots into a gap instead
// of drawing a line through them. The temperature encoding therefore starts at
// raw 1, and enc_t() maps a real 0.0 back to raw 0 so a missing DS18B20 still
// reads as "no data" exactly as it did with floats.
// ── Where the two DS18B20 come from ─────────────────────────────────────────
// NOT from Tasmota's native DS18x20 driver: on this board the 1-Wire GPIO is
// owned by the onewire.tc slot (owSetPin releases the pin from Tasmota, and
// pinFree() refuses the other way round — only one of the two can have the
// bus). onewire.tc publishes its sensors into the sensor JSON from its own
// JsonCall(), and sensorGet() in THIS slot sees that, because the re-entry
// guard skips only the calling slot.
//
// The key is whatever onewire.tc labels the sensor: its alias if one is set,
// else "DS18x20-<XX>" with XX = ROM byte 6. Aliases are worth setting because
// the fallback name depends on the ROM and silently changes if a sensor is
// swapped. In the console of the onewire.tc slot:
//     OW                       -> shows the sensors with their current labels
//     OW NAME T 0 Keller
//     OW NAME T 1 Warmwasser
// They persist across reboots and follow the ROM, not the index.
//
// ⚠️ If you instead give the pin back to Tasmota's own driver (template =
// DS18x20, onewire.tc not running on it), the keys are "DS18B20-1"/"DS18B20-2"
// — and "DS18B20" WITHOUT a suffix when only ONE sensor is found, because the
// driver only appends the index for sensors > 1.
// Two spellings each, because onewire.tc labels a sensor by its ALIAS if one is
// set and by its ROM otherwise (ow_temp_label: "DS18x20-" + ROM byte 6). The
// alias is the readable one and wins; the ROM name needs no configuration and
// therefore survives anything that empties the persist store.
//
// ⚠️ That is not hypothetical. Flashing 1.6.61 onto .150 on 2026-08-25 left
// /onewire.pvs in place but with ow_alias EMPTY: the sensors went back to
// DS18x20-1A / DS18x20-C1 and this script silently read 0 for both until the
// aliases were set again by hand. Which key is right is therefore decided at
// RUNTIME, once, and remembered — see key_src below.
#define KEY_KELLER      "Keller#Temperature"
#define KEY_WW          "Warmwasser#Temperature"
#define KEY_KELLER_ROM  "DS18x20-1A#Temperature"   // id 1ABEE9086461 -> ROM byte 6 = 0x1A
#define KEY_WW_ROM      "DS18x20-C1#Temperature"   // id C178441F64FF -> ROM byte 6 = 0xC1

#define T_SCALE     0.6         // K per step
#define T_OFFS    -30.0         // raw 1 = -29.4 C ... raw 255 = +123.0 C
#define T_NONE    -30.0         // what raw 0 decodes to = the gap sentinel
#define P_WP_SCALE 16.0         // Brauchwasser-WP: 0..4080 W in 16 W steps
#define P_SP_SCALE  0.5         // Solarpumpe:     0..127.5 W in 0.5 W steps

persist watch int sml_activ;
float min_at;
float max_at;
float min_ss;
float max_ss;
float min_ct;
float max_ct;
int startup;        // countdown to skip initial zero readings
int last_day;       // to detect midnight rollover
int atmp_valid;     // 1 once atmp received a real value
int key_src;        // 0 = noch ungeklaert, 1 = Alias-Schluessel, 2 = ROM-Schluessel

// date/time strings
char wdays[] = "So Mo Di Mi Do Fr Sa";
char mons[]  = "JanFebMrzAprMaiJunJulAugSepOktNovDez";
char s1[8];
char s2[8];

global float atmp;
// brauchwasser wärmepumpe total energy
global float bwwpc;
// solarpumpe total energy
global float sppc;
// OneWire DS18B20 temps — read locally on .150, broadcast to the fleet
global float ktmp;      // Kellertemperatur      — KEY_KELLER, id 1ABEE9086461
global float bw_ww;     // Warmwassertemperatur  — KEY_WW, id C178441F64FF

global float scol;
global float ssp;
global float spmp;

// ── Chart history — stored in /sml_ebus.bin (NOT persist/.pvs). File storage
// keeps the large arrays out of the .pvs, so adding a series never re-seeds the
// persist store, and the history survives reloads/OTA. (Same pattern as
// power_meter.tc / bresser_chart.tc.) ─────────────────────────────────────────
// 24h history (1 sample/5 min): 5 temps + 2 energy deltas
byte d_at[DAY_LEN];
byte d_ss[DAY_LEN];
byte d_ct[DAY_LEN];
byte d_kt[DAY_LEN];            // Kellertemperatur
byte d_bw[DAY_LEN];            // Warmwassertemperatur
byte d_bww[DAY_LEN];           // avg power per 5-min slot, /P_WP_SCALE (Brauchwasser-WP)
byte d_spp[DAY_LEN];           // avg power per 5-min slot, /P_SP_SCALE (Solarpumpe)
int   d_pos;                   // current wall-clock 5-min slot (ring anchor)

// weekly history (1 sample/30 min): 5 temps + 2 energy deltas
byte w_at[WEEK_LEN];
byte w_ss[WEEK_LEN];
byte w_ct[WEEK_LEN];
byte w_kt[WEEK_LEN];           // Kellertemperatur
byte w_bw[WEEK_LEN];           // Warmwassertemperatur
byte w_bww[WEEK_LEN];
byte w_spp[WEEK_LEN];
int   w_pos;                   // current wall-clock 30-min slot (ring anchor)

// Energy delta state: the accumulated meter value at the START of the current
// slot, so each slot shows the kWh consumed within it (diff of the accumulator).
int   d_lastdi;                // 5-min slot the current base was snapshot for
int   w_lastwi;                // 30-min slot the current base was snapshot for
float d_bww_base; float d_spp_base;
float w_bww_base; float w_spp_base;
int   save_slot;               // last 5-min slot flushed to .bin (flush cadence)
int   chart_dirty;             // 1 = arrays changed since last save (OnExit guard)

// /sml_ebus.bin layout: int header + float arrays (5×DAY_LEN + 5×WEEK_LEN) + bases[4]
int   chdr[8];                 // [magic, d_pos, w_pos, d_lastdi, w_lastwi, 0,0,0]
float cbas[4];                 // [d_bww_base, d_spp_base, w_bww_base, w_spp_base]

// Encode a temperature into a raw 0..255 byte.
// ⚠️ The clamp is not decoration: a byte store keeps only the lowest byte, so
// an out-of-range reading would wrap to a plausible-looking wrong value near
// the bottom of the chart instead of an obvious spike off the top.
int enc_t(float v) {
    if (v == 0.0) { return 0; }          // "no value" — same convention as the float version
    int raw = (int)((v - T_OFFS) / T_SCALE + 0.5);
    if (raw < 1)   { raw = 1; }          // raw 0 is reserved for the gap
    if (raw > 255) { raw = 255; }
    return raw;
}

// Encode an average power in W. Here raw 0 legitimately means BOTH "pump off"
// and "no sample" — exactly the ambiguity the float version had, so the chart's
// gap handling is unchanged for these two series.
int enc_p(float w, float scale) {
    int raw = (int)(w / scale + 0.5);
    if (raw < 0)   { raw = 0; }
    if (raw > 255) { raw = 255; }
    return raw;
}

void resetMinMax() {
    min_at = 999.0; max_at = -999.0;
    min_ss = 999.0; max_ss = -999.0;
    min_ct = 999.0; max_ct = -999.0;
}

// ── Chart storage: /sml_ebus.bin (fileWriteBin/fileReadBin, like other SML examples) ──
void sml_save() {
    int h = fileOpen(CHARTFILE, "w");
    if (h < 0) { addLog("sml_ebus save: fileOpen failed"); return; }
    chdr[0] = CHART_MAGIC; chdr[1] = d_pos; chdr[2] = w_pos;
    chdr[3] = d_lastdi; chdr[4] = w_lastwi; chdr[5] = ENERGY_VER; chdr[6] = 0; chdr[7] = 0;
    fileWriteBin(h, chdr, 8);
    fileWriteBin(h, d_at, DAY_LEN);  fileWriteBin(h, d_ss, DAY_LEN);  fileWriteBin(h, d_ct, DAY_LEN);
    fileWriteBin(h, d_bww, DAY_LEN); fileWriteBin(h, d_spp, DAY_LEN);
    fileWriteBin(h, w_at, WEEK_LEN); fileWriteBin(h, w_ss, WEEK_LEN); fileWriteBin(h, w_ct, WEEK_LEN);
    fileWriteBin(h, w_bww, WEEK_LEN); fileWriteBin(h, w_spp, WEEK_LEN);
    cbas[0] = d_bww_base; cbas[1] = d_spp_base; cbas[2] = w_bww_base; cbas[3] = w_spp_base;
    fileWriteBin(h, cbas, 4);
    // Keller/Warmwasser stay last — the order only has to match sml_load().
    fileWriteBin(h, d_kt, DAY_LEN);  fileWriteBin(h, d_bw, DAY_LEN);
    fileWriteBin(h, w_kt, WEEK_LEN); fileWriteBin(h, w_bw, WEEK_LEN);
    fileClose(h);
    chart_dirty = 0;
}

void sml_load() {
    // ⚠️ fileReadBin/fileWriteBin move RAW BYTES for a byte[] (one file byte per
    // element) but 4-byte int32s for int[]/float[] — so the header and the bases
    // still count 4 bytes each while the fourteen ring buffers now count one.
    // The whole file went from 17520 to 4416 bytes with the arrays.
    int want = (8 + 4) * 4 + (7 * DAY_LEN + 7 * WEEK_LEN);
    int sz = fileSize(CHARTFILE);
    if (sz != want) { return; }                                 // missing / old layout -> fresh start
    int h = fileOpen(CHARTFILE, "r");
    if (h < 0) { return; }
    fileReadBin(h, chdr, 8);
    if (chdr[0] != CHART_MAGIC) { fileClose(h); return; }        // version bump -> ignore old data
    d_pos = chdr[1]; w_pos = chdr[2]; d_lastdi = chdr[3]; w_lastwi = chdr[4];
    fileReadBin(h, d_at, DAY_LEN);  fileReadBin(h, d_ss, DAY_LEN);  fileReadBin(h, d_ct, DAY_LEN);
    fileReadBin(h, d_bww, DAY_LEN); fileReadBin(h, d_spp, DAY_LEN);
    fileReadBin(h, w_at, WEEK_LEN); fileReadBin(h, w_ss, WEEK_LEN); fileReadBin(h, w_ct, WEEK_LEN);
    fileReadBin(h, w_bww, WEEK_LEN); fileReadBin(h, w_spp, WEEK_LEN);
    fileReadBin(h, cbas, 4);
    d_bww_base = cbas[0]; d_spp_base = cbas[1]; w_bww_base = cbas[2]; w_spp_base = cbas[3];
    fileReadBin(h, d_kt, DAY_LEN);  fileReadBin(h, d_bw, DAY_LEN);
    fileReadBin(h, w_kt, WEEK_LEN); fileReadBin(h, w_bw, WEEK_LEN);
    // The kWh->W migration that used to live here is gone with the layout: the
    // new magic already discards every file written before the byte encoding.
    fileClose(h);
}

int main() {
    if (sml_activ != tasm_rule) {
        sml_activ = tasm_rule;
    }
    atmp_valid = 0;
    key_src = 0;            // beim Start neu entscheiden, welcher Schluessel traegt
    startup = 10;           // skip first 10 seconds for SML to deliver data
    last_day = tasm_day;
    resetMinMax();
    // Chart history lives in /sml_ebus.bin (not persist). The -1 sentinels force a
    // fresh energy base-snapshot on first run; sml_load() overwrites them + all the
    // arrays when a valid .bin exists, so history survives reloads/OTA.
    d_lastdi = -1; w_lastwi = -1; save_slot = -1; chart_dirty = 0;
    sml_load();
}

void EverySecond() {
    if (changed(sml_activ)) {
        tasm_rule = sml_activ;
        snapshot(sml_activ);
    }
    // Meter-def value order (see /sml_meter.def): 1 = Solarkollektor,
    // 2 = Solarspeicher, 3 = Solarpumpe. scol feeds the "Solarspeicher"
    // section+chart below and ssp the "Kollektortemperatur" one, so read
    // them swapped to match those labels. (Flip these two lines if your
    // descriptor lists Speicher first.)
    scol = smlGet(2);       // Solarspeicher
    ssp = smlGet(1);        // Solarkollektor
    spmp = smlGet(3);       // Solarpumpe (read, not charted)

    // skip first seconds — SML hasn't delivered valid data yet
    if (startup > 0) {
        startup = startup - 1;
        return;
    }

    // OneWire DS18B20 temps (native sensor JSON) -> assign broadcasts them to the fleet
    // Sample-and-hold, and the guard is not cosmetic: inside a sensorGet no
    // slot is allowed to WAIT for the VM (may_wait is false there, see the
    // JsonCall dispatch in xdrv_124_tinyc.ino). If the onewire.tc slot happens
    // to be mid-callback, its JsonCall is skipped for that call and sensorGet
    // returns 0 — not an error, just this one second. Writing that 0 through
    // would punch a hole in the chart, because 0.0 is the "no sample" value.
    // Alias first, ROM name as the fallback, and the answer is LATCHED: once one
    // spelling has delivered a value, only that one is asked from then on.
    // sensorGet() is not cheap — it rebuilds the whole sensor JSON through
    // MqttShowSensor() on every call — so the two extra calls must not become
    // permanent. Unresolved costs four calls a second, resolved costs two.
    // key_src stays 0 until a real reading arrives, which is also what happens
    // while the onewire slot is still starting up.
    float t_k = 0.0;
    float t_w = 0.0;
    if (key_src != 2) {
        t_k = sensorGet(KEY_KELLER);      // Kellertemperatur     (id 1ABEE9086461)
        t_w = sensorGet(KEY_WW);          // Warmwassertemperatur (id C178441F64FF)
        if (t_k != 0.0 || t_w != 0.0) { key_src = 1; }
    }
    // Bedingung an den WERTEN, nicht am Zustand: mit "key_src != 1" waere der
    // ROM-Zweig nach dem ersten Alias-Treffer dauerhaft gesperrt gewesen, und
    // ein spaeter verschwindender Alias haette den Rueckfall nie ausgeloest.
    if (t_k == 0.0 && t_w == 0.0) {
        t_k = sensorGet(KEY_KELLER_ROM);
        t_w = sensorGet(KEY_WW_ROM);
        if (t_k != 0.0 || t_w != 0.0) { key_src = 2; }
    }
    if (t_k != 0.0) { ktmp  = t_k; }
    if (t_w != 0.0) { bw_ww = t_w; }

    // reset daily min/max at midnight
    if (tasm_day != last_day) {
        last_day = tasm_day;
        atmp_valid = 0;
        resetMinMax();
    }

    // track daily min/max
    // atmp is global from external device — skip until first real value arrives
    if (atmp_valid == 0 && atmp != 0.0) {
        atmp_valid = 1;
        min_at = atmp;
        max_at = atmp;
    }
    if (atmp_valid) {
        if (atmp > max_at) { max_at = atmp; }
        if (atmp < min_at) { min_at = atmp; }
    }
    if (scol > max_ss) { max_ss = scol; }
    if (scol < min_ss) { min_ss = scol; }
    if (ssp > max_ct) { max_ct = ssp; }
    if (ssp < min_ct) { min_ct = ssp; }

    // 24h chart: WALL-CLOCK 5-min slot (0..287 = minute-of-day / 5), so the
    // x-axis lines up with real time. Each second overwrites the current slot
    // (sample-and-hold); d_pos = the current slot for WebChart's ring anchor.
    int di = (tasm_hour * 60 + tasm_minute) / 5;
    if (di >= 0 && di < DAY_LEN) {
        d_at[di] = enc_t(atmp);
        d_ss[di] = enc_t(scol);
        d_ct[di] = enc_t(ssp);
        d_kt[di] = enc_t(ktmp);
        d_bw[di] = enc_t(bw_ww);
        // WebChart's pos is the ring ANCHOR (oldest slot = one past the newest),
        // so the newest sample (slot di) renders at "now" not 24 h ago.
        d_pos = (di + 1) % DAY_LEN;
    }

    // weekly chart: WALL-CLOCK 30-min slot of the week (0..335). tasm_wday is
    // 1=Sun..7=Sat, so (wday-1)*24+hour = hour-of-week (0..167).
    int wi = (((tasm_wday - 1) * 24 + tasm_hour) * 60 + tasm_minute) / 30;
    if (wi >= 0 && wi < WEEK_LEN) {
        w_at[wi] = enc_t(atmp);
        w_ss[wi] = enc_t(scol);
        w_ct[wi] = enc_t(ssp);
        w_kt[wi] = enc_t(ktmp);
        w_bw[wi] = enc_t(bw_ww);
        w_pos = (wi + 1) % WEEK_LEN;
    }

    // --- Energy: bwwpc / sppc are ACCUMULATED kWh meters (UDP globals). Each slot
    // holds the kWh consumed WITHIN it = current total - total at the slot start.
    // Sample only once BOTH meters carry real values (skip startup zeros); a new
    // slot re-snapshots the base (the -1 sentinel forces it on the very first run).
    if (bwwpc != 0.0 && sppc != 0.0) {
        if (di >= 0 && di < DAY_LEN) {
            if (di != d_lastdi) { d_lastdi = di; d_bww_base = bwwpc; d_spp_base = sppc; }
            d_bww[di] = enc_p((bwwpc - d_bww_base) * 12000.0, P_WP_SCALE);  // kWh/5min -> W
            d_spp[di] = enc_p((sppc - d_spp_base) * 12000.0, P_SP_SCALE);
        }
        if (wi >= 0 && wi < WEEK_LEN) {
            if (wi != w_lastwi) { w_lastwi = wi; w_bww_base = bwwpc; w_spp_base = sppc; }
            w_bww[wi] = enc_p((bwwpc - w_bww_base) * 2000.0, P_WP_SCALE);   // kWh/30min -> W
            w_spp[wi] = enc_p((sppc - w_spp_base) * 2000.0, P_SP_SCALE);
        }
    }

    // Flush chart state to /sml_ebus.bin once per 5-min slot (bounded flash wear);
    // OnExit / CleanUp flush any remaining samples on restart / OTA.
    chart_dirty = 1;
    if (di != save_slot) { save_slot = di; sml_save(); }
}


void WebCall() {
    // ⚠️ Belongs HERE, not in WebUI(). A WebUI() callback renders on the /tc_ui
    // sub-page, and the main page only offers a button to that page when the
    // script registered one with webPageLabel() — a bare WebUI() gets none, so
    // the checkbox existed but nothing could reach it. WebCall() renders inline
    // on the main page, which is where a mode switch belongs anyway.
    webCheckbox(sml_activ, "Enable SML");
    byte buf[128];        // byte[] is a full drop-in for char[] as a text buffer
                         // (1.6.58) and costs 32 slots instead of 128.

    webSend("{s}<b style='color:#2196F3'>Aussentemperatur</b>{m}{e}");
    sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", atmp);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_at);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_at);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);

    webSend("{s}<b style='color:#FF5722'>Solarspeicher</b>{m}{e}");
    sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", scol);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_ss);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_ss);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);

    webSend("{s}<b style='color:#4CAF50'>Kollektortemperatur</b>{m}{e}");
    sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", ssp);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_ct);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_ct);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);

    sprintf(buf, "{s}<b style='color:#9C27B0'>Kellertemperatur</b>{m}<span style='color:#fff'>%.1f", ktmp);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);
    sprintf(buf, "{s}<b style='color:#FF9800'>Warmwassertemperatur</b>{m}<span style='color:#fff'>%.1f", bw_ww);
    strcat(buf, " &deg;C</span>{e}");
    webSend(buf);

    sprintf(buf, "{s}Heap{m}%d kB{e}", tasm_heap / 1024);
    webSend(buf);
}


void WebPage() {
    byte buf[128];        // byte[] is a full drop-in for char[] as a text buffer
                         // (1.6.58) and costs 32 slots instead of 128.
    int sr;
    int ss;
    int dl;
    sr = tasm_sunrise;
    ss = tasm_sunset;
    dl = ss - sr;

    // ─── Clock with JS auto-update + move to top of page ───
    // NOTE: this is deliberately NOT the shared `web_clock_header()`
    // subroutine from examples/clock_header.tc — that one re-renders
    // server-side on every WebCall poll (1–2s cadence). sml_ebus uses
    // a JS setInterval to tick the seconds smoothly *client-side*
    // without a server hit, important on a Modbus-heavy script where
    // every spare ms helps. If smoothness matters less than
    // consistency for your use case, replace this whole block with
    // a single `web_clock_header()` call (see clock_header.tc).
    webSend("<div id='tc_clock' style='text-align:center;background:#333;padding:8px;border-radius:8px;margin:8px 0'>");
    webSend("<span id='tc_clk' style='color:green;font-size:40px;font-weight:bold'></span><br>");
    webSend("<span id='tc_dat'></span><br>");
    // Sunrise / sunset (static, set once from server)
    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("</div>");
    // JS: update clock + date every second client-side
    webSend("<script>");
    webSend("var wd=['So','Mo','Di','Mi','Do','Fr','Sa'];");
    webSend("var mn=['Jan','Feb','Mrz','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];");
    webSend("function tc(){var d=new Date();");
    webSend("var h=('0'+d.getHours()).slice(-2);");
    webSend("var m=('0'+d.getMinutes()).slice(-2);");
    webSend("var s=('0'+d.getSeconds()).slice(-2);");
    webSend("document.getElementById('tc_clk').innerHTML=h+':'+m+':'+s;");
    webSend("document.getElementById('tc_dat').innerHTML=");
    webSend("wd[d.getDay()]+' '+d.getDate()+'. '+mn[d.getMonth()]+' '+d.getFullYear();");
    webSend("}tc();setInterval(tc,1000);");
    // move clock div before l1 (AJAX sensor content)
    webSend("var e=document.getElementById('tc_clock');");
    webSend("var l=document.getElementById('l1');");
    webSend("if(e&&l)l.parentNode.insertBefore(e,l);");
    webSend("</script>");
    webFlush();

    // Charts always render: the ring is indexed by wall-clock slot, so pass the
    // FULL length (24h / 7d axis). Un-recorded slots read 0.0 -> the WebChartJS
    // snippet turns those into null (a gap) so the line shows real data at its
    // real time instead of dropping to 0 C over the not-yet-filled slots.
    // Neutraler Rahmen: mit WebChartSize(0, ...) fuellt das Diagramm die
    // Karte — der alte Ausgleich margin-left:-30px schob es dann nur nach
    // links aus der Flucht der anderen Skripte (mi-hol, #111).
    webSend("<div>");
    WebChartSize(0, 300);   // 0 = volle Kartenbreite (#111)

    // --- 24h chart: 3 series, wall-clock 5-min slots, interval = 5 min ---
    // Every series carries its own WebChartQ: it applies to the NEXT WebChart
    // only and is cleared by it, so each one has to be repeated. All five
    // temperatures share the same encoding, which is what lets a single gap
    // sentinel work for the whole chart.
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "Temperaturen 24h", "Aussen|C", 0x2196F3, d_pos, DAY_LEN, d_at, 1, 5, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Speicher|C", 0xFF5722, d_pos, DAY_LEN, d_ss, 1, 5, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Kollektor|C", 0x4CAF50, d_pos, DAY_LEN, d_ct, 1, 5, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Keller|C", 0x9C27B0, d_pos, DAY_LEN, d_kt, 1, 5, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Warmwasser|C", 0xFF9800, d_pos, DAY_LEN, d_bw, 1, 5, 0.0, 0.0);
    WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==-30)dt.setValue(r,c,null);");   // -30 = T_NONE, the raw-0 gap

    // --- weekly chart: 3 series, wall-clock 30-min slots, interval = 30 min ---
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "Temperaturen Woche", "Aussen|C", 0x2196F3, w_pos, WEEK_LEN, w_at, 1, 30, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Speicher|C", 0xFF5722, w_pos, WEEK_LEN, w_ss, 1, 30, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Kollektor|C", 0x4CAF50, w_pos, WEEK_LEN, w_ct, 1, 30, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Keller|C", 0x9C27B0, w_pos, WEEK_LEN, w_kt, 1, 30, 0.0, 0.0);
    WebChartQ(T_SCALE, T_OFFS);
    WebChart(0, "", "Warmwasser|C", 0xFF9800, w_pos, WEEK_LEN, w_bw, 1, 30, 0.0, 0.0);
    WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==-30)dt.setValue(r,c,null);");   // -30 = T_NONE, the raw-0 gap

    // --- 24h consumption: avg power per 5-min slot in W (diff of accumulated kWh meters ×12000) ---
    WebChartQ(P_WP_SCALE, 0.0);
    WebChart(0, "Verbrauch 24h [W]", "Brauchw.-WP|W", 0xE91E63, d_pos, DAY_LEN, d_bww, 1, 5, 0.0, 0.0);
    WebChartQ(P_SP_SCALE, 0.0);
    WebChart(0, "", "Solarpumpe|W", 0x00BCD4, d_pos, DAY_LEN, d_spp, 1, 5, 0.0, 0.0);
    // dual y-axis: WP (~700 W) left, Solarpumpe (~10 W) right — each auto-scaled independently
    WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);o.series={0:{targetAxisIndex:0},1:{targetAxisIndex:1}};o.vAxes={0:{title:'WP'},1:{title:'Solar'}};delete o.vAxis;");

    // --- weekly consumption: avg power per 30-min slot in W (×2000) ---
    WebChartQ(P_WP_SCALE, 0.0);
    WebChart(0, "Verbrauch Woche [W]", "Brauchw.-WP|W", 0xE91E63, w_pos, WEEK_LEN, w_bww, 1, 30, 0.0, 0.0);
    WebChartQ(P_SP_SCALE, 0.0);
    WebChart(0, "", "Solarpumpe|W", 0x00BCD4, w_pos, WEEK_LEN, w_spp, 1, 30, 0.0, 0.0);
    // dual y-axis: WP left, Solarpumpe right — independent auto-scale
    WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);o.series={0:{targetAxisIndex:0},1:{targetAxisIndex:1}};o.vAxes={0:{title:'WP'},1:{title:'Solar'}};delete o.vAxis;");
    webSend("</div>");
}

// Flush chart history to /sml_ebus.bin on a clean shutdown (device Restart / OTA).
// Dirty-guarded so a freshly-imported .bin is never clobbered before any new sample.
void OnExit()  { if (chart_dirty) { sml_save(); } }
void CleanUp() { if (chart_dirty) { sml_save(); } }