Zum Inhalt

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

// ============================================================================
// Speicherung der Chart-Daten — .pvs (seit fw 1.6.42)
// ----------------------------------------------------------------------------
// ALLE Chart-Daten UND die Nullpunkte liegen jetzt in EINER Datei: der .pvs
// des Slots. Die Arrays oben sind `persist` deklariert; die Firmware stellt sie
// beim Laden wieder her und schreibt sie bei saveVars()/TinyCStop zurück.
//
// Warum die Umstellung (gemu 2026-07-25, nach ottelo9/tasmota-sml-images#50):
// Vorher lagen die Arrays in /sml_chart.bin, die Nullpunkte sml_dval/mval/yval
// aber in der .pvs. Wer die .bin sicherte und zurueckspielte, hatte die
// Historie — aber nicht die Bezugswerte. Der naechste 60-s-Tick rechnete dann
//     sml_dcon[d-1] = ein - sml_dval;    // sml_dval == 0
// und schrieb den KOMPLETTEN Zaehlerstand in den heutigen Balken. Ein
// Riesenausreisser, die Y-Achse skalierte mit, die ganze importierte Historie
// war optisch weg. mi-hol im selben Thread: „Wuerden diese Infos in den Dateien
// gespeichert, waere der Backup via Dateien trivial." Genau das ist es jetzt:
//
//     Sichern      = /sml_chart_pv.pvs kopieren (EINE Datei, alles drin)
//     Zurueckspielen = zurueckkopieren, Slot neu starten. Fertig.
//
// Kein Nullpunkt-Tanz, keine heikle Reihenfolge, keine doppelte Buchfuehrung.
// Vorbild ist energy_dashboard.tc, das schon so arbeitet.
//
// ⚠️ BRAUCHT FIRMWARE >= 1.6.42. Erst dort kommen heap-`persist`-Arrays ueber
//    Reload und Neustart zurueck (5a0f1f06f) und die .pvs ist namensbasiert
//    (PV3, 3763abf02) — Anhaengen/Umsortieren von persist-Variablen loescht
//    den Stand nicht mehr. Auf aelterer Firmware NICHT verwenden: dort kaemen
//    die Arrays als Nullen zurueck. Dann bei der .bin-Fassung bleiben
//    (git history).
// ============================================================================

// ── Einmalige Uebernahme aus der alten /sml_chart.bin ──────────────────────
// Bestandsgeraete haben ihre Historie noch in der .bin. Beim ersten Start nach
// dem Umstieg wird sie EINMAL eingelesen, in die .pvs uebernommen und dann in
// /sml_chart.bin.done umbenannt.
//
// Umbenannt, nicht geloescht: die Datei bleibt als Sicherung liegen, und ihr
// blosses Vorhandensein ist zugleich die Markierung „schon uebernommen" — es
// braucht keine zusaetzliche Zustandsdatei. (`fileRename` gab es in TinyC
// bisher nicht; sie ist fuer genau diesen Fall dazugekommen, gemu 2026-07-25.)
//
// Das ist zugleich der IMPORT-WEG: wer eine .bin aus sml_chart_editor.html
// einspielen will, laedt sie als /sml_chart.bin hoch und startet den Slot neu.
// Fertig — kein Nullpunkte-Setzen, keine Reihenfolge, kein Slot-stoppen-Tanz.
#ifndef CHART_CSV_DEC
#define CHART_CSV_DEC 3
#endif

void sml_chart_load() {
    int h = fileOpen("/sml_chart.bin", "r");
    if (h < 0) return;                         // nichts zu uebernehmen
    fileReadBin(h, sml_s4h,  481);
    fileReadBin(h, sml_s24h, 1441);
    fileReadBin(h, sml_dcon, 31);
    fileReadBin(h, sml_mcon, 12);
    fileClose(h);
    saveVars();                                 // ab jetzt gilt die .pvs
    if (fileRename("/sml_chart.bin", "/sml_chart.bin.done") < 0) {
        // Ziel liegt schon da (frueherer Import). Dann das alte Sicherungsstueck
        // weg und erneut umbenennen -- sonst wuerde die .bin bei JEDEM Start
        // wieder eingelesen und ueberschriebe neuere Daten.
        fileDelete("/sml_chart.bin.done");
        fileRename("/sml_chart.bin", "/sml_chart.bin.done");
    }
    addLog("sml_chart: .bin in die .pvs uebernommen, umbenannt in /sml_chart.bin.done");
}

// Sichern = die persist-Werte schreiben. Erfasst Arrays UND Nullpunkte in einem
// Zug — im Gegensatz zur alten .bin, die nur die Arrays kannte.
void sml_chart_save() {
    saveVars();
}

// Menschenlesbarer Export fuer Sicherung/Weitergabe (Tabellenkalkulation,
// sml_chart_editor.html). KEINE Speicherung — die laeuft ueber die .pvs.
// Absichtlich mit den Nullpunkten in der ersten Zeile: eine Sicherung ohne sie
// war ja gerade das Problem.
void sml_chart_export_csv() {
    int h = fileOpen("/sml_chart.csv", "w");
    if (h < 0) {
        addLog("sml_chart_export_csv: fileOpen fehlgeschlagen");
        return;
    }
    char kopf[96];
    sprintf(kopf, "%.3f\t%.3f\t%.3f\t%d\n", sml_dval, sml_mval, sml_yval, sml_da);
    fileWrite(h, kopf, strlen(kopf));
    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);
    fileClose(h);
    addLog("sml_chart: /sml_chart.csv geschrieben (Nullpunkte in Zeile 1)");
}

// Beim Herunterfahren die persist-Werte sichern:
//   CleanUp() — Geraete-Neustart / OTA (FUNC_SAVE_BEFORE_RESTART)
//   OnExit()  — dieser Slot gestoppt / neu gestartet / entfernt
// Die Firmware sichert persist bei TinyCStop ohnehin selbst; diese beiden sind
// der Guertel zum Hosentraeger fuer den Neustart-/OTA-Weg.
// Die frueher noetige sml_chart_dirty-Sperre ist entfallen: sie verhinderte,
// dass die Teardown-Sicherung eine frisch hochgeladene .bin ueberschreibt.
// Mit der .pvs gibt es diesen Wettlauf nicht mehr — der Import laeuft ueber die
// .mig-Marke und wird beim Laden angewandt, nicht beim Beenden.
void CleanUp() {
    saveVars();
}
void OnExit() {
    saveVars();
}

// ============================================================================
// 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;
    }
}

// ============================================================================
// 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;

    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.