power_meter.tc¶
power_meter.tc — energy-metering smart plug power + consumption charts
// ============================================================================
// power_meter.tc — energy-metering smart plug power + consumption charts
// ============================================================================
//
// TinyC port of ottelo's 5_SteckdoseLeistungsmesser_1.tas and _2.tas.
//
// For smart plugs / inline meters that expose the Tasmota ENERGY driver
// (Sonoff POW Elite, POW320D, NOUS A8T, …). No SML, no descriptor, no
// serial pins — `sensorGet("ENERGY#Power")` / `sensorGet("ENERGY#Total")`
// read the built-in energy sensor directly.
//
// The two ottelo variants differ ONLY in the high-resolution chart
// granularity — everything else (24 h chart, daily / monthly columns,
// power2 smoothing, MQTT option) is identical. They collapse into one
// file with a single compile-time toggle:
//
// default (variant 1) : 4 h chart, 30 s/sample, 480 samples
// -DPOWER_METER_HIRES (var 2): 1 h chart, 5 s/sample, 720 samples
//
// Storage footprint (PSRAM-backed via special_calloc on ESP32-S3):
// pm_s4h: 481 or 721 floats ≈ 1.9 / 2.8 KB
// pm_s24h: 1441 floats ≈ 5.6 KB
// pm_dcon: 31 floats ≈ 124 B
// pm_mcon: 12 floats ≈ 48 B
// -----------------------------------------------
// Total chart state ≈ 8–9 KB
//
// Setup: just flash a smart plug with this script in a TinyC slot. The
// ENERGY driver is always live on these devices — no Rule1 / Sensor53.
// Use the menu button "Einstellungen / Daten" to reset baselines or
// toggle the optional MQTT publish of the smoothed power value.
//
// Requires: a device with USE_ENERGY_SENSOR + USE_UFILESYS.
// ============================================================================
// ── High-resolution chart toggle ───────────────────────────────────────────
// Uncomment for variant 2 (1 h chart, 5 s resolution). Leave commented for
// variant 1 (4 h chart, 30 s resolution).
//#define POWER_METER_HIRES
#ifdef POWER_METER_HIRES
#define PM_S4H_LEN 721
#define PM_S4H_DATA 720
#define PM_S4H_PERHOUR 720 // samples per hour on the hi-res chart
#define PM_S4H_MINMUL 12 // = PERHOUR / 60
#define PM_S4H_SECDIV 5 // sample every 5 s
#define PM_S4H_HOURS 1
#define PM_S4H_TITLE "Leistung 1 Stunde [Watt]"
#else
#define PM_S4H_LEN 481
#define PM_S4H_DATA 480
#define PM_S4H_PERHOUR 120
#define PM_S4H_MINMUL 2
#define PM_S4H_SECDIV 30
#define PM_S4H_HOURS 4
#define PM_S4H_TITLE "Leistung 4 Stunden [Watt]"
#endif
// ── Persisted energy baselines (delta against ENERGY#Total) ─────────────────
persist float pm_dval; // daily zero point [kWh]
persist float pm_mval; // monthly zero point
persist float pm_yval; // yearly zero point
persist int pm_da; // last-seen day-of-month (month-wrap)
persist int pm_sndpwr; // 1 = publish smoothed power over MQTT
// ── Rolling power chart arrays (MUST be float — WebChart bit-casts) ──────────
persist float pm_s4h[PM_S4H_LEN];
persist float pm_s24h[1441]; // 24 h chart, 60 s per slot
persist int pm_s4h_pos;
persist int pm_s24h_pos;
// ── Daily / monthly column arrays — 0-based ─────────────────────────────────
persist float pm_dcon[31]; // kWh per day, [day-1] = today
persist float pm_mcon[12]; // kWh per month, [month-1] = this month
// ── Runtime state ──────────────────────────────────────────────────────────
int pm_s4hf[6]; // 6-sample moving-average ring (4 h / 1 h chart)
int pm_s24hf[12]; // 12-sample ring (24 h chart)
int pm_f4_pos;
int pm_f24_pos;
int pm_t1; // 5 s downcounter
int pm_t2; // 60 s downcounter
int pm_hr_last; // midnight edge-detect
float pm_power2; // exponentially-smoothed power (DPL feed)
char pm_buf[200];
// ── Transient WebButton flags ──────────────────────────────────────────────
int do_init;
int do_init2;
int do_save;
// Uncomment to pre-fill all four charts with synthetic data for visual
// validation without waiting for real energy readings to accumulate.
//#define POWER_METER_DEMO
// ============================================================================
// Save / load chart arrays to /power_meter.bin
// ============================================================================
void pm_save() {
int h = fileOpen("/power_meter.bin", "w");
if (h < 0) {
addLog("power_meter_save: fileOpen failed");
return;
}
fileWriteBin(h, pm_s4h, PM_S4H_LEN);
fileWriteBin(h, pm_s24h, 1441);
fileWriteBin(h, pm_dcon, 31);
fileWriteBin(h, pm_mcon, 12);
fileClose(h);
}
void pm_load() {
int h = fileOpen("/power_meter.bin", "r");
if (h < 0) return;
fileReadBin(h, pm_s4h, PM_S4H_LEN);
fileReadBin(h, pm_s24h, 1441);
fileReadBin(h, pm_dcon, 31);
fileReadBin(h, pm_mcon, 12);
fileClose(h);
}
// ============================================================================
// Init helpers
// ============================================================================
void pm_init_baselines() {
float total = sensorGet("ENERGY#Total");
pm_dval = total;
pm_mval = total;
pm_yval = total;
pm_s4h_pos = 0;
pm_s24h_pos = 0;
int i = 0;
while (i < PM_S4H_LEN) { pm_s4h[i] = 0.0; i = i + 1; }
i = 0;
while (i < 1441) { pm_s24h[i] = 0.0; i = i + 1; }
pm_save();
addLog("power_meter: baselines set from ENERGY#Total, power charts cleared");
}
void pm_init_columns() {
int i = 0;
while (i < 31) { pm_dcon[i] = 0.0; i = i + 1; }
i = 0;
while (i < 12) { pm_mcon[i] = 0.0; i = i + 1; }
pm_save();
addLog("power_meter: daily + monthly column charts cleared");
}
// ============================================================================
// 5-s tick — moving-average filter feed + push to rolling charts.
// Mirrors ottelo's >S "alle 5s" block. The hi-res variant pushes a new
// 4 h-chart slot every 5 s; the default variant every 30 s (idx4 changes
// at the configured cadence).
// ============================================================================
void pm_5s_tick() {
float pf = sensorGet("ENERGY#Power");
int p = (int)pf;
pm_s4hf[pm_f4_pos] = p;
pm_f4_pos = (pm_f4_pos + 1) % 6;
pm_s24hf[pm_f24_pos] = p;
pm_f24_pos = (pm_f24_pos + 1) % 12;
int sum = 0;
int i = 0;
while (i < 6) { sum = sum + pm_s4hf[i]; i = i + 1; }
float s4h_avg = (float)sum / 6.0;
sum = 0;
i = 0;
while (i < 12) { sum = sum + pm_s24hf[i]; i = i + 1; }
float s24h_avg = (float)sum / 12.0;
int idx4 = ((tasm_hour - PM_S4H_HOURS + 24) % 24) * PM_S4H_PERHOUR
+ tasm_minute * PM_S4H_MINMUL + (tasm_second / PM_S4H_SECDIV);
int idx24 = tasm_hour * 60 + tasm_minute;
if (idx4 >= 0 && idx4 < PM_S4H_DATA) {
pm_s4h[idx4] = s4h_avg;
pm_s4h_pos = idx4;
}
if (idx24 >= 0 && idx24 < 1440) {
pm_s24h[idx24] = s24h_avg;
pm_s24h_pos = idx24;
}
// Exponentially-smoothed power (ottelo's `power2` — feed for e.g.
// opendtu-onbattery DPL). 0.9 old + 0.1 new.
pm_power2 = 0.9 * pm_power2 + 0.1 * pf;
if (pm_sndpwr) {
// TinyC's mqttPublish() needs const-pool string args (no runtime
// payload) and doesn't do Scripter's %topic% substitution. Use
// Tasmota's `Publish` console command instead — tasmInfo(0) gives
// the device MQTT topic so the published topic matches ottelo's
// `stat/%topic%/script/power2`.
char tp[64];
tasmInfo(0, tp);
sprintf(pm_buf, "Publish stat/%s/script/power2 %.0f", tp, pm_power2);
tasmCmd(pm_buf, tp); // tp reused as response scratch
}
}
// ============================================================================
// 60-s tick — daily / monthly counters + midnight rollover.
// ============================================================================
void pm_60s_tick() {
float total = sensorGet("ENERGY#Total");
int d = tasm_day;
int m = tasm_month;
if (d >= 1 && d <= 31) pm_dcon[d - 1] = total - pm_dval;
if (m >= 1 && m <= 12) pm_mcon[m - 1] = total - pm_mval;
int hr = tasm_hour;
if (pm_hr_last != hr && hr == 0) {
if (d > 1) {
pm_da = d;
} else {
int i = pm_da;
while (i < 31) { pm_dcon[i] = 0.0; i = i + 1; }
pm_mval = total;
pm_da = 1;
}
if (d == 1 && m == 1) pm_yval = total;
pm_dval = total;
pm_save();
}
pm_hr_last = hr;
}
// ============================================================================
// Render: totals row on the main sensor page
// ============================================================================
void pm_render_totals() {
float total = sensorGet("ENERGY#Total");
sprintf(pm_buf, "{s}Leistung (gefiltert){m}%.0f W{e}", pm_power2); webSend(pm_buf);
sprintf(pm_buf, "{s}Tagesverbrauch{m}%.2f kWh{e}", total - pm_dval); webSend(pm_buf);
sprintf(pm_buf, "{s}Monatsverbrauch{m}%.2f kWh{e}", total - pm_mval); webSend(pm_buf);
sprintf(pm_buf, "{s}Jahresverbrauch{m}%.2f kWh{e}", total - pm_yval); webSend(pm_buf);
}
// ============================================================================
// Render: WebChart line charts (4 h / 1 h + 24 h power)
// ============================================================================
void pm_render_4h() {
WebChartSize(600, 280);
WebChart(0, PM_S4H_TITLE, "W", 0x3498db, pm_s4h_pos, PM_S4H_DATA, pm_s4h, 0, 1, 0.0, 0.0);
}
void pm_render_24h() {
WebChartSize(600, 280);
WebChart(0, "Leistung 24 Stunden [Watt]", "W", 0x3498db, pm_s24h_pos, 1440, pm_s24h, 0, 1, 0.0, 0.0);
}
// ============================================================================
// Render: daily + monthly column charts — direct Google Charts emit for
// per-bar tri-color (past=green / today=red / future=blue). Same idiom
// as sml_chart_common.tc.
// ============================================================================
void pm_render_days() {
int today = tasm_day;
if (today < 1) today = 1;
webSend("<div id='pm_dch' style='text-align:center;width:600px;height:280px'></div>");
webSend("<script>function _pmDD(){var d=google.visualization.arrayToDataTable([['Tag','Energie [kWh]',{role:'style'}]");
int i = 1;
while (i <= 31) {
if (i < today) sprintf(pm_buf, ",[%d,%.2f,'green']", i, pm_dcon[i - 1]);
else if (i == today) sprintf(pm_buf, ",[%d,%.2f,'red']", i, pm_dcon[i - 1]);
else sprintf(pm_buf, ",[%d,%.2f,'blue']", i, pm_dcon[i - 1]);
webSend(pm_buf);
i = i + 1;
}
webSend("]);new google.visualization.ColumnChart(document.getElementById('pm_dch')).draw(d,{chartArea:{left:50,right:20,height:'75%'},legend:'none',title:'Tagesverbraeuche Monatsansicht',vAxis:{format:'# kWh'},hAxis:{title:'Tag',ticks:[1,5,10,15,20,25,30]}});}google.charts.setOnLoadCallback(_pmDD);</script>");
}
void pm_render_months() {
int thismon = tasm_month;
if (thismon < 1) thismon = 1;
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='pm_mch' style='text-align:center;width:600px;height:280px'></div>");
webSend("<script>function _pmDM(){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(pm_buf, ",['%s',%.2f,'green']", mn, pm_mcon[i - 1]);
else if (i == thismon) sprintf(pm_buf, ",['%s',%.2f,'red']", mn, pm_mcon[i - 1]);
else sprintf(pm_buf, ",['%s',%.2f,'blue']", mn, pm_mcon[i - 1]);
webSend(pm_buf);
i = i + 1;
}
webSend("]);new google.visualization.ColumnChart(document.getElementById('pm_mch')).draw(d,{chartArea:{left:50,right:30,top:30,height:'70%'},legend:'none',title:'Monatsverbraeuche Jahresansicht',vAxis:{format:'# kWh'},hAxis:{slantedText:false,showTextEvery:1}});}google.charts.setOnLoadCallback(_pmDM);</script>");
}
// ============================================================================
// Settings panel
// ============================================================================
void pm_render_settings_panel() {
webSend("<style>.sml-p{max-width:340px;margin:12px auto;background:#f0f0f0;color:#000;padding:16px;border:2px solid #ccc;border-radius:6px;text-align:left}.sml-p h3{margin:0 0 8px}.sml-p hr{border:0;border-top:1px solid #bbb;margin:12px 0}.sml-p b{display:inline-block;margin-bottom:4px;font-size:13px}.sml-p div{margin:6px 0}.sml-p .hint{font-size:9px;color:#555;line-height:1.4}</style>");
webSend("<div class='sml-p'><h3>🔌 Leistungsmesser</h3>");
int up_d = tasm_uptime / 86400;
int up_h = (tasm_uptime / 3600) % 24;
int up_m = (tasm_uptime / 60) % 60;
sprintf(pm_buf, "<div>Uptime: %d d %d h %d min</div>", up_d, up_h, up_m);
webSend(pm_buf);
float total = sensorGet("ENERGY#Total");
float pwr = sensorGet("ENERGY#Power");
sprintf(pm_buf, "<div>Aktuell: %.0f W | Zaehler: %.2f kWh</div>", pwr, total);
webSend(pm_buf);
webSend("<hr><b>🔧 Optionen</b>");
webCheckbox(pm_sndpwr, "Gemittelte Leistung per MQTT senden");
webSend("<div class='hint'>Publishes stat/<topic>/script/power2 every 5 s — feed for e.g. opendtu-onbattery DPL.</div>");
}
#ifdef POWER_METER_DEMO
// ── Synthetic chart data — purely for rendering validation ────────────────
void pm_demo_fill() {
int i = 0;
while (i < PM_S4H_DATA) {
float x = ((float)i - (float)PM_S4H_DATA / 2.0) / ((float)PM_S4H_DATA / 8.0);
float bell = cos(x * 0.5);
if (bell < 0.0) bell = 0.0;
pm_s4h[i] = bell * bell * 2200.0 + 80.0;
i = i + 1;
}
pm_s4h_pos = PM_S4H_DATA / 2;
i = 0;
while (i < 1440) {
float t = (float)i;
float v = 0.0;
if (t > 360.0 && t < 1320.0) {
float ph = (t - 360.0) / 960.0;
v = sin(ph * 3.14159);
v = v * v * 1800.0;
}
pm_s24h[i] = v + 60.0;
i = i + 1;
}
pm_s24h_pos = 720;
i = 0;
while (i < 31) {
float ph = (float)i / 31.0 * 6.28318;
pm_dcon[i] = 4.5 + 3.0 * cos(ph);
i = i + 1;
}
i = 0;
while (i < 12) {
float ph = (float)i / 12.0 * 6.28318;
pm_mcon[i] = 135.0 + 60.0 * cos(ph);
i = i + 1;
}
addLog("power_meter: demo data loaded into all four charts");
}
#endif
// ============================================================================
// Callbacks
// ============================================================================
void EverySecond() {
if (tasm_year < 2020) return;
// Wait for the energy driver to report something (Scripter: enrg[1]==0)
if (sensorGet("ENERGY#Power") == 0.0 && sensorGet("ENERGY#Total") == 0.0) {
// still allow the very first reading of exactly 0 W after warmup —
// a plugged-in idle device legitimately reads 0 W but has a Total
// > 0; only skip when BOTH are zero (driver not up yet).
return;
}
if (do_init) { pm_init_baselines(); do_init = 0; }
if (do_init2) { pm_init_columns(); do_init2 = 0; }
if (do_save) { pm_save(); do_save = 0; }
pm_t1 = pm_t1 - 1;
if (pm_t1 <= 0) {
pm_t1 = 5;
pm_5s_tick();
}
pm_t2 = pm_t2 - 1;
if (pm_t2 <= 0) {
pm_t2 = 60;
pm_60s_tick();
}
}
// ── Main page, sensor-table block ──
void WebCall() {
pm_render_totals();
}
// ── Main page, chart block below sensors ──
void WebPage() {
// ottelo's chart-centering compensation (margin-left:-30px wrapper) —
// counters the ~30 px right-shift of the Tasmota main page.
webSend("<div style='margin-left:-30px'>");
pm_render_4h();
pm_render_24h();
pm_render_days();
pm_render_months();
webSend("</div>");
}
// ── Settings sub-page (menu button) ──
void WebUI() {
pm_render_settings_panel();
webSend("<hr><b>💾 Daten</b>");
webButton(do_init, "Werte initialisieren|initialisiert");
webButton(do_init2, "Balkendiagramme zuruecksetzen|zurueckgesetzt");
webButton(do_save, "Diagrammdaten speichern|gespeichert");
webSend("<div style='text-align:center;font-size:10px;color:#777;margin-top:10px'><b>power_meter.tc</b><br>TinyC port of ottelo's 5_SteckdoseLeistungsmesser_1/2.tas</div></div>");
}
int main() {
if (pm_da == 0) pm_da = 1;
pm_load();
#ifdef POWER_METER_DEMO
pm_demo_fill();
#endif
webPageLabel(0, "Einstellungen / Daten");
webPageLabel(1, "");
int hires = 0;
#ifdef POWER_METER_HIRES
hires = 1;
#endif
addLog("power_meter: started (hires=%d) sndpwr=%d", hires, pm_sndpwr);
return 0;
}