energy_dashboard.tc¶
energy_dashboard.tc — house-wide energy / climate 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 %
global float sip = 0.0; // PW Netz (W)
global float sop = 0.0; // PW Solar (W)
global float bip = 0.0; // PW Batterie (W)
global float hip = 0.0; // PW Haus (W)
global float tcap = 0.0; // PW total capacity (Wh)
global float rcap = 0.0; // PW remaining capacity (Wh)
global float rper = 0.0; // PW remaining %
global float wtemp = 0.0; // Wohnzimmer Temp (°C)
global float whumi = 0.0; // Wohnzimmer Feuchte (%)
global float wtvoc = 0.0; // Wohnzimmer TVOC (ppb)
global float wco2 = 0.0; // Wohnzimmer CO2 (ppm)
global float aztemp = 0.0; // SZ G Temp (°C)
global float azhumi = 0.0; // SZ G Feuchte (%)
global float aztvoc = 0.0; // SZ G TVOC (ppb)
global float azeco2 = 0.0; // SZ G eCO2 (ppm)
global float bpress = 0.0; // Büro Luftdruck (hPa)
global float btemp = 0.0; // Büro Temperatur (°C)
global float bhumi = 0.0; // Büro Feuchte (%)
global float klima = 0.0; // Klima-Anlage (W, negative = consume)
global float pwp = 0.0; // Pool-WP (W)
global float t_kpwp = 0.0; // Klima+Pool total (kWh)
global float bwwp = 0.0; // Brauchwasser-WP total (kWh)
global float bwtwp = 0.0; // Brauchwasser-WP today (kWh)
global float ktmp = 0.0; // Kellertemperatur (°C)
global float hwp = 0.0; // Heizungs-WP current (W)
global float t_hwp = 0.0; // Heizungs-WP total (kWh)
global float train = 0.0; // Regen total (mm)
global float hrain = 0.0; // Regen heute (mm)
global float shtemp = 0.0; // Schlafzimmer Temp via UDP
global float phs1 = 0.0; // Phase 1 (W)
global float phs2 = 0.0; // Phase 2 (W)
global float phs3 = 0.0; // Phase 3 (W)
global float gerr = 0.0; // energy manager error flag
// Heat-pump live values — broadcast by examples/heatpump_map.tc on
// the device that runs the Modbus client. Pulled via UDP global so
// the dashboard can show pump status without each device talking
// Modbus to the heat pump itself.
global float hp_run = 0.0; // 1.0 = running, 0.0 = off (r217 enum)
global float hp_in = 0.0; // Puffer / return water (°C, r188)
global float hp_out = 0.0; // Ausgangstemp / supply water (°C, r191)
global float hp_at = 0.0; // HP's own outside-air sensor (°C, r190)
// ── Persist (snapshot at midnight, used to compute "today" deltas) ──
// Scalar baselines, NOT chart data — scalar persist (.pvs) is reliable,
// so these stay `persist`.
persist float gas_m = 0.0;
persist float was_m = 0.0;
persist float hwp_m = 0.0;
persist float zrz_m = 0.0; // not float-y but kept for symmetry
persist float auto_m = 0.0;
// ── Chart arrays — stored in a dedicated binary file, NOT in .pvs ───
// The four rolling chart arrays below (dvals / days_w / days_t /
// fast_h) and their ring cursors are persisted to
// /energy_dashboard.bin via energy_dash_save()/energy_dash_load() —
// the same fileWriteBin/fileReadBin approach the sml_chart family uses
// (see sml_chart_common.tc). They are deliberately NOT `persist`:
// heap-backed persist arrays proved unreliable across reboot and device
// migration, so chart state lives in one self-contained file we load
// explicitly in main() (after the arrays are allocated) and flush on
// data-commit + teardown. Because the ring cursors travel in the file
// header too, the whole chart history transfers by copying this single
// file — no .pvs dependency.
// 24h heat-pump kWh trace, 15-minute resolution. Plain ring buffer:
// slot N = kWh consumed during 15-min window N. dvals_pos = current
// write cursor (0..95). The Scripter version used dvals[0] as the
// cursor and 1..96 as data; we use a separate scalar instead so the
// array lays out cleanly for WebChart() consumption.
float dvals[96];
int dvals_pos = 0;
// Display-only scratch — 24 hourly averages computed from dvals[]
// for the "Heizungs-WP 24h" chart. Each entry is the sum of 4
// consecutive 15-min kWh slots, which equals the average kW over
// that hour (since 1 h = 4 × 15 min and ∑kWh / 1h = kW). Filled
// fresh each WebPage() render. NOT persisted.
float dvals_kw[24];
// Display-only scratch — fast_h[] is W; chart shows kW. Conversion
// is fast_h[i] / 1000.0. Filled fresh each WebPage() render.
float fast_h_kw[60];
// 31-day historical: parallel arrays of (heat-pump kWh, average temp).
float days_w[31];
float days_t[31];
int days_pos = 0;
// Per-minute heat-pump current trace (last 60 minutes).
float fast_h[60];
int fast_pos = 0;
// Vattenfall meter offset captured 2023-09-01 — fixed reference,
// never overwritten. Kept in source for a public example; if the
// real value should be private, move to /energy.cfg.
int zrz_o = 21399;
// Gas/water price ceilings (EUR/cent). Used only for display rows.
int gpe = 12; // gas cent/kWh
float spe = 0.30; // strom EUR/kWh (Vattenfall reference)
// ── Plain scalars (not persisted, not UDP) ─────────────────────────
int udp_timer = 60; // counts down in EverySecond; reset by UdpReady
int boot_logged = 0; // first-tick log marker
int last_hour = -1; // for hour-change detection
int last_m15 = -1; // for 15-minute slot transition
int last_minute = -1; // for per-minute fast trace
float oavgt = 0.0; // averaged temp snapshot (computed every 10s)
float dsel = 0.0; // last t_hwp seen at slot rollover (for delta calc)
float c_hwp = 0.0; // 15-minute heat-pump kWh delta
float gas_c = 0.0; // gas today (m³)
float was_c = 0.0; // water today (m³)
char scratch[256]; // sprintf workspace — sized for the
// longest row in WebPage's 31-day table
// (~169 chars/row with inline styles)
// ── Life indicator ───────────────────────────────────────────────
// Increments every WebCall (~every 2 s, on the AJAX `/?m=1` poll
// that drives the live block re-render). Rendered next to the
// clock so a stuck WebUI is visually obvious — clock alone isn't
// enough since time-of-day can jump backwards or stall on RTC drift.
int web_tick = 0;
// ── Helpers ────────────────────────────────────────────────────────
//
// Append a single line to /log.txt with a timestamp. The original
// Scripter script appends without bound — on a 320 KB filesystem we
// can't afford that, so we cap at LOG_MAX bytes and truncate on
// rollover (drop the file, start fresh) instead of partial-trim.
//
// Cap is intentionally low — events are rare (boot, UDP errors,
// midnight rollover) so 4 KB holds many days of history.
void log_evt(char msg[]) {
if (fileSize("/log.txt") > 4096) {
fileDelete("/log.txt");
}
int h = fileOpen("/log.txt", 2); // append mode
if (h < 0) return;
char tsbuf[32];
timeStamp(tsbuf); // local time, "YYYY-MM-DDTHH:MM:SS"
// Build the line in a single buffer — fileWrite with a string literal
// emits SYS_FILE_WRITE_STR (id 276) which has no VM handler yet
// (compiler/runtime mismatch — see MEMORY.md). Going via a char[]
// routes through SYS_FILE_WRITE which works.
char line[160];
sprintf(line, "%s\t%s\n", tsbuf, msg);
fileWrite(h, line, strlen(line));
fileClose(h);
}
// ── Chart-array persistence — dedicated binary file (NOT .pvs) ──────
//
// /energy_dashboard.bin layout (all int32 slots; floats bit-stored,
// exactly as WebChart reads them back):
// [0..3] header: magic, dvals_pos, days_pos, fast_pos
// [4..] dvals[96], days_w[31], days_t[31], fast_h[60]
//
// energy_dash_load() runs once in main() after the arrays exist;
// energy_dash_save() is called at each data-commit point (15-min slot,
// midnight rollover, console fill/clear) and flushed on teardown.
#define ED_MAGIC 0x45440001 // 'ED' + format version 1
int ed_hdr[4]; // marshalling buffer for the file header
int ed_dirty = 0; // set on any chart-array change; gates teardown flush
void energy_dash_save() {
int h = fileOpen("/energy_dashboard.bin", "w");
if (h < 0) { addLog("ENERGY: chart save fileOpen failed"); return; }
ed_hdr[0] = ED_MAGIC;
ed_hdr[1] = dvals_pos;
ed_hdr[2] = days_pos;
ed_hdr[3] = fast_pos;
fileWriteBin(h, ed_hdr, 4);
fileWriteBin(h, dvals, 96);
fileWriteBin(h, days_w, 31);
fileWriteBin(h, days_t, 31);
fileWriteBin(h, fast_h, 60);
fileClose(h);
ed_dirty = 0; // file now matches the in-RAM arrays
}
void energy_dash_load() {
ed_dirty = 0;
int h = fileOpen("/energy_dashboard.bin", "r");
if (h < 0) return; // no file yet — arrays stay zero
fileReadBin(h, ed_hdr, 4);
if (ed_hdr[0] != ED_MAGIC) { // unknown/garbage file — ignore it
fileClose(h);
addLog("ENERGY: chart file bad magic, ignored");
return;
}
dvals_pos = ed_hdr[1];
days_pos = ed_hdr[2];
fast_pos = ed_hdr[3];
fileReadBin(h, dvals, 96);
fileReadBin(h, days_w, 31);
fileReadBin(h, days_t, 31);
fileReadBin(h, fast_h, 60);
fileClose(h);
addLog("ENERGY: chart arrays loaded from /energy_dashboard.bin");
}
// Flush chart data when this slot is going away so a post-boot load
// never reads stale data. GUARDED by ed_dirty so a freshly-uploaded
// .bin (import while the slot is STOPPED) is not clobbered by the
// outgoing instance's teardown save — same rule as sml_chart_common.
// => Always upload /energy_dashboard.bin while the slot is STOPPED.
void CleanUp() { // device Restart / OTA (FUNC_SAVE_BEFORE_RESTART)
if (ed_dirty) energy_dash_save();
}
void OnExit() { // this slot stopped / re-run / unlinked
if (ed_dirty) energy_dash_save();
}
// One sensor section — two rows: a horizontal rule and a magenta
// section heading. Mirrors the Scripter `#head(hdr)` subroutine.
void web_section(char title[]) {
sprintf(scratch, "{s}<hr>{m}<hr>{e}{s}<span style='color:magenta;'>%s</span>{m}{e}", title);
webSend(scratch);
}
// Yellow-valued data row helpers, one per output precision. The
// Scripter source uses `%0X`, `%1X`, `%2X`, `%3X`, `%4X` — keeping a
// helper per precision keeps the per-row cost to one sprintf+webSend.
void row_i(char label[], int v, char unit[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%d %s</span>{e}", label, v, unit);
webSend(scratch);
}
void row_f1(char label[], float v, char unit[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.1f %s</span>{e}", label, v, unit);
webSend(scratch);
}
void row_f2(char label[], float v, char unit[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.2f %s</span>{e}", label, v, unit);
webSend(scratch);
}
void row_f3(char label[], float v, char unit[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.3f %s</span>{e}", label, v, unit);
webSend(scratch);
}
void row_f4(char label[], float v, char unit[]) {
sprintf(scratch, "{s}%s{m}<span style='color:yellow;'>%.4f %s</span>{e}", label, v, unit);
webSend(scratch);
}
// ── EverySecond — cron-ish bookkeeping ────────────────────────────
//
// The original Scripter `>S` block ran every second. This callback
// is the same. Logic preserved 1:1 modulo the dropped audio paths.
void EverySecond() {
// Skip everything until NTP has set the clock — otherwise the
// midnight rollover would fire spuriously at boot.
if (tasm_year < 2025) return;
// First-tick boot log.
if (boot_logged == 0) {
log_evt("boot");
boot_logged = 1;
}
// UDP watchdog — reset to 60 each tick that UdpReady fires
// (callback below). If 60 ticks elapse without a packet, the
// sender is probably gone; log and (potentially) re-subscribe.
if (udp_timer > 0) udp_timer = udp_timer - 1;
if (udp_timer == 0) {
log_evt("udp timeout");
udp_timer = 60;
}
// Every-10-seconds: snapshot avgt → oavgt for midnight use.
if (tasm_uptime % 10 == 0) {
oavgt = avgt;
}
// Hourly trigger.
int hr = tasm_hour;
if (hr != last_hour) {
if (hr == 0 && last_hour >= 0) {
// Midnight rollover — record yesterday's heat-pump kWh
// and average outdoor temp into the 31-day rolling buffer.
days_w[days_pos] = (bwtwp + t_hwp) - hwp_m;
days_t[days_pos] = oavgt;
days_pos = days_pos + 1;
if (days_pos >= 31) days_pos = 0;
ed_dirty = 1;
// Snapshot meter values so today-counters reset.
was_m = t_ws;
hwp_m = t_hwp;
zrz_m = zwzi;
auto_m = t_wb;
energy_dash_save(); // 31-day arrays → /energy_dashboard.bin
saveVars(); // scalar baselines → .pvs
log_evt("midnight rollover");
}
last_hour = hr;
}
// Today-deltas always recomputed (cheap, used in WebCall).
was_c = t_ws - was_m;
// 15-minute slot index 0..95 → daily heat-pump kWh trace.
// Guarded by `t_hwp > 0.0` — if the heat-pump UDP global hasn't
// arrived yet (fresh boot, sender device down), we MUST NOT
// baseline `dsel` to 0, otherwise the next slot rollover does
// `c_hwp = real_lifetime_kWh - 0` = a huge spurious value (a
// visible "13 kWh peak" with everything else getting auto-scaled
// into the noise floor). Wait until t_hwp is real.
int m15 = (tasm_hour * 60 + tasm_minute) / 15; // 0..95
if (m15 != last_m15 && t_hwp > 0.0) {
if (last_m15 >= 0) {
// kWh consumed *in this 15-min slot*.
// Sanity-clamp: heat-pump physical max is ~4 kW, so per
// 15-min slot can't exceed 1 kWh; we cap at 2 kWh
// (covers double-pump / future bigger units). Negative
// (meter wrap / sensor reset) or out-of-range deltas
// are dropped — the slot keeps its previous value.
float delta = t_hwp - dsel;
if (delta >= 0.0 && delta < 2.0) {
c_hwp = delta;
dvals[m15] = c_hwp;
dvals_pos = m15;
ed_dirty = 1;
energy_dash_save(); // 24h array → file (15-min cadence)
}
dsel = t_hwp;
} else {
// First observation since boot — just establish baseline.
dsel = t_hwp;
dvals_pos = m15;
}
last_m15 = m15;
}
// Per-minute fast trace (heat-pump current power) — sliding window.
// Instead of a ring buffer (which makes a partially-filled chart
// appear "centered" because zero-padding lives at indices not yet
// visited), we shift the array left by one slot every minute and
// append the latest sample at slot 59. Then slot 0 is always the
// oldest reading, slot 59 is "now", and pre-fill shows up as
// zero-padding on the LEFT side of the chart (right-aligned data).
int mn = tasm_minute;
if (mn != last_minute) {
for (int i = 0; i < 59; i = i + 1) fast_h[i] = fast_h[i + 1];
fast_h[59] = hwp;
fast_pos = 60; // sentinel — tells WebChart "render slots 0..59"
ed_dirty = 1; // committed at the next 15-min save / teardown
last_minute = mn;
}
}
// UDP-receive callback — fires when a known UDP global lands.
// We only need it as a watchdog tickle.
void UdpReady() {
udp_timer = 60;
}
// ── Main page sensor table — port of >WS + html_sub.txt ───────────
//
// Each `web_section` call emits a horizontal-rule + magenta heading.
// Each `row_*` call emits one yellow-valued row in {s}label{m}val{e}
// format. Rows that need richer markup (Powerwall colored values,
// time block, sunrise/sunset emoji line) build the HTML inline with
// sprintf + webSend.
void WebCall() {
// ── Section header — frames this slot's output as a visually
// distinct block. Pattern lifted from core2_energy.tc:
// `<tr><td colspan=2 style='background:#333...'>` rounded card.
// Sister script pool_pump.tc emits its own header to keep the
// two slots cleanly separated on the shared / page.
// Section banner — distinct from the Tasmota blue (#1fa3ec)
// button color so it doesn't read as tappable. Dark slate fill,
// orange left-accent bar, sharp corners → "section divider", not
// "button". Same style used in pool_pump.tc.
webSend("{s}<hr>{m}<hr>{e}<tr><td colspan=2 style='text-align:left;background:#34495e;color:#fff;padding:8px 12px;border-left:6px solid #e67e22;border-radius:2px;font-size:1.4em;font-weight:bold;letter-spacing:0.5px;'>Energie-Dashboard</td></tr>");
// ── Top: time, weekday, date, sunrise/sunset ─────────────────
// Day-of-week abbreviation (de). tasm_wday is 1=Sun..7=Sat.
// Indexed via strToken(dst, src, delim, n) — 1-based lookup,
// lines up directly with tasm_wday (1=Sun..7=Sat) and tasm_month
// (1..12). See examples/clock_header.tc for the canonical block.
char wd_names[] = "So|Mo|Di|Mi|Do|Fr|Sa";
char wd_label[4];
strToken(wd_label, wd_names, '|', tasm_wday);
// Month abbreviation (de) — tasm_month is 1..12.
char mo_names[] = "Jan|Feb|Mar|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez";
char mo_label[4];
strToken(mo_label, mo_names, '|', tasm_month);
web_tick = web_tick + 1;
// Single dark-grey rounded card spanning both columns. Two
// sprintfs because the full card exceeds 256-byte scratch.
sprintf(scratch, "<tr><td colspan=2 style='text-align:center;background:#333;padding:8px;border-radius:8px'><span style='color:green;font-size:40px;font-weight:bold'>%02d:%02d:%02d</span><br>%s %d. %s %d <span style='font-size:0.7em;color:#888;'>● %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");
row_i("Heute", (int)(was_c * 1000.0), "l");
row_f4("Zählerstand", t_ws, "m³");
// ── Solarthermie ─────────────────────────────────────────────
web_section("Solarthermie");
row_f1("Außentemperatur", atmp, "°C");
row_f2("Durchschnittstemperatur", avgt, "°C");
row_f2("Kellertemperatur", ktmp, "°C");
row_f1("Solarkollektor", scol, "°C");
row_f1("Solarspeicher", ssp, "°C");
// ── Regen ────────────────────────────────────────────────────
web_section("Regen");
row_f2("Heute", hrain, "mm");
row_i ("Total", (int)train, "mm");
// ── Haupt Hauszähler ────────────────────────────────────────
web_section("Haupt Hauszähler");
row_f4("Verbrauch", zwzi, "kWh");
row_f4("Vattenfall Verbrauch", zwzi - (float)zrz_o, "kWh");
row_f4("Verbrauch heute", zwzi - zrz_m, "kWh");
row_f4("Einspeisung", zwzo, "kWh");
row_i ("aktueller Verbrauch", (int)zwzc, "W");
// ── Wallbox ──────────────────────────────────────────────────
web_section("Wallbox");
row_i ("aktuell", (int)auto, "W");
row_f3("heute", t_wb - auto_m, "kWh");
row_f3("Total", t_wb, "kWh");
// ── Klima-Anlage ────────────────────────────────────────────
web_section("Klima-Anlage");
row_i ("aktuell", (int)(0.0 - klima), "W");
row_f3("Total (+Pool)", t_kpwp, "kWh");
// ── Pool-WP ──────────────────────────────────────────────────
web_section("Pool-WP");
row_i("aktuell", (int)(0.0 - pwp), "W");
// ── Brauchwasser-WP ─────────────────────────────────────────
web_section("Brauchwasser-WP");
row_f2("Heute", bwtwp, "kWh");
row_i ("Total", (int)bwwp, "kWh");
// ── Heizungs-WP ─────────────────────────────────────────────
// Status / water in / water out / outside come via UDP from the
// device running examples/heatpump_map.tc (Modbus to the actual
// heat-pump controller). hp_run is 1.0 when r217 == 1 (running),
// 0.0 otherwise (booting / remote-off).
web_section("Heizungs-WP");
char hp_st_buf[8];
if (hp_run > 0.5) { strcpy(hp_st_buf, "AN"); }
else { strcpy(hp_st_buf, "AUS"); }
sprintf(scratch, "{s}Status{m}<span style='color:yellow;'>%s</span>{e}", hp_st_buf);
webSend(scratch);
row_f1("Wasser ein", hp_in, "°C");
row_f1("Wasser aus", hp_out, "°C");
row_f1("Aussen (HP)", hp_at, "°C");
row_f2("Heute", t_hwp - hwp_m, "kWh");
row_i ("aktuell", (int)hwp, "W");
row_f3("Total", t_hwp, "kWh");
// ── Solar Hausdach (5.25 KW) ────────────────────────────────
web_section("Solar Hausdach, 5.25 KW");
row_i ("aktuell", (int)sedc, "W");
row_f3("Total", sedt, "kWh");
// ── Solar Garage (2.42/3.45 KW) ─────────────────────────────
web_section("Solar Garage, 2.42/3.45 KW");
row_i ("aktuell", (int)-wrga, "W");
row_f3("Total", t_ga, "kWh");
// ── Solar Gartenhaus (1.96/2.8 KW) ──────────────────────────
web_section("Solar Gartenhaus, 1.96/2.8 KW");
row_i ("aktuell", (int)-wrgh, "W");
row_f3("Total", t_gh, "kWh");
// ── Solar Garten (2.87/4.1 KW) ──────────────────────────────
web_section("Solar Garten, 2.87/4.1 KW");
row_i ("aktuell", (int)-wrgg, "W");
row_f3("Total", t_gg, "kWh");
// ── Solar Gesamt ────────────────────────────────────────────
web_section("Solar Gesamt, 7.25/10.35 KW");
row_i ("aktuell", (int)(0.0 - (wrga + wrgh + wrgg)), "W");
// ── Powerwall — colored per metric ──────────────────────────
web_section("Powerwall");
sprintf(scratch,
// `%` 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);
// ── Büro ─────────────────────────────────────────────────────
web_section("Büro");
row_f1("Temperatur", btemp, "°C");
row_f1("Feuchte", bhumi, "%");
row_f1("Luftdruck", bpress, "hPa");
// ── Schlafzimmer ────────────────────────────────────────────
web_section("Schlafzimmer");
row_f1("Temperatur", shtemp, "°C");
// ── Schlafzimmer G ──────────────────────────────────────────
web_section("Schlafzimmer G");
row_f1("Temperatur", aztemp, "°C");
row_f1("Feuchte", azhumi, "%");
row_i ("TVOC", (int)aztvoc, "ppb");
row_i ("eCO2", (int)azeco2, "ppm");
// ── Wohnzimmer ───────────────────────────────────────────────
web_section("Wohnzimmer");
row_f1("Temperatur", wtemp, "°C");
row_f1("Feuchte", whumi, "%");
row_i ("TVOC", (int)wtvoc, "ppb");
row_i ("CO2", (int)wco2, "ppm");
// ── Phasen ───────────────────────────────────────────────────
web_section("Phasen");
row_i("Phase 1", (int)phs1, "W");
row_i("Phase 2", (int)phs2, "W");
row_i("Phase 3", (int)phs3, "W");
// ── System ───────────────────────────────────────────────────
web_section("System");
sprintf(scratch, "{s}Filesystem frei{m}<span style='color:yellow;'>%.2f kB</span>{e}",
(float)fsInfo(1));
webSend(scratch);
sprintf(scratch, "{s}System Heap{m}<span style='color:yellow;'>%d kB</span>{e}",
tasm_heap / 1024);
webSend(scratch);
// Reload control — small in-row link instead of a full-width
// button, less visually intrusive. Triggers location.reload() so
// WebPage() re-runs and the charts pick up the latest array data.
webSend("{s}{m}<a href='#' onclick='location.reload();return false;' style='color:#1fa3ec;'>↻ Charts aktualisieren</a>{e}");
}
// ── Charts + 31-day table (rendered once on full page load) ───────
//
// Why this lives in WebPage and NOT in WebCall:
//
// Tasmota's main `/` page fetches `/?m=1` every ~2.3 s and updates
// the live area via `el.innerHTML = response`. Setting innerHTML
// PARSES <script> tags but does NOT execute them — that's a
// browser-side rule. Google Charts requires its setup script to
// run, so chart containers placed in WebCall would render as empty
// boxes. WebPage runs at full page-load (FUNC_WEB_ADD_MAIN_BUTTON)
// where <script> tags ARE executed normally.
//
// Trade-off: WebPage content always lands BELOW everything emitted
// in any slot's WebCall, so the visual order is
// slot 0 WebCall (energy) → slot 1 WebCall (pool) → WebPage charts
// rather than the more natural energy → charts → pool. To redraw
// with fresh persist data, click the "↻ Charts aktualisieren" link
// in the energy block — it triggers location.reload() so WebPage()
// runs again.
void WebPage() {
// ottelo's chart-centering compensation (margin-left:-30px wrapper)
webSend("<div style='margin-left:-30px'>");
// Order (top→bottom): 60-min chart (W), 24-hour chart (kW),
// 31-day chart (kWh + avg temp dual-axis), 31-day table.
// Rationale: shortest timeframe at the top, longest at the
// bottom; same horizontal scale (640×280) so they line up; the
// table closes the page as a precise read-out of the same data
// the dual-axis chart visualizes.
//
// ── decimals encoding: low 3 bits = decimal places, bit 3 (=+8)
// = enable curve smoothing. So 1 decimal + smoothing = 1|8 = 9.
// ── 60-minute heat-pump current power (kW, 1-min samples).
// fast_h[] holds the raw W readings; convert to kW for display
// so the unit matches the 24-h chart below. fast_pos=60 is a
// sentinel set by EverySecond's shift-append code; it tells
// WebChart's ring-unwind to read slots 0..59 in order, so
// partial-fill leaves zero-padding at the left and real data
// lines up against the right edge of the chart.
//
// y-axis: anchor at 0, top = max(observed) × 1.2, floor 0.1 kW
// so an idle pump still renders a visible line instead of
// flat-on-axis. Fixed lower bound prevents Google Charts'
// smoothing curve from overshooting into negatives.
float fmax = 0.1;
for (int i = 0; i < 60; i = i + 1) {
fast_h_kw[i] = fast_h[i] / 1000.0;
if (fast_h_kw[i] > fmax) fmax = fast_h_kw[i];
}
WebChartSize(640, 280);
WebChart(0, "Heizungs-WP letzte 60 min", "kW",
0xe74c3c, fast_pos, 60, fast_h_kw,
1 | 8, 1, 0.0, fmax * 1.2);
// ── 24-hour heat-pump average power (kW, hourly averages).
// Each chart point = average over 1 hour (4 × 15-min slots).
// The raw kWh in dvals[] sums per hour to exactly the kW for
// that hour (since 1 h = 4 × ¼-h, ∑kWh ÷ 1 h = kW). 24 smooth
// data points instead of 96 jagged ones, lines up cleanly with
// the 60-min view above.
//
// Ring-buffer cursor: dvals_pos points at the next 15-min slot
// to write (0..95); the equivalent hour cursor is dvals_pos/4.
// The current hour is partial — it averages real new data with
// old data from this hour 24 hours ago. Self-corrects within an
// hour as new quarters overwrite old ones.
// Same physical-max sanity clamp as the writer — drops any
// pre-existing poisoned slot to 0 at render time so the chart
// self-heals on the next page reload, not after 24 h. 8 kWh/h
// ceiling = pump physical max × 2 = generous.
float kwmax = 1.0; // floor 1 kW so a low-load day still shows
for (int i = 0; i < 24; i = i + 1) {
int b = i * 4;
float h = dvals[b] + dvals[b + 1] + dvals[b + 2] + dvals[b + 3];
if (h < 0.0 || h > 8.0) h = 0.0;
dvals_kw[i] = h;
if (dvals_kw[i] > kwmax) kwmax = dvals_kw[i];
}
WebChartSize(640, 280);
WebChart(0, "Heizungs-WP 24h", "kW",
0xe74c3c, dvals_pos / 4, 24, dvals_kw,
1 | 8, 60, 0.0, kwmax * 1.2);
// ── 31-day combined: WP kWh + outdoor temp on dual axis.
// Daily totals so kWh (not kW) is the right unit — each row is
// an integral over 24 h.
WebChartSize(640, 280);
WebChart(0, "Wärmepumpe + Aussen 31 Tage", "kWh",
0xe74c3c, days_pos, 31, days_w,
1, 1440, 0.0, 60.0);
// Second series — empty title means "add to previous chart"
WebChart(0, "", "°C",
0x3498db, days_pos, 31, days_t,
1, 1440, -10.0, 40.0);
// ── 31-day table — Google Charts Table renderer (`'t'` = type
// 116). Pattern lifted from core2_energy.tc:
// WebChart('t', "ColHeader|Row1|Row2|…", "Series", color, …)
// First call sets the column-1 header ("Tag"); subsequent
// calls with title="" append columns to the same table. Row
// labels in the title are optional — when omitted, rows are
// auto-numbered 1..N by the renderer. Note: data is rendered
// in physical array-index order, NOT chronological order.
// With a ring buffer like ours that means rows 1..days_pos-1
// show the most recent days and rows days_pos..30 show the
// oldest. Re-ordering would need an extra display buffer; for
// now leaving the natural ring-order display.
WebChart('t', "Tag", "WPV (kWh)", 0xe74c3c,
days_pos, 31, days_w, 1, 0, 0.0, 0.0);
WebChart('t', "", "D-Temp (°C)", 0x3498db,
days_pos, 31, days_t, 1, 0, 0.0, 0.0);
webSend("</div>");
}
// ── Test data presets (console-triggered) ───────────────────────────
// Both `dvals[96]` (24-hour 15-min trace) and `days_w[31]`/`days_t[31]`
// (31-day trace) take real-world time to fill — 24 h and 31 days
// respectively. To eyeball chart styling without waiting, the `ED`
// command prefix exposes synthetic-data presets:
//
// DASHFILL24 synthetic bimodal HP daily-energy pattern in dvals[]
// DASHFILL31 synthetic warming-month pattern in days_w[]/days_t[]
// DASHFILLALL both
// DASHCLEAR zero out all three preview arrays
//
// Each preset writes the chart file (energy_dash_save()) so a reload
// preserves the preview data — handy when iterating on chart styles.
// Real readings overwrite the synthetic data on the next 15-min
// boundary / midnight rollover.
//
// Why register these instead of auto-firing in main(): main() runs
// on every boot. Auto-fill would clobber real accumulated data if
// the device reboots after running for a week.
//
// Prefix is `DASH` (NOT `ED`) — Tasmota has built-in `ED…` console
// commands (e.g. EDPlay/EDExtended) that shadow ours, returning
// `{"Command":"Unknown"}` even though our handler ran the matcher.
void Command(char cmd[]) {
if (strcmp(cmd, "FILL24") == 0) {
// Bimodal Gaussian: morning peak around 7:00, evening around 19:00.
// Floor 0.3 kWh (standby), peaks 2.0–2.5 kWh per 15-min slot.
for (int i = 0; i < 96; i = i + 1) {
float h = i * 0.25; // hour-of-day, 0..23.75
float dm = h - 7.0; // distance from morning peak
float de = h - 19.0; // distance from evening peak
float morn = 2.5 * exp(-dm * dm / 8.0);
float even = 2.0 * exp(-de * de / 12.0);
dvals[i] = 0.3 + morn + even;
}
dvals_pos = 95;
energy_dash_save();
responseCmnd("ED: 24h chart filled (synthetic bimodal HP day)");
}
else if (strcmp(cmd, "FILL31") == 0) {
// Linear warming month: kWh trends 45 → 15, °C trends -3 → +19.
// Slight weekly sinusoidal jitter so the line isn't a ruler.
for (int i = 0; i < 31; i = i + 1) {
float frac = i / 30.0;
float wkl = sin(i * 0.9); // weekly-ish bumpiness
days_w[i] = 45.0 - 30.0 * frac + 5.0 * wkl;
days_t[i] = -3.0 + 22.0 * frac + 2.0 * wkl;
}
days_pos = 30;
energy_dash_save();
responseCmnd("ED: 31d chart filled (warming month)");
}
else if (strcmp(cmd, "FILLALL") == 0) {
for (int i = 0; i < 96; i = i + 1) {
float h = i * 0.25;
float dm = h - 7.0;
float de = h - 19.0;
dvals[i] = 0.3 + 2.5 * exp(-dm * dm / 8.0)
+ 2.0 * exp(-de * de / 12.0);
}
dvals_pos = 95;
for (int i = 0; i < 31; i = i + 1) {
float frac = i / 30.0;
float wkl = sin(i * 0.9);
days_w[i] = 45.0 - 30.0 * frac + 5.0 * wkl;
days_t[i] = -3.0 + 22.0 * frac + 2.0 * wkl;
}
days_pos = 30;
energy_dash_save();
responseCmnd("ED: 24h + 31d charts filled");
}
else if (strcmp(cmd, "CLEAR") == 0) {
for (int i = 0; i < 96; i = i + 1) dvals[i] = 0.0;
for (int i = 0; i < 31; i = i + 1) days_w[i] = 0.0;
for (int i = 0; i < 31; i = i + 1) days_t[i] = 0.0;
dvals_pos = 0;
days_pos = 0;
energy_dash_save();
responseCmnd("ED: cleared dvals + days_w + days_t");
}
else {
responseCmnd("ED: FILL24 | FILL31 | FILLALL | CLEAR");
}
}
int main() {
addLog("ENERGY: dashboard starting");
addCommand("DASH"); // DASHFILL24 / DASHFILL31 / DASHFILLALL / DASHCLEAR
energy_dash_load(); // chart arrays from /energy_dashboard.bin (NOT .pvs)
return 0;
}