energy_dashboard.tc¶
energy_dashboard.tc — house-wide energy / climate dashboard
// @name: Energy Dashboard
// ─────────────────────────────────────────────────────────────────────
// 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 %
// Marstek Venus E SOC % — broadcast by marstek_venus.tc on .170. Receive-only, so
// declared WITHOUT an initializer (a `global float x = 0.0;` would broadcast =>x=0 at
// boot and clobber the owner). Same var the P4 solar_dashboard.tc consumes.
global float msoc;
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 — persisted in the slot .pvs (`persist` arrays) ────
// The four rolling chart arrays below (dvals / days_w / days_t /
// fast_h) and their ring cursors are declared `persist`, so the
// firmware restores them from the slot .pvs at load and re-writes them
// on saveVars(). This replaces the earlier dedicated /energy_dashboard.bin
// file: heap-backed persist arrays used to come back zero across a
// reload/reboot, but fw 1.6.42 (commit 5a0f1f06f) fixed heap-persist
// restore (restore-at-load + torn_down idempotent stop), and the PVS3
// name-keyed format (3763abf02) means appending/reordering vars no longer
// wipes the store. NOTE: this device therefore needs fw >= 1.6.42; on
// older firmware fall back to the .bin approach (see git history).
// 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.
persist float dvals[96];
persist 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).
persist float days_w[31];
persist float days_t[31];
persist int days_pos = 0;
// Per-minute heat-pump current trace (last 60 minutes).
persist float fast_h[60];
persist int fast_pos = 0;
// ── Solar / battery 24h charts — port of the P4 LCD solar_dashboard.tc ─────
// Two web charts that mirror what the P4 display shows:
// 1) the 4 solar inverters (Dach/Gartenhaus/Garage/Garten), power in kW
// 2) Powerwall + Marstek battery SOC (%)
// Rolling 24 h ring: 96 bins × 15 min. The bin IS the 15-min slot of the
// day (bin = (hour*60+minute)/15), like the heat-pump ring dvals[] above —
// never a free-running counter, or a reboot puts the ring permanently out
// of phase with the clock. sol_pos trails as "the NEXT slot to write",
// which is exactly what WebChart(pos,count) expects (it reads
// (pos+i)%count, newest at pos-1). Sampled on every 15-min boundary in
// EverySecond, persisted to the slot .pvs alongside the HP chart arrays
// (both share the one saveVars() write).
persist float inv0[96]; // Dach (Hausdach, sedc) kW
persist float inv1[96]; // Gartenhaus (wrgh) kW
persist float inv2[96]; // Garage (wrga) kW
persist float inv3[96]; // Garten (wrgg) kW
persist float soc_pw[96]; // Powerwall SOC %
persist float soc_ma[96]; // Marstek SOC %
persist int sol_pos = 0; // shared ring write cursor (0..95)
// 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 UdpCall
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
int last_m5 = -1; // for 15-minute solar/SOC history sample
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³)
// ── Tageswerte ab Mitternacht: Referenzen aus der Tagesdatenbank ────
// energy_backup.tc (Slot 1) legt jede Nacht um 00:20 die Tagesdatenbank von
// .61 auf die SD-Karte (/energy_m_log.txt). Ihre Zeile "D 0:00" traegt die
// Zaehlerstaende von heute Mitternacht -- die Referenz fuer "heute". Das haelt
// auch dann, wenn um Mitternacht kein UDP-Wert da war (am 23.09.2026 stand so
// "Verbrauch heute" auf dem ganzen Zaehlerstand) oder .118 neu startet.
// Bewusst NICHT persist: die Datei ist die einzige Quelle. Ein gespeicherter
// Stand koennte nach einem Neustart zum falschen Tag gehoeren und muesste
// ohnehin gegen sie geprueft werden. (Eine neue persist-Variable waere seit
// PVS3 harmlos -- die .pvs wird nach Namen geladen, nichts geht verloren.)
float rf_wb = 0.0; float rf_ga = 0.0; float rf_gh = 0.0; float rf_gg = 0.0;
float rf_sd = 0.0; float rf_zi = 0.0; float rf_zo = 0.0; float rf_ws = 0.0;
float rf_kp = 0.0; float rf_bw = 0.0; float rf_rain = 0.0; float rf_hwp = 0.0;
int rf_tag = -1; // Tagesnummer, fuer die rf_* gelten (-1 = keine)
int rf_next = 0; // Uptime des naechsten Leseversuchs
float e_wb[2]; float e_ga[2]; float e_gh[2]; float e_gg[2]; float e_sd[2];
float e_zi[2]; float e_zo[2]; float e_ws[2]; float e_kp[2]; float e_bw[2];
float e_rain[2]; float e_hwp[2]; float e_du[2];
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 — now via .pvs (persist arrays) ─────────
// The rolling chart arrays (dvals / days_w / days_t / fast_h) and their
// ring cursors are declared `persist` above, so the firmware writes them
// to the slot .pvs on saveVars() and restores them at slot load (before
// main runs). Heap-persist array restore was fixed in fw 1.6.42
// (commit 5a0f1f06f: restore-at-load + torn_down idempotent stop), so the
// old dedicated /energy_dashboard.bin file is no longer needed.
// energy_dash_save() is kept as a thin wrapper — a single saveVars() writes
// EVERY persist var (scalars + all chart arrays) at once — so the many
// existing call sites stay unchanged.
int ed_dirty = 0; // set on any chart-array change; gates teardown flush
void energy_dash_save() {
saveVars(); // one write persists every `persist` var (scalars + arrays)
ed_dirty = 0;
}
// ── Solar / SOC chart persistence — now via .pvs (persist arrays) ──────────
// inv0..3 / soc_pw / soc_ma / sol_pos are declared `persist` above and travel
// in the slot .pvs exactly like the HP chart arrays. solar_save() is a thin
// saveVars() wrapper (shares the one .pvs write with the HP arrays) so the
// sample + console call sites stay unchanged.
int sol_dirty = 0;
// Inverters report production with mixed sign (Dach positive, the other three
// negative = export). Chart magnitude is |value|, matching the P4.
float pabs(float v) { if (v < 0.0) { return 0.0 - v; } return v; }
void solar_save() {
saveVars(); // shares the single .pvs with the HP chart arrays
sol_dirty = 0;
}
// Flush chart data to the .pvs when this slot is going away so a restart
// restores the latest. GUARDED by the dirty flags so an unchanged slot
// doesn't rewrite the .pvs on every teardown. The heap-persist restore
// fix (fw 1.6.42) makes this safe across reload/reboot.
void CleanUp() { // device Restart / OTA (FUNC_SAVE_BEFORE_RESTART)
if (ed_dirty) energy_dash_save();
if (sol_dirty) solar_save();
}
void OnExit() { // this slot stopped / re-run / unlinked
if (ed_dirty) energy_dash_save();
if (sol_dirty) solar_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);
}
// Tageszeile, solange der Zaehlerstand nach einem Neustart noch nicht per UDP
// da ist (0): ein Strich statt "0 minus Referenz" = grosse negative Zahl.
void row_leer(char label[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>–</span>{e}", label);
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.
// Tagesnummer (Tage seit 1970, Ortszeit)
int tagNr() {
char t[24];
timeStamp(t);
return timeToSecs(t) / 86400;
}
// Zeile "heute 0:00" der Tagesdatenbank lesen -> rf_*. 1 = gefunden.
// Spalten (ab WB = 1): WB WR1 WR2 WR3, 7 x _a, SD ZWZI ZWZO H20 GAS,
// 8 x _a, WP BWWP RAIN HWP_a HWP.
int tagesRefs() {
char von[24];
timeStamp(von);
timeOffset(von, 0, 1); // heute 00:00
int fr = fileOpen("/energy_m_log.txt", "r");
if (fr < 0) { return 0; }
int rows = fileExtract(fr, von, von, 1, 1,
e_wb, e_ga, e_gh, e_gg,
e_du, e_du, e_du, e_du, e_du, e_du,
e_sd, e_zi, e_zo, e_ws, e_du,
e_du, e_du, e_du, e_du, e_du, e_du, e_du, e_du,
e_kp, e_bw, e_rain, e_du, e_hwp);
fileClose(fr);
if (rows < 1) { return 0; }
rf_wb = e_wb[0]; rf_ga = e_ga[0]; rf_gh = e_gh[0]; rf_gg = e_gg[0];
rf_sd = e_sd[0]; rf_zi = e_zi[0]; rf_zo = e_zo[0]; rf_ws = e_ws[0];
rf_kp = e_kp[0]; rf_bw = e_bw[0]; rf_rain = e_rain[0]; rf_hwp = e_hwp[0];
// die bestehenden Tageszeilen rechnen mit diesen Referenzen
was_m = rf_ws; zrz_m = rf_zi; auto_m = rf_wb; hwp_m = rf_hwp;
rf_tag = tagNr();
log_evt("Tagesreferenzen aus der Tagesdatenbank");
return 1;
}
void EverySecond() {
// Skip everything until NTP has set the clock — otherwise the
// midnight rollover would fire spuriously at boot.
if (tasm_year < 2025) return;
// Tagesreferenzen: nach dem Start und jeden Tag ab 00:40 (der Nachtlauf
// des Backups endet gegen 00:25); bis es klappt, alle 10 Minuten.
if (rf_tag != tagNr() && tasm_uptime >= 60 && tasm_uptime >= rf_next &&
(tasm_hour > 0 || tasm_minute >= 40)) {
rf_next = tasm_uptime + 600;
tagesRefs();
}
// Die HWP ist im Sommer aus, ihr Zaehler meldet dann 0 und kommt im Herbst
// mit dem alten Stand zurueck. Steht die Referenz auf 0, gilt der erste
// Wert des Tages -- sonst stuende der ganze Zaehler als "heute" da.
if (hwp_m <= 0.0 && t_hwp > 0.0) { hwp_m = t_hwp; rf_hwp = t_hwp; }
// 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(); // one saveVars(): 31-day arrays + 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 → .pvs (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;
}
// 15-minute solar/SOC history sample (24h ring, 96 bins) — the two charts
// ported from the P4 LCD (solar_dashboard.tc). Store inverter magnitude in
// kW, batteries in %. The bin is the time of day; the file is flushed on
// every sample (same cadence as the HP 15-min save).
int m5 = (tasm_hour * 60 + tasm_minute) / 15; // 0..95, wraps at midnight
if (m5 != last_m5) {
// ⚠️ THE SLOT IS THE TIME OF DAY, NOT A FREE-RUNNING CURSOR.
// This ring is 96 bins × 15 min = exactly 24 h, and m5 is the
// 15-min bin of the day, so bin == m5 is self-correcting: a
// reboot, a missed tick or a clock jump can never put the ring
// out of phase with the wall clock. The old code did
// `inv0[sol_pos]` with sol_pos++ per sample, and because
// last_m5 is not persisted EVERY reboot wrote one extra sample
// and advanced the cursor — so the two charts that share
// sol_pos drifted a slot per boot and never recovered. The
// heat-pump ring above (dvals[m15]) always did it this way,
// which is why it stayed correct while these two did not.
// Gap fill: the bins between the last one written and now
// belong to a time we were not measuring (power cut, reboot).
// Without this they would still hold yesterday's values.
int luecke = m5 - sol_pos;
if (luecke < 0) luecke = luecke + 96;
// A wrap of 95 means sol_pos sits one bin AHEAD of m5, i.e. this bin
// was already written and we are re-writing it after a reboot inside
// the same 15 min — not a 23¾-hour gap. Leave the ring alone.
if (luecke > 94) luecke = 0;
for (int k = 0; k < luecke; k = k + 1) {
int f = sol_pos + k; // < 192, so one wrap is enough
if (f >= 96) f = f - 96;
inv0[f] = 0.0;
inv1[f] = 0.0;
inv2[f] = 0.0;
inv3[f] = 0.0;
soc_pw[f] = 0.0;
soc_ma[f] = 0.0;
}
inv0[m5] = pabs(sedc) / 1000.0;
inv1[m5] = pabs(wrgh) / 1000.0;
inv2[m5] = pabs(wrga) / 1000.0;
inv3[m5] = pabs(wrgg) / 1000.0;
soc_pw[m5] = pwl;
soc_ma[m5] = msoc;
sol_pos = m5 + 1; // next slot to write = what WebChart wants
if (sol_pos >= 96) sol_pos = 0;
sol_dirty = 1;
solar_save();
last_m5 = m5;
}
}
// UDP-receive callback — fires when a known UDP global lands.
// We only need it as a watchdog tickle.
// ⚠️ THE NAME IS `UdpCall`. The driver looks up this exact string
// (xdrv_124_tinyc_vm.h: tc_vm_call_callback(&s->vm, "UdpCall")); a
// callback under any other name is simply never registered — it does not
// warn, it just never runs. This was `UdpReady` and so the watchdog was
// never tickled: "udp timeout" was logged every 60 s (and written to
// /log.txt every 60 s) while UDP was working perfectly the whole time.
void UdpCall() {
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;'>● %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, "🌞 %02d:%02d <--- %02d:%02d ---> %02d:%02d 🌙</td></tr>",
sr / 60, sr % 60,
dl / 60, dl % 60,
ss / 60, ss % 60);
webSend(scratch);
// ── Wasser ───────────────────────────────────────────────────
web_section("Wasser");
if (t_ws > 0.0) { row_i("Heute", (int)(was_c * 1000.0), "l"); } else { row_leer("Heute"); }
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");
if (rf_tag == tagNr()) {
if (train > 0.0) { row_f2("Heute", train - rf_rain, "mm"); } else { row_leer("Heute"); }
} else { 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");
if (zwzi > 0.0) { row_f4("Verbrauch heute", zwzi - zrz_m, "kWh"); } else { row_leer("Verbrauch heute"); }
row_f4("Einspeisung", zwzo, "kWh");
if (rf_tag == tagNr()) {
if (zwzo > 0.0) { row_f3("Einspeisung heute", zwzo - rf_zo, "kWh"); } else { row_leer("Einspeisung heute"); }
}
row_i ("aktueller Verbrauch", (int)zwzc, "W");
// ── Wallbox ──────────────────────────────────────────────────
web_section("Wallbox");
row_i ("aktuell", (int)auto, "W");
if (t_wb > 0.0) { row_f3("heute", t_wb - auto_m, "kWh"); } else { row_leer("heute"); }
row_f3("Total", t_wb, "kWh");
// ── Klima-Anlage ────────────────────────────────────────────
web_section("Klima-Anlage");
row_i ("aktuell", (int)(0.0 - klima), "W");
if (rf_tag == tagNr()) {
if (t_kpwp > 0.0) { row_f3("heute (+Pool)", t_kpwp - rf_kp, "kWh"); } else { row_leer("heute (+Pool)"); }
}
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");
if (rf_tag == tagNr()) {
if (sedt > 0.0) { row_f3("heute", sedt - rf_sd, "kWh"); } else { row_leer("heute"); }
}
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");
if (rf_tag == tagNr()) {
if (t_ga > 0.0) { row_f3("heute", t_ga - rf_ga, "kWh"); } else { row_leer("heute"); }
}
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");
if (rf_tag == tagNr()) {
if (t_gh > 0.0) { row_f3("heute", t_gh - rf_gh, "kWh"); } else { row_leer("heute"); }
}
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");
if (rf_tag == tagNr()) {
if (t_gg > 0.0) { row_f3("heute", t_gg - rf_gg, "kWh"); } else { row_leer("heute"); }
}
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");
if (rf_tag == tagNr()) {
if (sedt > 0.0 && t_ga > 0.0 && t_gh > 0.0 && t_gg > 0.0) {
row_f3("heute", (sedt - rf_sd) + (t_ga - rf_ga) + (t_gh - rf_gh) + (t_gg - rf_gg), "kWh");
} else { row_leer("heute"); }
}
// ── Powerwall — colored per metric ──────────────────────────
web_section("Powerwall");
sprintf(scratch,
// `%` 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% (%.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);
// ── Marstek Venus E ──────────────────────────────────────────
// Second home battery — SOC % broadcast by marstek_venus.tc on .170.
// Shown like the Powerwall "Batterie Füllstand" row above (%37; = '%').
web_section("Marstek Venus E");
sprintf(scratch, "{s}Batterie Füllstand{m}<span style='color:yellow;'>%d%</span>{e}", (int)msoc);
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");
// Daikin: energy_alarm.tc (Slot 5) fragt sie ab und legt den Wert im
// Share-Speicher ab; der UDP-Wert kam bisher vom Core2 (.76)
float sz = shtemp;
if (shareHas("shtemp")) { sz = shareGetFloat("shtemp"); }
row_f1("Temperatur", sz, "°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() {
// Volle Kartenbreite (0). Bis 23.09.2026 stand hier 640: die Juli-Firmware
// von .118 kannte das Karten-CSS noch nicht (body>div{width:min(96vw,960px)},
// ab TinyC 1.6.54), ihr Rahmen war INHALTSBREIT und wuchs mit jedem Diagramm
// um 40 px (gemessen 16.09.: 330, 370 … 490 px). Seit dem Firmware-Stand vom
// 23.09. sitzt der Rahmen fest, und 640 px wirkten in der 960-px-Karte schmal
// (gemu: "kleiner in x als zuvor").
webSend("<div>");
// ── Solar + battery, 24h/5min — the two charts from the P4 LCD display
// (solar_dashboard.tc). Placed first: headline energy view.
//
// Chart 1: the 4 solar inverters on ONE shared y-axis (kW). Multi-series =
// first WebChart with a title, the rest with "" (add to previous chart).
// The "name|unit" field routes the name to the legend, the unit to the
// y-axis; identical ymin/ymax on all series keeps them on one axis.
// decimals 1|8 = 1 decimal + smooth curve; interval 5 = 5 min/sample.
// Same colours/labels as the P4: Dach blue, GHaus red, Garage green,
// Garten amber.
WebChartSize(0, 300);
WebChart(0, "Solar Wechselrichter 24h", "Dach|kW", 0x4285f4, sol_pos, 96, inv0, 1 | 8, 15, 0.0, 5.0);
WebChart(0, "", "GHaus|kW", 0xea4335, sol_pos, 96, inv1, 1 | 8, 15, 0.0, 5.0);
WebChart(0, "", "Garage|kW", 0x34a853, sol_pos, 96, inv2, 1 | 8, 15, 0.0, 5.0);
WebChart(0, "", "Garten|kW", 0xfbbc04, sol_pos, 96, inv3, 1 | 8, 15, 0.0, 5.0);
// Google Charts squeezes a right-side legend against the fixed chartArea width and
// truncates every multi-series label to "..." — move it to the top (horizontal, roomy).
// Keep chartArea width as-is (widening it truncates the y-axis title on the SOC chart's
// 3-digit ticks). Runs per-chart via the WebChartJS hook (dt,o,el).
WebChartJS("o.legend={position:'top'};");
// Chart 2: Powerwall + Marstek SOC (%), one 0..100 axis. Powerwall green,
// Marstek blue (as on the P4). decimals 0|8 = whole % + smooth.
WebChartSize(0, 280);
WebChart(0, "Batterie SOC 24h", "Powerwall|%", 0x34a853, sol_pos, 96, soc_pw, 0 | 8, 15, 0.0, 100.0);
WebChart(0, "", "Marstek|%", 0x4285f4, sol_pos, 96, soc_ma, 0 | 8, 15, 0.0, 100.0);
WebChartJS("o.legend={position:'top'};");
// 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(0, 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(0, 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(0, 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);
// ── Live: die vier Wechselrichter, Chart.js-Streaming (vom Core2 .76,
// core2_energy.tc). Holt /tc_array jede Sekunde; der Endpunkt muss hier
// in Slot 0 stehen, die Firmware leitet webOn immer an Slot 0 weiter.
// In einer Funktion gekapselt, damit cfg/bgpl nicht mit _tcC & Co.
// zusammenstossen.
webSend("<div style='text-align:center'><b>Wechselrichter live</b></div>");
// Kartenbreite wie die Diagramme darueber: Chart.js misst den Rahmen
// (responsive), statt fest 640 px zu zeichnen.
webSend("<div><div style='position:relative;width:100%;height:300px;'><canvas id='schart'></canvas></div>");
webSend("<script src='https://cdn.jsdelivr.net/npm/chart.js@3.3.2'></script>");
webSend("<script src='https://cdn.jsdelivr.net/npm/luxon@1.27.0'></script>");
webSend("<script src='https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.0.0'></script>");
webSend("<script src='https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0'></script>");
webSend("<script>(function(){Chart.register(ChartStreaming);");
webSend("const bgpl={id:'cbg',beforeDraw:(c)=>{const x=c.canvas.getContext('2d');x.save();x.globalCompositeOperation='destination-over';x.fillStyle='lightgrey';x.fillRect(0,0,c.width,c.height);x.restore();}};");
webSend("const cfg={type:'line',plugins:[bgpl],data:{datasets:[");
webSend("{label:'Dach',backgroundColor:'Red',borderColor:'Red',fill:false,data:[]},");
webSend("{label:'Garage',backgroundColor:'Green',borderColor:'Green',fill:false,data:[]},");
webSend("{label:'Gartenhaus',backgroundColor:'Cyan',borderColor:'Cyan',fill:false,data:[]},");
webSend("{label:'Garten',backgroundColor:'Yellow',borderColor:'Yellow',fill:false,data:[]}");
webSend("]},options:{responsive:true,maintainAspectRatio:false,scales:{x:{type:'realtime',realtime:{delay:2000,duration:60000,frameRate:30,refresh:1000,pause:false,");
webSend("onRefresh:c=>{fetch('/tc_array').then(r=>r.json()).then(d=>{const a=d.array;var i=0;c.data.datasets.forEach(ds=>{ds.data.push({x:Date.now(),y:a[i++]});});});}");
webSend("}}},interaction:{intersect:false}}};");
webSend("cfg.options.plugins={annotation:false,datalabels:false,zoom:false};");
webSend("new Chart(document.getElementById('schart'),cfg);})();</script></div>");
webSend("</div>");
}
// /tc_array -> JSON mit den vier Wechselrichtern in W (wortgleich vom Core2)
void WebOn() {
int h;
h = webHandler();
if (h == 1) {
char b[48];
sprintf(b, "{\"array\":[%d,", sedc);
webSend(b);
sprintf(b, "%d,", 0 - wrga);
webSend(b);
sprintf(b, "%d,", 0 - wrgh);
webSend(b);
sprintf(b, "%d]}", 0 - wrgg);
webSend(b);
}
}
// ── 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 calls saveVars() (via energy_dash_save/solar_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, "FILLSOL") == 0) {
// Synthetic solar day + battery curve so the two P4-style charts show
// immediately (no 24h wait). Real 5-min samples overwrite it.
for (int i = 0; i < 96; i = i + 1) {
float t = 24.0 * i / 96.0; // hour-of-day 0..24
float hump = 0.0;
if (t > 6.0) { if (t < 20.0) {
float s = sin(3.14159265 * (t - 6.0) / 14.0);
hump = s * s;
} }
inv0[i] = 4.5 * hump; // Dach (biggest string)
inv1[i] = 2.0 * hump; // Gartenhaus
inv2[i] = 1.5 * hump; // Garage
inv3[i] = 1.0 * hump; // Garten
float soc;
if (t < 9.0) { soc = 30.0; }
else { if (t < 16.0) { soc = 30.0 + 65.0 * (t - 9.0) / 7.0; }
else { soc = 95.0 - 55.0 * (t - 16.0) / 8.0; } }
soc_pw[i] = soc;
soc_ma[i] = soc - 10.0;
}
sol_pos = 0;
sol_dirty = 1;
solar_save();
responseCmnd("ED: solar+SOC charts seeded (synthetic day)");
}
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();
for (int i = 0; i < 96; i = i + 1) {
inv0[i] = 0.0; inv1[i] = 0.0;
inv2[i] = 0.0; inv3[i] = 0.0;
soc_pw[i] = 0.0; soc_ma[i] = 0.0;
}
sol_pos = 0;
solar_save();
responseCmnd("ED: cleared HP + solar/SOC arrays");
}
else {
responseCmnd("ED: FILL24 | FILL31 | FILLALL | FILLSOL | CLEAR");
}
}
int main() {
addLog("ENERGY: dashboard starting");
addCommand("DASH"); // DASHFILL24 / FILL31 / FILLALL / FILLSOL / CLEAR
webOn(1, "/tc_array"); // Live-Diagramm der Wechselrichter
// Chart arrays are `persist` — the firmware restores them from the slot
// .pvs at load (before main runs), so no explicit load call is needed.
return 0;
}