Skip to content

sml_chart_common.tc

sml_chart_common.tc — shared chart + descriptor helpers for SML scripts

Source on GitHub

// ============================================================================
// sml_chart_common.tc — shared chart + descriptor helpers for SML scripts
// ============================================================================
//
// Helper library used by the sml_chart family (sml_chart.tc + later
// sml_chart_pv / sml_chart_bezug / sml_chart_modbus / sml_water /
// power_meter ports). Provides:
//
//   * Persist state for descriptor management, baselines and the rolling
//     chart arrays
//   * `sml_descriptor_apply()` — change-detect for rx/tx/filter/activ,
//     re-runs Sensor53 r + smlApplyPins on any flip (same logic as in
//     sml_simple.tc)
//   * Two-stage chart-array pump: filter ring at 5 s cadence → averaged
//     value pushed to the visible chart at 30 s / 60 s cadence (matches
//     ottelo's `s4hf/s24hf` moving-average idiom)
//   * `sml_chart_60s_tick()` — daily/monthly/yearly counter updates +
//     midnight rollover with month-wrap zero-fill
//   * `sml_chart_render_*()` — WebChart-driven 4 h / 24 h power line
//     charts + daily / monthly column charts
//   * `sml_chart_save() / load()` — binary persistence of the four
//     chart arrays to `/sml_chart.bin`
//   * `sml_render_settings_page()` — same Stromzähler / Pins / Filter
//     panel as sml_simple, kept central so all sml_chart_* variants
//     share one source of truth
//
// **No callbacks / no main() in here** — the including script wires the
// helpers into its own EverySecond / WebUI / WebCall callbacks.
//
// Storage footprint (all in PSRAM on ESP32-S3-PSRAM via special_calloc):
//   s4h:    481 ints       ≈ 1.9 KB     (4 h @ 30 s/sample)
//   s24h:  1441 ints       ≈ 5.6 KB     (24 h @ 60 s/sample)
//   dcon:    32 floats     ≈ 128 B
//   mcon:    13 floats     ≈ 52 B
//   filter rings, cursors, scratch                ≈ 200 B
//   ---------------------------------------------------------
//   Total chart state                              ≈ 8 KB
// ============================================================================

// Descriptor management (persist watch vars + sml_descriptor_apply +
// sml_render_settings_panel) lives in sml_descriptor.tc — one source of
// truth shared with sml_simple.tc and any future SML-flavoured script.
#include "sml_descriptor.tc"

// ── Energy baselines (delta against current SML kWh counter) ────────────────
persist float sml_dval;         // daily zero point
persist float sml_mval;         // monthly zero point
persist float sml_yval;         // yearly zero point
persist int   sml_da;           // last seen day-of-month (month-wrap detector)

// ── Rolling-power chart arrays — head index in a separate cursor ────────────
// MUST be float arrays — WebChart memcpy's each int32 slot into a float
// (vm.h SYS_WEB_CHART: `memcpy(&fval, &arr[idx], sizeof(float))`). Int
// arrays render as garbage values (bit-cast).
persist float sml_s4h[481];     // 4 h chart, 30 s per slot
persist float sml_s24h[1441];   // 24 h chart, 60 s per slot
persist int   sml_s4h_pos;
persist int   sml_s24h_pos;

// ── Daily / monthly columnar totals — 0-based indexing matching WebChart's
// ring-buffer walk (pos-count+i, mod count). Day 1 = index 0, Month 1 = 0.
persist float sml_dcon[31];
persist float sml_mcon[12];

// ── Runtime state — moving filter rings + tick counters ─────────────────────
int sml_s4hf[6];                // 6×5s = 30s average window for 4 h chart
int sml_s24hf[12];              // 12×5s = 60s average window for 24 h chart
int sml_f4_pos;
int sml_f24_pos;
int sml_t1;                     // 5s downcounter
int sml_t2;                     // 60s downcounter
int sml_hr_last;                // last seen tasm_hour (midnight edge detect)
// sml_buf is declared in sml_descriptor.tc and shared by both modules

// ============================================================================
// Save / load chart arrays
// ----------------------------------------------------------------------------
// By default the arrays are stored as compact BINARY (/sml_chart*.bin).
// Define CHART_CSV (uncomment below, or `#define CHART_CSV` before the
// #include in your main script) to instead store human-readable TAB-separated
// CSV (/sml_chart*.csv) you can open / read / edit in a spreadsheet and
// restore. CHART_CSV_DEC sets the decimal places (trailing zeros stripped).
// Note: the two formats use different filenames, so toggling starts a fresh
// chart file rather than misreading the other format.
// ============================================================================
// #define CHART_CSV
#ifndef CHART_CSV_DEC
#define CHART_CSV_DEC 3
#endif

// Set whenever the meter modifies the chart arrays (5s/60s ticks). Gates the
// OnExit/CleanUp flush so a freshly-imported .bin is never clobbered — see the
// comment on CleanUp()/OnExit() below. NOT persisted (it is run-state only).
int sml_chart_dirty = 0;

void sml_chart_save() {
#ifdef CHART_CSV
    int h = fileOpen("/sml_chart.csv", "w");
#else
    int h = fileOpen("/sml_chart.bin", "w");
#endif
    if (h < 0) {
        addLog("sml_chart_save: fileOpen failed");
        return;
    }
#ifdef CHART_CSV
    fileWriteArray(sml_s4h,  h, 481,  0, CHART_CSV_DEC);
    fileWriteArray(sml_s24h, h, 1441, 0, CHART_CSV_DEC);
    fileWriteArray(sml_dcon, h, 31,   0, CHART_CSV_DEC);
    fileWriteArray(sml_mcon, h, 12,   0, CHART_CSV_DEC);
#else
    fileWriteBin(h, sml_s4h,  481);
    fileWriteBin(h, sml_s24h, 1441);
    fileWriteBin(h, sml_dcon, 31);
    fileWriteBin(h, sml_mcon, 12);
#endif
    fileClose(h);
    sml_chart_dirty = 0;        // file now matches the in-RAM arrays
}

void sml_chart_load() {
    sml_chart_dirty = 0;        // about to (re)sync arrays from the file
#ifdef CHART_CSV
    int h = fileOpen("/sml_chart.csv", "r");
#else
    int h = fileOpen("/sml_chart.bin", "r");
#endif
    if (h < 0) return;          // no file yet — arrays stay at persist defaults
#ifdef CHART_CSV
    fileReadArray(sml_s4h,  h, 481);
    fileReadArray(sml_s24h, h, 1441);
    fileReadArray(sml_dcon, h, 31);
    fileReadArray(sml_mcon, h, 12);
#else
    fileReadBin(h, sml_s4h,  481);
    fileReadBin(h, sml_s24h, 1441);
    fileReadBin(h, sml_dcon, 31);
    fileReadBin(h, sml_mcon, 12);
#endif
    fileClose(h);
}

// Flush chart data to file when the script is going away, so the post-boot
// sml_chart_load() never reads a stale .bin and shows a gap:
//   CleanUp() — device Restart / OTA (FUNC_SAVE_BEFORE_RESTART). The .tas `>R`.
//   OnExit()  — this slot stopped / re-run / unlinked (per-slot teardown).
// GUARDED by sml_chart_dirty: only flush if the meter actually changed the
// arrays since the last load/save. This is essential for the "import new data,
// then restart" workflow — without the guard, the outgoing slot's teardown
// save overwrites a freshly-uploaded /sml_chart.bin *before* the new instance
// reads it (the clobber that made imports silently revert). With it, a
// stop → upload → run cycle preserves the import: the stop-save flushes and
// clears dirty, the upload overwrites the file, and the run-teardown sees
// dirty == 0 and skips. => Always upload the .bin while the slot is STOPPED.
void CleanUp() {
    if (sml_chart_dirty) sml_chart_save();
}
void OnExit() {
    if (sml_chart_dirty) sml_chart_save();
}

// ============================================================================
// Init helpers — reset baselines from current meter, reset chart arrays
// ============================================================================
void sml_chart_init_baselines() {
    float ein = smlGet(2);
    sml_dval = ein;
    sml_mval = ein;
    sml_yval = ein;
    sml_s4h_pos  = 0;
    sml_s24h_pos = 0;
    int i = 0;
    while (i < 481)  { sml_s4h[i]  = 0.0; i = i + 1; }
    i = 0;
    while (i < 1441) { sml_s24h[i] = 0.0; i = i + 1; }
    sml_chart_save();
    addLog("sml_chart: baselines set from meter, power charts cleared");
}

void sml_chart_init_columns() {
    int i = 0;
    while (i < 31) { sml_dcon[i] = 0.0; i = i + 1; }
    i = 0;
    while (i < 12) { sml_mcon[i] = 0.0; i = i + 1; }
    sml_chart_save();
    addLog("sml_chart: daily + monthly column charts cleared");
}

// ============================================================================
// 5-s tick — feed the moving-average filters and push to the rolling
// chart arrays. Mirrors ottelo's >S "alle 5s" block exactly.
// ============================================================================
void sml_chart_5s_tick() {
    int p = (int)smlGet(1);             // current power in W (int filter ring)
    sml_s4hf[sml_f4_pos] = p;
    sml_f4_pos = (sml_f4_pos + 1) % 6;
    sml_s24hf[sml_f24_pos] = p;
    sml_f24_pos = (sml_f24_pos + 1) % 12;

    int sum = 0;
    int i = 0;
    while (i < 6) { sum = sum + sml_s4hf[i]; i = i + 1; }
    float s4h_avg = (float)sum / 6.0;

    sum = 0;
    i = 0;
    while (i < 12) { sum = sum + sml_s24hf[i]; i = i + 1; }
    float s24h_avg = (float)sum / 12.0;

    // 240-slot ring at 1 min/slot = the last 4 h, sliding. Two fixes vs the old
    // ((hour-4)%24)*120 mapping: (1) that only produced idx4 < 480 for hours
    // 4..7, so outside 04:00-08:00 nothing was stored and the chart was empty;
    // (2) 1-min slots match the WebChart x-axis interval=1, so 240 points span a
    // real 4 h axis — the previous 480 30-s points were drawn over an 8 h axis.
    int idx4  = (tasm_hour * 60 + tasm_minute) % 240;
    int idx24 = tasm_hour * 60 + tasm_minute;
    if (idx4 >= 0 && idx4 < 240) {
        sml_s4h[idx4] = s4h_avg;
        sml_s4h_pos = idx4;
    }
    if (idx24 >= 0 && idx24 < 1440) {
        sml_s24h[idx24] = s24h_avg;
        sml_s24h_pos = idx24;
    }
    sml_chart_dirty = 1;        // meter modified the high-res arrays
}

// ============================================================================
// 60-s tick — daily / monthly counters + midnight rollover.
// Mirrors ottelo's >S "alle 60s" block (lines 244-278).
// ============================================================================
void sml_chart_60s_tick() {
    float ein = smlGet(2);
    int   d   = tasm_day;        // 1..31
    int   m   = tasm_month;      // 1..12

    if (d >= 1 && d <= 31) sml_dcon[d - 1] = ein - sml_dval;
    if (m >= 1 && m <= 12) sml_mcon[m - 1] = ein - sml_mval;
    sml_chart_dirty = 1;        // meter modified the daily/monthly counters

    int hr = tasm_hour;
    if (sml_hr_last != hr && hr == 0) {
        if (d > 1) {
            sml_da = d;
        } else {
            // Month-wrap — zero out days past the previous month's length.
            // sml_da still holds last-seen 1..31; zero indices [sml_da..30].
            int i = sml_da;
            while (i < 31) { sml_dcon[i] = 0.0; i = i + 1; }
            sml_mval = ein;
            sml_da = 1;
        }
        if (d == 1 && m == 1) sml_yval = ein;
        sml_dval = ein;
        sml_chart_save();
    }
    sml_hr_last = hr;
}

// ============================================================================
// Render helpers — chart + table blocks for the WebUI main page
// ============================================================================
void sml_chart_render_totals() {
    // Datum + Uptime (Paritaet zum .tas-Original; auf der Hauptseite gewuenscht)
    sprintf(sml_buf, "{s}Datum{m}%02d.%02d.%04d %02d:%02d{e}", tasm_day, tasm_month, tasm_year, tasm_hour, tasm_minute); webSend(sml_buf);
    int up_d = tasm_uptime / 86400;
    int up_h = (tasm_uptime / 3600) % 24;
    int up_m = (tasm_uptime / 60) % 60;
    sprintf(sml_buf, "{s}Uptime{m}%d d %d h %d min{e}", up_d, up_h, up_m); webSend(sml_buf);

    float ein = smlGet(2);
    sprintf(sml_buf, "{s}Tagesverbrauch{m}%.2f kWh{e}",   ein - sml_dval); webSend(sml_buf);
    sprintf(sml_buf, "{s}Monatsverbrauch{m}%.2f kWh{e}",  ein - sml_mval); webSend(sml_buf);
    sprintf(sml_buf, "{s}Jahresverbrauch{m}%.2f kWh{e}",  ein - sml_yval); webSend(sml_buf);
}

// Script JS hook applied to both rolling charts: filled area (better
// readability) + DE date in the tooltip (dd.MM.yyyy), independent of the
// browser locale. WebChartJS runs in the chart's draw scope (dt/o/el) and
// o.done=1 takes over the draw so it renders as an AreaChart.
char sml_chart_js[224];   // must hold the full snippet incl. trailing o.done=1

void sml_chart_area_de() {
    strcpy(sml_chart_js,
        "var f=new google.visualization.DateFormat({pattern:'dd.MM.yyyy HH:mm'});f.format(dt,0);o.areaOpacity=0.3;new google.visualization.AreaChart(el).draw(dt,o);o.done=1");
    WebChartJS(sml_chart_js);
}

void sml_chart_render_4h() {
    WebChartSize(600, 280);
    // 240 points at interval=1 min = a 4 h x-axis (matches the 240-slot 1-min
    // ring). Filled area + DE date via the hook.
    WebChart(0, "Verbrauch 4 Stunden [Watt]", "W", 0x3498db, sml_s4h_pos, 240, sml_s4h, 0, 1, 0.0, 0.0);
    sml_chart_area_de();
}

void sml_chart_render_24h() {
    WebChartSize(600, 280);
    WebChart(0, "Verbrauch 24 Stunden [Watt]", "W", 0x3498db, sml_s24h_pos, 1440, sml_s24h, 0, 1, 0.0, 0.0);
    sml_chart_area_de();
}

// ── Direct Google Charts emit — per-bar tri-color (past=green / today=red /
//    future=blue) and day-of-month x-axis. WebChart's built-in axis
//    formatter shows DD.MM. for >7 column points and doesn't support
//    per-bar styles, so we bypass it for the column charts. Loader is
//    already on the page from the WebChart line-chart calls above.
void sml_chart_render_days() {
    int today = tasm_day;
    if (today < 1) today = 1;
    webSend("<div id='sml_dch' style='text-align:center;width:600px;height:280px'></div>");
    webSend("<script>function _smlDD(){var d=google.visualization.arrayToDataTable([['Tag','Energie [kWh]',{role:'style'}]");
    int i = 1;
    while (i <= 31) {
        if (i < today)        sprintf(sml_buf, ",[%d,%.2f,'green']", i, sml_dcon[i - 1]);
        else if (i == today)  sprintf(sml_buf, ",[%d,%.2f,'red']",   i, sml_dcon[i - 1]);
        else                  sprintf(sml_buf, ",[%d,%.2f,'blue']",  i, sml_dcon[i - 1]);
        webSend(sml_buf);
        i = i + 1;
    }
    webSend("]);new google.visualization.ColumnChart(document.getElementById('sml_dch')).draw(d,{chartArea:{left:50,right:20,height:'75%',backgroundColor:'#f0f2f5'},legend:'none',title:'Tagesverbraeuche Monatsansicht',vAxis:{format:'# kWh'},hAxis:{title:'Tag',ticks:[1,5,10,15,20,25,30]}});}google.charts.setOnLoadCallback(_smlDD);</script>");
}

void sml_chart_render_months() {
    int thismon = tasm_month;
    if (thismon < 1) thismon = 1;
    // strToken needs the source as a resolvable char[] variable — string
    // literals fall through tc_resolve_ref and return empty (the earlier
    // version produced ['',12.3,...] with no month names on the x-axis).
    char mn_names[64];
    strcpy(mn_names, "Jan|Feb|Maer|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez");
    char mn[6];
    webSend("<div id='sml_mch' style='text-align:center;width:600px;height:280px'></div>");
    webSend("<script>function _smlDM(){var d=google.visualization.arrayToDataTable([['Monat','Verbrauch [kWh]',{role:'style'}]");
    int i = 1;
    while (i <= 12) {
        strToken(mn, mn_names, '|', i);
        if (i < thismon)        sprintf(sml_buf, ",['%s',%.2f,'green']", mn, sml_mcon[i - 1]);
        else if (i == thismon)  sprintf(sml_buf, ",['%s',%.2f,'red']",   mn, sml_mcon[i - 1]);
        else                    sprintf(sml_buf, ",['%s',%.2f,'blue']",  mn, sml_mcon[i - 1]);
        webSend(sml_buf);
        i = i + 1;
    }
    webSend("]);new google.visualization.ColumnChart(document.getElementById('sml_mch')).draw(d,{chartArea:{left:50,right:30,top:30,height:'70%',backgroundColor:'#f0f2f5'},legend:'none',title:'Monatsverbraeuche Jahresansicht',vAxis:{format:'# kWh'},hAxis:{slantedText:false,showTextEvery:1}});}google.charts.setOnLoadCallback(_smlDM);</script>");
}

// Settings panel renderer is in sml_descriptor.tc as
// `sml_render_settings_panel()` — including modules call that directly.