sml_water.tc¶
sml_water.tc — water-meter (pulse counter, Reed contact / S0) with charts
// ============================================================================
// sml_water.tc — water-meter (pulse counter, Reed contact / S0) with charts
// ============================================================================
//
// TinyC port of ottelo's 6_SML_Wasseruhr.tas.
//
// Why standalone (not sharing sml_chart_common.tc):
// * Sensor is a pulse counter on a single GPIO — no rx/tx UART pair
// * Descriptor uses Tasmota's SML counter syntax (`+1,<pin>,c,1,-50,...`)
// instead of the serial-meter syntax, so the `smlApplyPins` placeholder
// trio (rxpin / txpin / smlf) doesn't map cleanly. We build the
// descriptor file ourselves at runtime via fileWrite — cleanest for a
// two-field config (pin + factor).
// * Reading is monotonic m³, not oscillating power → no moving-average
// filter needed. 4 h / 24 h charts show cumulative m³ directly so the
// slope visualises consumption rate (flat = no usage, steep = high
// flow). Matches ottelo's intent.
//
// Storage footprint (PSRAM-backed via special_calloc on ESP32-S3):
// sml_w_s4h: 481 floats ≈ 1.9 KB (4 h @ 30 s/sample)
// sml_w_s24h: 1441 floats ≈ 5.6 KB (24 h @ 60 s/sample)
// sml_w_dcon: 31 floats ≈ 124 B
// sml_w_mcon: 12 floats ≈ 48 B
// ----------------------------------------------
// Total chart state ≈ 8 KB
//
// Wiring + Setup:
// 1. Connect Reed contact / S0 output of water meter to a free GPIO (with
// pull-up to 3V3 — Tasmota's counter mode `c,1` enables the internal
// pull-up. Debounce 50 ms covers typical mechanical bounce.)
// 2. Pick the GPIO under "Einstellungen → Wasseruhr".
// 3. Set `factor` (pulses per m³) — typical reed switches send 1
// pulse / 0.0001 m³ = 10000 pulses/m³ (default).
// 4. Toggle "SML aktiv" checkbox — emits `Rule1 1` and the SML driver
// starts polling the counter.
// 5. On first start the m³ counter reads from 0; set the absolute meter
// reading via Tasmota console: `Sensor53 c1 <reading_in_m³_×_factor>`.
//
// Requires: USE_SML (or USE_SML_M) + USE_UFILESYS in firmware.
// ============================================================================
// ── Persisted descriptor + factor (changeable via WebUI) ────────────────────
persist watch int sml_w_pin; // counter GPIO (-1 = unset, set on first boot)
persist watch int sml_w_factor; // pulses per m³ (default 10000)
persist watch int sml_w_activ; // mirrors tasm_rule bit 0
// ── Consumption baselines (delta against current m³ counter) ───────────────
persist float sml_w_dval; // daily zero point
persist float sml_w_mval; // monthly zero point
persist float sml_w_yval; // yearly zero point
persist int sml_w_da; // last seen day-of-month (month-wrap)
// ── Rolling chart arrays (cumulative m³) ───────────────────────────────────
// MUST be float arrays — WebChart bit-casts each slot to float (vm.h SYS_WEB_CHART
// `memcpy(&fval, &arr[idx], sizeof(float))`). See sml_chart_common.tc note.
persist float sml_w_s4h[481]; // 4 h chart, 30 s per slot
persist float sml_w_s24h[1441]; // 24 h chart, 60 s per slot
persist int sml_w_s4h_pos;
persist int sml_w_s24h_pos;
// ── Daily / monthly column arrays — 0-based indexing ───────────────────────
persist float sml_w_dcon[31]; // m³ per day, [day-1] = today
persist float sml_w_mcon[12]; // m³ per month, [month-1] = this month
// ── Runtime state ──────────────────────────────────────────────────────────
int sml_w_t1; // 5 s downcounter
int sml_w_t2; // 60 s downcounter
int sml_w_hr_last; // for midnight edge-detect
char sml_w_buf[200]; // sprintf scratch
// ── Transient WebButton flags ──────────────────────────────────────────────
int do_init; // re-baseline counters
int do_init2; // reset daily + monthly columns
int do_save; // manual save
int do_reset; // Sensor53 r
// Uncomment to pre-fill all four charts with synthetic data — useful for
// visually validating chart rendering without waiting for real pulses.
//#define SML_WATER_DEMO
// ============================================================================
// Descriptor file builder — writes /sml_water.def from current pin + factor
// ============================================================================
//
// Tasmota SML descriptor for a pulse counter:
// +1,<gpio>,c,1,-50,Wasser
// +1 = meter slot
// gpio = the configured pin
// c = counter mode
// 1 = pull-up enabled
// -50 = debounce window (negative = ms with IRQ-driven counting)
// Wasser = display name
// 1,1-0:1.8.0*255(@<factor>,Wasseruhr,m3,Wasseruhr,4)
// 1 = meter slot
// 1-0:1.8.0*255 = OBIS "energy in" — repurposed for cumulative m³
// @<factor> = divisor (pulses per m³)
// Wasseruhr = field name
// m3 = unit (ASCII-safe in SML config)
// Wasseruhr = JSON key
// 4 = decimal places
// # = end of descriptor block
//
// Idempotent — caller skips re-write when pin/factor unchanged.
void sml_water_write_descriptor() {
if (sml_w_pin < 0 || sml_w_factor <= 0) return;
int h = fileOpen("/sml_water.def", "w");
if (h < 0) {
addLog("sml_water: cannot open /sml_water.def for write");
return;
}
// Build the whole descriptor in one buffer then write once. Passing a
// string literal to fileWrite() makes the compiler emit
// SYS_FILE_WRITE_STR (259) which has no firmware handler (CLAUDE.md
// §11 known gap → "Unknown syscall" at runtime) — so the trailing
// "#\n" terminator must come from a char[], not a literal.
sprintf(sml_w_buf,
"+1,%d,c,1,-50,Wasser\n1,1-0:1.8.0*255(@%d,Wasseruhr,m3,Wasseruhr,4)\n#\n",
sml_w_pin, sml_w_factor);
fileWrite(h, sml_w_buf, strlen(sml_w_buf));
fileClose(h);
addLog("sml_water: wrote /sml_water.def pin=%d factor=%d", sml_w_pin, sml_w_factor);
}
// ============================================================================
// Change-detect + re-apply: descriptor / pin / factor / activ.
// ============================================================================
void sml_water_apply() {
int pin_changed = changed(sml_w_pin);
int factor_changed = changed(sml_w_factor);
int activ_changed = changed(sml_w_activ);
if (activ_changed) {
tasm_rule = sml_w_activ;
snapshot(sml_w_activ);
}
if (pin_changed || factor_changed) {
sml_water_write_descriptor();
snapshot(sml_w_pin);
snapshot(sml_w_factor);
tasmCmd("Sensor53 r", sml_w_buf);
}
}
// ============================================================================
// Save / load chart arrays to /sml_water.bin
// ============================================================================
void sml_water_save() {
int h = fileOpen("/sml_water.bin", "w");
if (h < 0) {
addLog("sml_water_save: fileOpen failed");
return;
}
fileWriteBin(h, sml_w_s4h, 481);
fileWriteBin(h, sml_w_s24h, 1441);
fileWriteBin(h, sml_w_dcon, 31);
fileWriteBin(h, sml_w_mcon, 12);
fileClose(h);
}
void sml_water_load() {
int h = fileOpen("/sml_water.bin", "r");
if (h < 0) return;
fileReadBin(h, sml_w_s4h, 481);
fileReadBin(h, sml_w_s24h, 1441);
fileReadBin(h, sml_w_dcon, 31);
fileReadBin(h, sml_w_mcon, 12);
fileClose(h);
}
// ============================================================================
// Init helpers — reset baselines + clear chart arrays
// ============================================================================
void sml_water_init_baselines() {
float total = smlGet(1);
sml_w_dval = total;
sml_w_mval = total;
sml_w_yval = total;
sml_w_s4h_pos = 0;
sml_w_s24h_pos = 0;
int i = 0;
while (i < 481) { sml_w_s4h[i] = 0.0; i = i + 1; }
i = 0;
while (i < 1441) { sml_w_s24h[i] = 0.0; i = i + 1; }
sml_water_save();
addLog("sml_water: baselines set from meter, 4h/24h charts cleared");
}
void sml_water_init_columns() {
int i = 0;
while (i < 31) { sml_w_dcon[i] = 0.0; i = i + 1; }
i = 0;
while (i < 12) { sml_w_mcon[i] = 0.0; i = i + 1; }
sml_water_save();
addLog("sml_water: daily + monthly column charts cleared");
}
// ============================================================================
// 5-s tick — sample current m³ and push to the rolling chart arrays.
// Cumulative m³ goes in directly (no moving-average filter — the counter
// is monotonic, averaging just adds lag for no smoothing benefit).
// ============================================================================
void sml_water_5s_tick() {
float total = smlGet(1);
int idx4 = ((tasm_hour - 4 + 24) % 24) * 120 + tasm_minute * 2 + (tasm_second / 30);
int idx24 = tasm_hour * 60 + tasm_minute;
if (idx4 >= 0 && idx4 < 480) {
sml_w_s4h[idx4] = total;
sml_w_s4h_pos = idx4;
}
if (idx24 >= 0 && idx24 < 1440) {
sml_w_s24h[idx24] = total;
sml_w_s24h_pos = idx24;
}
}
// ============================================================================
// 60-s tick — daily / monthly counters + midnight rollover.
// Identical structure to sml_chart_common's 60 s tick but in m³.
// ============================================================================
void sml_water_60s_tick() {
float total = smlGet(1);
int d = tasm_day;
int m = tasm_month;
if (d >= 1 && d <= 31) sml_w_dcon[d - 1] = total - sml_w_dval;
if (m >= 1 && m <= 12) sml_w_mcon[m - 1] = total - sml_w_mval;
int hr = tasm_hour;
if (sml_w_hr_last != hr && hr == 0) {
if (d > 1) {
sml_w_da = d;
} else {
int i = sml_w_da;
while (i < 31) { sml_w_dcon[i] = 0.0; i = i + 1; }
sml_w_mval = total;
sml_w_da = 1;
}
if (d == 1 && m == 1) sml_w_yval = total;
sml_w_dval = total;
sml_water_save();
}
sml_w_hr_last = hr;
}
// ============================================================================
// Render: totals row on the main sensor page
// ============================================================================
void sml_water_render_totals() {
float total = smlGet(1);
sprintf(sml_w_buf, "{s}Zaehlerstand{m}%.4f m³{e}", total); webSend(sml_w_buf);
sprintf(sml_w_buf, "{s}Tagesverbrauch{m}%.4f m³{e}", total - sml_w_dval); webSend(sml_w_buf);
sprintf(sml_w_buf, "{s}Monatsverbrauch{m}%.3f m³{e}", total - sml_w_mval); webSend(sml_w_buf);
sprintf(sml_w_buf, "{s}Jahresverbrauch{m}%.3f m³{e}", total - sml_w_yval); webSend(sml_w_buf);
}
// ============================================================================
// Render: WebChart line charts (4 h + 24 h cumulative m³)
// ============================================================================
void sml_water_render_4h() {
WebChartSize(600, 280);
// type=0 (line), color=blue, 4 decimals, interval=1 min, ymin/ymax=0 (auto)
WebChart(0, "Verbrauch 4 Stunden [m\xC2\xB3]", "m\xC2\xB3", 0x3498db,
sml_w_s4h_pos, 480, sml_w_s4h, 4, 1, 0.0, 0.0);
}
void sml_water_render_24h() {
WebChartSize(600, 280);
WebChart(0, "Verbrauch 24 Stunden [m\xC2\xB3]", "m\xC2\xB3", 0x3498db,
sml_w_s24h_pos, 1440, sml_w_s24h, 4, 1, 0.0, 0.0);
}
// ============================================================================
// Render: daily + monthly column charts — direct Google Charts emit so we
// get per-bar tri-color (past=green / today=red / future=blue). Same idiom
// as in sml_chart_common.tc render_days/render_months.
// ============================================================================
void sml_water_render_days() {
int today = tasm_day;
if (today < 1) today = 1;
webSend("<div id='smlw_dch' style='text-align:center;width:600px;height:280px'></div>");
webSend("<script>function _smlwDD(){var d=google.visualization.arrayToDataTable([['Tag','Verbrauch [m³]',{role:'style'}]");
int i = 1;
while (i <= 31) {
if (i < today) sprintf(sml_w_buf, ",[%d,%.4f,'green']", i, sml_w_dcon[i - 1]);
else if (i == today) sprintf(sml_w_buf, ",[%d,%.4f,'red']", i, sml_w_dcon[i - 1]);
else sprintf(sml_w_buf, ",[%d,%.4f,'blue']", i, sml_w_dcon[i - 1]);
webSend(sml_w_buf);
i = i + 1;
}
webSend("]);new google.visualization.ColumnChart(document.getElementById('smlw_dch')).draw(d,{chartArea:{left:60,right:20,height:'75%'},legend:'none',title:'Tagesverbraeuche Monatsansicht',vAxis:{format:'# m³'},hAxis:{title:'Tag',ticks:[1,5,10,15,20,25,30]}});}google.charts.setOnLoadCallback(_smlwDD);</script>");
}
void sml_water_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='smlw_mch' style='text-align:center;width:600px;height:280px'></div>");
webSend("<script>function _smlwDM(){var d=google.visualization.arrayToDataTable([['Monat','Verbrauch [m³]',{role:'style'}]");
int i = 1;
while (i <= 12) {
strToken(mn, mn_names, '|', i);
if (i < thismon) sprintf(sml_w_buf, ",['%s',%.3f,'green']", mn, sml_w_mcon[i - 1]);
else if (i == thismon) sprintf(sml_w_buf, ",['%s',%.3f,'red']", mn, sml_w_mcon[i - 1]);
else sprintf(sml_w_buf, ",['%s',%.3f,'blue']", mn, sml_w_mcon[i - 1]);
webSend(sml_w_buf);
i = i + 1;
}
webSend("]);new google.visualization.ColumnChart(document.getElementById('smlw_mch')).draw(d,{chartArea:{left:60,right:30,top:30,height:'70%'},legend:'none',title:'Monatsverbraeuche Jahresansicht',vAxis:{format:'# m³'},hAxis:{slantedText:false,showTextEvery:1}});}google.charts.setOnLoadCallback(_smlwDM);</script>");
}
// ============================================================================
// Settings panel — counter pin pulldown + factor input + activ checkbox
// ============================================================================
void sml_water_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>🚰 Wasseruhr</h3>");
int up_d = tasm_uptime / 86400;
int up_h = (tasm_uptime / 3600) % 24;
int up_m = (tasm_uptime / 60) % 60;
sprintf(sml_w_buf, "<div>Uptime: %d d %d h %d min</div>", up_d, up_h, up_m);
webSend(sml_w_buf);
webCheckbox(sml_w_activ, "SML Zaehler aktiv");
webSend("📝 <a href='/ufse?file=/sml_water.def'>Descriptor bearbeiten</a>");
webSend("<hr><b>🔌 Counter-GPIO</b>");
webPulldown(sml_w_pin, "Pin (Reed/S0)", "@getfreepins");
webSend("<div class='hint'>Reed contact / S0 output of the meter. Internal pull-up enabled, 50 ms debounce.</div>");
webSend("<hr><b>⚖️ Faktor</b>");
webNumber(sml_w_factor, 1, 1000000, "Pulses per m³");
webSend("<div class='hint'>10000 = 1 pulse / 0.0001 m³<br>1000 = 1 pulse / 0.001 m³ (1 L)</div>");
}
#ifdef SML_WATER_DEMO
// ── Synthetic chart data — purely for rendering validation ────────────────
void sml_water_demo_fill() {
// 4h: smooth cumulative curve, starts at 100 m³, +5 m³ over 4h
int i = 0;
while (i < 480) {
float t = (float)i / 480.0;
sml_w_s4h[i] = 100.0 + 5.0 * t;
i = i + 1;
}
sml_w_s4h_pos = 240;
// 24h: similar but over 24h, +30 m³
i = 0;
while (i < 1440) {
float t = (float)i / 1440.0;
sml_w_s24h[i] = 80.0 + 30.0 * t;
i = i + 1;
}
sml_w_s24h_pos = 720;
// Daily: 0.2..1.5 m³ per day
i = 0;
while (i < 31) {
float ph = (float)i / 31.0 * 6.28318;
sml_w_dcon[i] = 0.85 + 0.65 * cos(ph);
i = i + 1;
}
// Monthly: 8..25 m³ per month
i = 0;
while (i < 12) {
float ph = (float)i / 12.0 * 6.28318;
sml_w_mcon[i] = 16.0 + 8.0 * cos(ph);
i = i + 1;
}
addLog("sml_water: demo data loaded into all four charts");
}
#endif
// ============================================================================
// Callbacks
// ============================================================================
void EverySecond() {
sml_water_apply();
// Manual reset button — one-shot
if (do_reset) {
tasmCmd("Sensor53 r", sml_w_buf);
do_reset = 0;
}
if (tasm_year < 2020) return;
if (sml_w_pin < 0) return; // no pin picked yet → idle
if (do_init) { sml_water_init_baselines(); do_init = 0; }
if (do_init2) { sml_water_init_columns(); do_init2 = 0; }
if (do_save) { sml_water_save(); do_save = 0; }
sml_w_t1 = sml_w_t1 - 1;
if (sml_w_t1 <= 0) {
sml_w_t1 = 5;
sml_water_5s_tick();
}
sml_w_t2 = sml_w_t2 - 1;
if (sml_w_t2 <= 0) {
sml_w_t2 = 60;
sml_water_60s_tick();
}
}
// ── Main page, sensor-table block ──
void WebCall() {
if (!sml_w_activ) {
webSend("{s}Wasseruhr{m}disabled (Rule1 off){e}");
return;
}
sml_water_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'>");
sml_water_render_4h();
sml_water_render_24h();
sml_water_render_days();
sml_water_render_months();
webSend("</div>");
}
// ── Settings sub-page (menu button) ──
void WebUI() {
sml_water_render_settings_panel();
webSend("<hr><b>💾 Daten</b>");
webButton(do_init, "Zaehlerwerte initialisieren|initialisiert");
webButton(do_init2, "Balkendiagramme zuruecksetzen|zurueckgesetzt");
webButton(do_save, "Diagrammdaten speichern|gespeichert");
webSend("<hr><b>🔄 SML neu initialisieren</b>");
webButton(do_reset, "Sensor53 r|SML neu geladen");
webSend("<div style='text-align:center;font-size:10px;color:#777;margin-top:10px'><b>sml_water.tc</b><br>TinyC port of ottelo's 6_SML_Wasseruhr.tas</div></div>");
}
int main() {
// First-run defaults: pin -1 (unset), factor 10000 (typical reed switch).
// sml_w_factor == 0 is the "uninitialized" sentinel — a 0 factor would
// divide-by-zero in SML's reading-scale calculation.
if (sml_w_pin == 0 && sml_w_factor == 0) sml_w_pin = -1;
if (sml_w_factor == 0) sml_w_factor = 10000;
if (sml_w_da == 0) sml_w_da = 1;
if (sml_w_activ != tasm_rule) {
tasm_rule = sml_w_activ;
}
sml_water_load();
#ifdef SML_WATER_DEMO
sml_water_demo_fill();
#endif
// Make sure descriptor file is in sync with persisted settings at boot —
// if the user changed factor/pin between FW reboots, the .def file might
// be stale.
sml_water_write_descriptor();
webPageLabel(0, "Einstellungen / Daten");
webPageLabel(1, ""); // clear any stale slot-1 label
smlScripterLoad("/sml_water.def");
addLog("sml_water: started pin=%d factor=%d activ=%d",
sml_w_pin, sml_w_factor, sml_w_activ);
return 0;
}