sml_ebus.tc¶
SML eBus Solar Monitor with 24h and weekly charts
// SML eBus Solar Monitor with 24h and weekly charts
// Displays Außentemperatur, Solarspeicher, Kollektortemperatur
// with min/max tracking and Google Charts history
// --- Chart sizes ---
#define DAY_LEN 288 // 24h at 1 sample/5 min
#define DAY_INT 300 // sample interval in seconds (5 min)
#define WEEK_LEN 336 // 7 days at 1 sample/30 min
#define WEEK_INT 1800 // sample interval in seconds (30 min)
#define CHARTFILE "/sml_ebus.bin"
#define CHART_MAGIC 0x53424531 // "SBE1" — bump to discard an old-layout .bin on load
#define ENERGY_VER 2 // energy arrays now hold AVG POWER (W); bump = one-time clear of old kWh data
persist watch int sml_activ;
float min_at;
float max_at;
float min_ss;
float max_ss;
float min_ct;
float max_ct;
int startup; // countdown to skip initial zero readings
int last_day; // to detect midnight rollover
int atmp_valid; // 1 once atmp received a real value
// date/time strings
char wdays[] = "So Mo Di Mi Do Fr Sa";
char mons[] = "JanFebMrzAprMaiJunJulAugSepOktNovDez";
char s1[8];
char s2[8];
global float atmp;
// brauchwasser wärmepumpe total energy
global float bwwpc;
// solarpumpe total energy
global float sppc;
// OneWire DS18B20 temps — read locally on .150, broadcast to the fleet
global float ktmp; // Kellertemperatur — DS18B20-1, id 1ABEE9086461
global float bw_ww; // Warmwassertemperatur — DS18B20-2, id C178441F64FF
global float scol;
global float ssp;
global float spmp;
// ── Chart history — stored in /sml_ebus.bin (NOT persist/.pvs). File storage
// keeps the large arrays out of the .pvs, so adding a series never re-seeds the
// persist store, and the history survives reloads/OTA. (Same pattern as
// power_meter.tc / bresser_chart.tc.) ─────────────────────────────────────────
// 24h history (1 sample/5 min): 5 temps + 2 energy deltas
float d_at[DAY_LEN];
float d_ss[DAY_LEN];
float d_ct[DAY_LEN];
float d_kt[DAY_LEN]; // Kellertemperatur
float d_bw[DAY_LEN]; // Warmwassertemperatur
float d_bww[DAY_LEN]; // kWh consumed per 5-min slot (Brauchwasser-WP)
float d_spp[DAY_LEN]; // kWh consumed per 5-min slot (Solarpumpe)
int d_pos; // current wall-clock 5-min slot (ring anchor)
// weekly history (1 sample/30 min): 5 temps + 2 energy deltas
float w_at[WEEK_LEN];
float w_ss[WEEK_LEN];
float w_ct[WEEK_LEN];
float w_kt[WEEK_LEN]; // Kellertemperatur
float w_bw[WEEK_LEN]; // Warmwassertemperatur
float w_bww[WEEK_LEN];
float w_spp[WEEK_LEN];
int w_pos; // current wall-clock 30-min slot (ring anchor)
// Energy delta state: the accumulated meter value at the START of the current
// slot, so each slot shows the kWh consumed within it (diff of the accumulator).
int d_lastdi; // 5-min slot the current base was snapshot for
int w_lastwi; // 30-min slot the current base was snapshot for
float d_bww_base; float d_spp_base;
float w_bww_base; float w_spp_base;
int save_slot; // last 5-min slot flushed to .bin (flush cadence)
int chart_dirty; // 1 = arrays changed since last save (OnExit guard)
// /sml_ebus.bin layout: int header + float arrays (5×DAY_LEN + 5×WEEK_LEN) + bases[4]
int chdr[8]; // [magic, d_pos, w_pos, d_lastdi, w_lastwi, 0,0,0]
float cbas[4]; // [d_bww_base, d_spp_base, w_bww_base, w_spp_base]
void resetMinMax() {
min_at = 999.0; max_at = -999.0;
min_ss = 999.0; max_ss = -999.0;
min_ct = 999.0; max_ct = -999.0;
}
// ── Chart storage: /sml_ebus.bin (fileWriteBin/fileReadBin, like other SML examples) ──
void sml_save() {
int h = fileOpen(CHARTFILE, "w");
if (h < 0) { addLog("sml_ebus save: fileOpen failed"); return; }
chdr[0] = CHART_MAGIC; chdr[1] = d_pos; chdr[2] = w_pos;
chdr[3] = d_lastdi; chdr[4] = w_lastwi; chdr[5] = ENERGY_VER; chdr[6] = 0; chdr[7] = 0;
fileWriteBin(h, chdr, 8);
fileWriteBin(h, d_at, DAY_LEN); fileWriteBin(h, d_ss, DAY_LEN); fileWriteBin(h, d_ct, DAY_LEN);
fileWriteBin(h, d_bww, DAY_LEN); fileWriteBin(h, d_spp, DAY_LEN);
fileWriteBin(h, w_at, WEEK_LEN); fileWriteBin(h, w_ss, WEEK_LEN); fileWriteBin(h, w_ct, WEEK_LEN);
fileWriteBin(h, w_bww, WEEK_LEN); fileWriteBin(h, w_spp, WEEK_LEN);
cbas[0] = d_bww_base; cbas[1] = d_spp_base; cbas[2] = w_bww_base; cbas[3] = w_spp_base;
fileWriteBin(h, cbas, 4);
// Keller/Warmwasser appended AFTER cbas so a pre-existing .bin (without them) still loads.
fileWriteBin(h, d_kt, DAY_LEN); fileWriteBin(h, d_bw, DAY_LEN);
fileWriteBin(h, w_kt, WEEK_LEN); fileWriteBin(h, w_bw, WEEK_LEN);
fileClose(h);
chart_dirty = 0;
}
void sml_load() {
int want_old = (8 + 5 * DAY_LEN + 5 * WEEK_LEN + 4) * 4; // pre-Keller/Warmwasser layout
int want_new = want_old + (2 * DAY_LEN + 2 * WEEK_LEN) * 4; // + d_kt/d_bw/w_kt/w_bw appended
int sz = fileSize(CHARTFILE);
if (sz != want_old && sz != want_new) { return; } // missing / unknown -> fresh start
int h = fileOpen(CHARTFILE, "r");
if (h < 0) { return; }
fileReadBin(h, chdr, 8);
if (chdr[0] != CHART_MAGIC) { fileClose(h); return; } // version bump -> ignore old data
d_pos = chdr[1]; w_pos = chdr[2]; d_lastdi = chdr[3]; w_lastwi = chdr[4];
fileReadBin(h, d_at, DAY_LEN); fileReadBin(h, d_ss, DAY_LEN); fileReadBin(h, d_ct, DAY_LEN);
fileReadBin(h, d_bww, DAY_LEN); fileReadBin(h, d_spp, DAY_LEN);
fileReadBin(h, w_at, WEEK_LEN); fileReadBin(h, w_ss, WEEK_LEN); fileReadBin(h, w_ct, WEEK_LEN);
fileReadBin(h, w_bww, WEEK_LEN); fileReadBin(h, w_spp, WEEK_LEN);
fileReadBin(h, cbas, 4);
d_bww_base = cbas[0]; d_spp_base = cbas[1]; w_bww_base = cbas[2]; w_spp_base = cbas[3];
// Keller/Warmwasser only present in the new layout; an older .bin leaves them fresh at 0.
if (sz == want_new) {
fileReadBin(h, d_kt, DAY_LEN); fileReadBin(h, d_bw, DAY_LEN);
fileReadBin(h, w_kt, WEEK_LEN); fileReadBin(h, w_bw, WEEK_LEN);
}
// One-time migration: the 4 energy arrays switched from kWh to W scaling — clear the
// old kWh values so the charts don't show mixed units until they refill (in W).
if (chdr[5] != ENERGY_VER) {
int i = 0;
while (i < DAY_LEN) { d_bww[i] = 0.0; d_spp[i] = 0.0; i = i + 1; }
i = 0;
while (i < WEEK_LEN) { w_bww[i] = 0.0; w_spp[i] = 0.0; i = i + 1; }
d_lastdi = -1; w_lastwi = -1; // fresh energy base on the first sample
}
fileClose(h);
}
int main() {
if (sml_activ != tasm_rule) {
sml_activ = tasm_rule;
}
atmp_valid = 0;
startup = 10; // skip first 10 seconds for SML to deliver data
last_day = tasm_day;
resetMinMax();
// Chart history lives in /sml_ebus.bin (not persist). The -1 sentinels force a
// fresh energy base-snapshot on first run; sml_load() overwrites them + all the
// arrays when a valid .bin exists, so history survives reloads/OTA.
d_lastdi = -1; w_lastwi = -1; save_slot = -1; chart_dirty = 0;
sml_load();
}
void EverySecond() {
if (changed(sml_activ)) {
tasm_rule = sml_activ;
snapshot(sml_activ);
}
// Meter-def value order (see /sml_meter.def): 1 = Solarkollektor,
// 2 = Solarspeicher, 3 = Solarpumpe. scol feeds the "Solarspeicher"
// section+chart below and ssp the "Kollektortemperatur" one, so read
// them swapped to match those labels. (Flip these two lines if your
// descriptor lists Speicher first.)
scol = smlGet(2); // Solarspeicher
ssp = smlGet(1); // Solarkollektor
spmp = smlGet(3); // Solarpumpe (read, not charted)
// skip first seconds — SML hasn't delivered valid data yet
if (startup > 0) {
startup = startup - 1;
return;
}
// OneWire DS18B20 temps (native sensor JSON) -> assign broadcasts them to the fleet
ktmp = sensorGet("DS18B20-1#Temperature"); // Kellertemperatur (id 1ABEE9086461)
bw_ww = sensorGet("DS18B20-2#Temperature"); // Warmwassertemperatur (id C178441F64FF)
// reset daily min/max at midnight
if (tasm_day != last_day) {
last_day = tasm_day;
atmp_valid = 0;
resetMinMax();
}
// track daily min/max
// atmp is global from external device — skip until first real value arrives
if (atmp_valid == 0 && atmp != 0.0) {
atmp_valid = 1;
min_at = atmp;
max_at = atmp;
}
if (atmp_valid) {
if (atmp > max_at) { max_at = atmp; }
if (atmp < min_at) { min_at = atmp; }
}
if (scol > max_ss) { max_ss = scol; }
if (scol < min_ss) { min_ss = scol; }
if (ssp > max_ct) { max_ct = ssp; }
if (ssp < min_ct) { min_ct = ssp; }
// 24h chart: WALL-CLOCK 5-min slot (0..287 = minute-of-day / 5), so the
// x-axis lines up with real time. Each second overwrites the current slot
// (sample-and-hold); d_pos = the current slot for WebChart's ring anchor.
int di = (tasm_hour * 60 + tasm_minute) / 5;
if (di >= 0 && di < DAY_LEN) {
d_at[di] = atmp;
d_ss[di] = scol;
d_ct[di] = ssp;
d_kt[di] = ktmp;
d_bw[di] = bw_ww;
// WebChart's pos is the ring ANCHOR (oldest slot = one past the newest),
// so the newest sample (slot di) renders at "now" not 24 h ago.
d_pos = (di + 1) % DAY_LEN;
}
// weekly chart: WALL-CLOCK 30-min slot of the week (0..335). tasm_wday is
// 1=Sun..7=Sat, so (wday-1)*24+hour = hour-of-week (0..167).
int wi = (((tasm_wday - 1) * 24 + tasm_hour) * 60 + tasm_minute) / 30;
if (wi >= 0 && wi < WEEK_LEN) {
w_at[wi] = atmp;
w_ss[wi] = scol;
w_ct[wi] = ssp;
w_kt[wi] = ktmp;
w_bw[wi] = bw_ww;
w_pos = (wi + 1) % WEEK_LEN;
}
// --- Energy: bwwpc / sppc are ACCUMULATED kWh meters (UDP globals). Each slot
// holds the kWh consumed WITHIN it = current total - total at the slot start.
// Sample only once BOTH meters carry real values (skip startup zeros); a new
// slot re-snapshots the base (the -1 sentinel forces it on the very first run).
if (bwwpc != 0.0 && sppc != 0.0) {
if (di >= 0 && di < DAY_LEN) {
if (di != d_lastdi) { d_lastdi = di; d_bww_base = bwwpc; d_spp_base = sppc; }
d_bww[di] = (bwwpc - d_bww_base) * 12000.0; // kWh/5min -> W (avg power = kWh*1000*60/5)
d_spp[di] = (sppc - d_spp_base) * 12000.0;
}
if (wi >= 0 && wi < WEEK_LEN) {
if (wi != w_lastwi) { w_lastwi = wi; w_bww_base = bwwpc; w_spp_base = sppc; }
w_bww[wi] = (bwwpc - w_bww_base) * 2000.0; // kWh/30min -> W (avg power = kWh*1000*60/30)
w_spp[wi] = (sppc - w_spp_base) * 2000.0;
}
}
// Flush chart state to /sml_ebus.bin once per 5-min slot (bounded flash wear);
// OnExit / CleanUp flush any remaining samples on restart / OTA.
chart_dirty = 1;
if (di != save_slot) { save_slot = di; sml_save(); }
}
void WebCall() {
char buf[128];
webSend("{s}<b style='color:#2196F3'>Aussentemperatur</b>{m}{e}");
sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", atmp);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_at);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_at);
strcat(buf, " °C</span>{e}");
webSend(buf);
webSend("{s}<b style='color:#FF5722'>Solarspeicher</b>{m}{e}");
sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", scol);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_ss);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_ss);
strcat(buf, " °C</span>{e}");
webSend(buf);
webSend("{s}<b style='color:#4CAF50'>Kollektortemperatur</b>{m}{e}");
sprintf(buf, "{s}Zur Zeit{m}<span style='color:#fff'>%.2f", ssp);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Maximum{m}<span style='color:#F44'>%.2f", max_ct);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Minimum{m}<span style='color:#4AF'>%.2f", min_ct);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}<b style='color:#9C27B0'>Kellertemperatur</b>{m}<span style='color:#fff'>%.1f", ktmp);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}<b style='color:#FF9800'>Warmwassertemperatur</b>{m}<span style='color:#fff'>%.1f", bw_ww);
strcat(buf, " °C</span>{e}");
webSend(buf);
sprintf(buf, "{s}Heap{m}%d kB{e}", tasm_heap / 1024);
webSend(buf);
}
void WebPage() {
char buf[128];
int sr;
int ss;
int dl;
sr = tasm_sunrise;
ss = tasm_sunset;
dl = ss - sr;
// ─── Clock with JS auto-update + move to top of page ───
// NOTE: this is deliberately NOT the shared `web_clock_header()`
// subroutine from examples/clock_header.tc — that one re-renders
// server-side on every WebCall poll (1–2s cadence). sml_ebus uses
// a JS setInterval to tick the seconds smoothly *client-side*
// without a server hit, important on a Modbus-heavy script where
// every spare ms helps. If smoothness matters less than
// consistency for your use case, replace this whole block with
// a single `web_clock_header()` call (see clock_header.tc).
webSend("<div id='tc_clock' style='text-align:center;background:#333;padding:8px;border-radius:8px;margin:8px 0'>");
webSend("<span id='tc_clk' style='color:green;font-size:40px;font-weight:bold'></span><br>");
webSend("<span id='tc_dat'></span><br>");
// Sunrise / sunset (static, set once from server)
webSend("🌞 ");
sprintf(buf, "%02d:", sr / 60);
webSend(buf);
sprintf(buf, "%02d", sr % 60);
webSend(buf);
webSend(" <--- ");
sprintf(buf, "%d:", dl / 60);
webSend(buf);
sprintf(buf, "%02d", dl % 60);
webSend(buf);
webSend(" ---> ");
sprintf(buf, "%02d:", ss / 60);
webSend(buf);
sprintf(buf, "%02d", ss % 60);
webSend(buf);
webSend(" 🌙");
webSend("</div>");
// JS: update clock + date every second client-side
webSend("<script>");
webSend("var wd=['So','Mo','Di','Mi','Do','Fr','Sa'];");
webSend("var mn=['Jan','Feb','Mrz','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];");
webSend("function tc(){var d=new Date();");
webSend("var h=('0'+d.getHours()).slice(-2);");
webSend("var m=('0'+d.getMinutes()).slice(-2);");
webSend("var s=('0'+d.getSeconds()).slice(-2);");
webSend("document.getElementById('tc_clk').innerHTML=h+':'+m+':'+s;");
webSend("document.getElementById('tc_dat').innerHTML=");
webSend("wd[d.getDay()]+' '+d.getDate()+'. '+mn[d.getMonth()]+' '+d.getFullYear();");
webSend("}tc();setInterval(tc,1000);");
// move clock div before l1 (AJAX sensor content)
webSend("var e=document.getElementById('tc_clock');");
webSend("var l=document.getElementById('l1');");
webSend("if(e&&l)l.parentNode.insertBefore(e,l);");
webSend("</script>");
webFlush();
// Charts always render: the ring is indexed by wall-clock slot, so pass the
// FULL length (24h / 7d axis). Un-recorded slots read 0.0 -> the WebChartJS
// snippet turns those into null (a gap) so the line shows real data at its
// real time instead of dropping to 0 C over the not-yet-filled slots.
webSend("<div style='margin-left:-30px'>");
// --- 24h chart: 3 series, wall-clock 5-min slots, interval = 5 min ---
WebChart(0, "Temperaturen 24h", "Aussen|C", 0x2196F3, d_pos, DAY_LEN, d_at, 1, 5, 0.0, 0.0);
WebChart(0, "", "Speicher|C", 0xFF5722, d_pos, DAY_LEN, d_ss, 1, 5, 0.0, 0.0);
WebChart(0, "", "Kollektor|C", 0x4CAF50, d_pos, DAY_LEN, d_ct, 1, 5, 0.0, 0.0);
WebChart(0, "", "Keller|C", 0x9C27B0, d_pos, DAY_LEN, d_kt, 1, 5, 0.0, 0.0);
WebChart(0, "", "Warmwasser|C", 0xFF9800, d_pos, DAY_LEN, d_bw, 1, 5, 0.0, 0.0);
WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);");
// --- weekly chart: 3 series, wall-clock 30-min slots, interval = 30 min ---
WebChart(0, "Temperaturen Woche", "Aussen|C", 0x2196F3, w_pos, WEEK_LEN, w_at, 1, 30, 0.0, 0.0);
WebChart(0, "", "Speicher|C", 0xFF5722, w_pos, WEEK_LEN, w_ss, 1, 30, 0.0, 0.0);
WebChart(0, "", "Kollektor|C", 0x4CAF50, w_pos, WEEK_LEN, w_ct, 1, 30, 0.0, 0.0);
WebChart(0, "", "Keller|C", 0x9C27B0, w_pos, WEEK_LEN, w_kt, 1, 30, 0.0, 0.0);
WebChart(0, "", "Warmwasser|C", 0xFF9800, w_pos, WEEK_LEN, w_bw, 1, 30, 0.0, 0.0);
WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);");
// --- 24h consumption: avg power per 5-min slot in W (diff of accumulated kWh meters ×12000) ---
WebChart(0, "Verbrauch 24h [W]", "Brauchw.-WP|W", 0xE91E63, d_pos, DAY_LEN, d_bww, 1, 5, 0.0, 0.0);
WebChart(0, "", "Solarpumpe|W", 0x00BCD4, d_pos, DAY_LEN, d_spp, 1, 5, 0.0, 0.0);
// dual y-axis: WP (~700 W) left, Solarpumpe (~10 W) right — each auto-scaled independently
WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);o.series={0:{targetAxisIndex:0},1:{targetAxisIndex:1}};o.vAxes={0:{title:'WP'},1:{title:'Solar'}};delete o.vAxis;");
// --- weekly consumption: avg power per 30-min slot in W (×2000) ---
WebChart(0, "Verbrauch Woche [W]", "Brauchw.-WP|W", 0xE91E63, w_pos, WEEK_LEN, w_bww, 1, 30, 0.0, 0.0);
WebChart(0, "", "Solarpumpe|W", 0x00BCD4, w_pos, WEEK_LEN, w_spp, 1, 30, 0.0, 0.0);
// dual y-axis: WP left, Solarpumpe right — independent auto-scale
WebChartJS("for(var r=0;r<dt.getNumberOfRows();r++)for(var c=1;c<dt.getNumberOfColumns();c++)if(dt.getValue(r,c)==0)dt.setValue(r,c,null);o.series={0:{targetAxisIndex:0},1:{targetAxisIndex:1}};o.vAxes={0:{title:'WP'},1:{title:'Solar'}};delete o.vAxis;");
webSend("</div>");
}
// Flush chart history to /sml_ebus.bin on a clean shutdown (device Restart / OTA).
// Dirty-guarded so a freshly-imported .bin is never clobbered before any new sample.
void OnExit() { if (chart_dirty) { sml_save(); } }
void CleanUp() { if (chart_dirty) { sml_save(); } }
void WebUI() {
webCheckbox(sml_activ, "Enable SML");
}