house_monitor.tc¶
house_monitor.tc — compact, table-driven house monitor with an LVGL dashboard
// house_monitor.tc — compact, table-driven house monitor with an LVGL dashboard
// + spoken (German TTS) alarms. Runs on the ILI9488 480x320 audio+display node
// (192.168.188.135), taking over the screen from the clock slot.
//
// DESIGNED TO GROW: adding a monitored variable = 3 small edits —
// 1) add its name to `names` 2) add a setRule(...) line in main()
// 3) add its value read in readVals() (globals can't be indexed dynamically)
// Everything else (day/night gate, debounce, alarm, TTS, the row list) is shared.
//
// Rule types:
// PEER — solar peer-comparison (self-calibrating, capacity-agnostic): an
// inverter is "dead" if it's flat while >= PEER_MIN of the group are
// delivering. The peers ARE the "is it sunny" reference.
// DAYMIN — value >= p1 during daylight AND while it's actually sunny.
// RANGE — value must stay within [p1, p2].
// FROZEN — value must CHANGE within p1 minutes, else it's stuck.
// SOCLIVE — a battery-SOC row (Powerwall / Marstek). Shows the SOC %, and goes
// RED when its watched UDP global stops ARRIVING for p1 minutes.
// Uses written(var): the flag fires on every inbound UDP packet
// (even a flat SOC), so it's a true "is the device still broadcasting"
// signal — a packet-level heartbeat, not a value heartbeat.
// ENALIVE — pure UDP-liveness row (Energy_Manager .61). Shows the age of the
// last packet ("vor N s") and alarms if none arrived for p1 minutes.
// INFO — display only, never alarms.
//
// SOCLIVE/ENALIVE need firmware where UDP-receive sets the watch flag
// (tc_udp_on_receive -> tc_global_write_with_watch) AND a compiler that
// self-registers receive-only watch vars (STORE_WATCH at init). On older builds
// written() only fires on LOCAL writes, so these rows would false-alarm.
//
// Screen: date/time header (with seconds) + one thin card row per variable —
// value green when ok, RED on detection/alarm. Alarm also speaks via deferred
// I2STTS (needs the picotts de-DE voice on the device).
#define N 8 // number of rules (grow this)
#define DAYMIN 1
#define RANGE 2
#define FROZEN 3
#define PEER 4
#define INFO 5 // display only — never alarms
#define SOCLIVE 6 // battery-SOC row + UDP-liveness (Powerwall/Marstek)
#define ENALIVE 7 // pure UDP-liveness row (Energy_Manager .61)
#define TMPLIVE 8 // temperature value + UDP-liveness (e.g. .150 Solarspeicher scol)
// LVGL touch-event code (LVGL 9.5) + label align — for the on-screen mute switch
#define EV_CLICKED 10
#define AL_CENTER 9
// LVGL style-int ids + colours (from the moritz dashboard)
#define ST_RADIUS 120
#define ST_BORDER 56
#define ST_BCOLOR 57
#define ST_BOPA 58
#define C_BG 0x0E1116
#define C_CARD 0x1B2530
#define C_CARDB 0x2C3A48
#define C_TITLE 0xE8EAED
#define C_SUB 0x9AA0A6
#define C_AMBER 0xFBBC04
#define C_GREEN 0x34A853
#define C_RED 0xEA4335
#define C_ALARMBG 0x3A1414
// tunables
#define DAY_MARGIN 60 // (DAYMIN) min after sunrise / before sunset before judging
#define SOLAR_ACTIVE_W 200.0 // (DAYMIN) only judge while some inverter makes > this
#define PRODUCING_W 50.0 // (PEER) a healthy inverter in sun makes >> this
#define DEAD_W 20.0 // (PEER) "flat" — delivering essentially nothing
#define PEER_MIN 2 // (PEER) this many peers must deliver to trust the sun signal
// per-device UDP-liveness stale thresholds (minutes). Set well above the observed
// broadcast gap so a normal lull never false-alarms, but a stopped device is caught.
#define PW_STALE_MIN 10 // (SOCLIVE) Powerwall pwl: relayed ~every 30 s via .20
#define MA_STALE_MIN 15 // (SOCLIVE) Marstek msoc: app-driven, can be bursty/slow
#define EN_STALE_MIN 5 // (ENALIVE) .61 sedc: broadcasts ~every 10-12 s (>S round-robin)
#define SC_STALE_MIN 5 // (TMPLIVE) .150 scol: sml_ebus broadcasts every ~1 s
#define DEBOUNCE_S 300 // stay bad this long before the first alarm (5 min)
#define REALARM_S 300 // re-speak this often while tripped (5 min)
#define QUIET_START 22 // mute spoken alarms from 22:00 ...
#define QUIET_END 6 // ... until 06:00 (still detect + show red, just silent)
#define ROWH 28 // px per row (fits ~10 on 480x320)
// ── the rule config (parallel columns) ──
char names[] = "Hausdach|Gartenhaus|Garten|Garage|Powerwall|Marstek|Energiemonitor|Solarspeicher";
int typ[N]; float p1[N]; float p2[N];
// fleet globals (auto-received over UDP). SIGN: sedc is +producing, wr* are
// -producing (matches energy_dashboard.tc) — normalised in readVals().
// sedc / pwl / msoc are `watch`: written(x) fires on each inbound UDP packet, so
// EverySecond can tell a live source from a stalled one at the PACKET level (works
// even when the value sits flat — a battery SOC or a night-time 0). Receive-only
// watch vars are declared WITHOUT an initializer (an initializer would broadcast
// at boot and clobber the fleet source).
global watch float sedc; // .61 Hausdach inverter — doubles as the .61 heartbeat
global float wrgh; global float wrgg; global float wrga; // other inverters (peer group)
global watch float pwl; // Powerwall SOC % — Tesla gateway reader (.140 -> .20 relay)
global watch float msoc; // Marstek SOC % — Venus E (.170)
global watch float scol; // Solarspeicher temp — sml_ebus (.150) heartbeat
// runtime state
float val[N]; int viol[N]; int alm[N]; int spk[N]; int badf[N]; // badf = live bad flag (for WebUI)
float lastv[N]; int lastchg[N]; // FROZEN change-tracking
// PEER thresholds kept in vars (compare var-vs-var in if(); never float-literal-to-int)
float producing_w; float dead_w; int peer_min; int grp_nprod;
// UDP-liveness per row: seen[i] = uptime of the last packet, liv[i] = stalled flag.
// Only the SOCLIVE/ENALIVE rows use these; the rest stay 0.
int seen[N]; int liv[N];
// per-row acknowledge (mute). PERSISTED so a known, unfixable-for-now fault (e.g.
// a dead inverter awaiting replacement) stays quiet across reboots. When acked[i],
// the row still DETECTS + SHOWS the fault (amber, not red), only the spoken TTS
// alarm is muted. Toggle via the web button or `HM ack <row>`; a muted PEER
// inverter auto-re-arms once it delivers again (see EverySecond). NOT reset in
// main() — persist restores it.
persist int acked[N];
// LVGL handles
int clockL; int rowC[N]; int rowN[N]; int rowV[N];
// on-screen (touch) mute switch per row + its label + a cached display-state
// (btnSt: -2 uninit, -1 hidden, 0 shown-loud, 1 shown-muted) so we only touch LVGL
// when the state actually changes.
int muteBtn[N]; int muteLbl[N]; int btnSt[N];
// scratch (globals — TinyC keeps buffers global)
char g_cmd[320]; char g_btn[200]; char g_msg[96]; char g_s[64]; char g_nm[20];
char wdays[] = "So|Mo|Di|Mi|Do|Fr|Sa";
void speak(char m[]) {
// Quiet hours: detect + show red as usual, but stay silent (no I2STTS).
if (tasm_hour >= QUIET_START || tasm_hour < QUIET_END) {
sprintf(g_cmd, "HM: quiet hours - muted: %s", m); addLog(g_cmd); return;
}
sprintf(g_cmd, "I2STTS %s", m); tasmDefer(g_cmd); addLog(g_cmd);
}
void setRule(int i, int t, float a, float b) { typ[i] = t; p1[i] = a; p2[i] = b; }
// The ONE place that reads the actual globals (with per-source sign).
// The Energiemonitor row (6) has no value of its own — its val is the packet age,
// set in EverySecond.
void readVals() {
val[0] = sedc; // Hausdach (positive = producing)
val[1] = -wrgh; // Gartenhaus (stored negative)
val[2] = -wrgg; // Garten
val[3] = -wrga; // Garage
val[4] = pwl; // Powerwall SOC %
val[5] = msoc; // Marstek SOC %
val[7] = scol; // Solarspeicher temp (.150) [row 6 = packet age, set in EverySecond]
}
int main() {
addCommand("HM"); // `HM ack <row>` (mute) | `HM test` | `HM stat`
producing_w = PRODUCING_W; dead_w = DEAD_W; peer_min = PEER_MIN;
// rule table: setRule(index, type, p1, p2). For SOCLIVE/ENALIVE, p1 = stale minutes.
// 0-3 solar inverters: PEER (judge each other, no fixed threshold).
setRule(0, PEER, 0.0, 0.0);
setRule(1, PEER, 0.0, 0.0);
setRule(2, PEER, 0.0, 0.0);
setRule(3, PEER, 0.0, 0.0);
// 4 Powerwall SOC + liveness (written(pwl)); 5 Marstek SOC + liveness (written(msoc)).
setRule(4, SOCLIVE, PW_STALE_MIN, 0.0);
setRule(5, SOCLIVE, MA_STALE_MIN, 0.0);
// 6 Energy_Manager (.61) liveness (written(sedc)) — shows packet age.
setRule(6, ENALIVE, EN_STALE_MIN, 0.0);
// 7 sml_ebus (.150) Solarspeicher temp + liveness (written(scol)).
setRule(7, TMPLIVE, SC_STALE_MIN, 0.0);
lvglInit(); lvglClean(0); lvglSetBgColor(0, C_BG);
clockL = lvglLabel(0);
lvglSetFont(clockL, 22); lvglSetTextColor(clockL, C_TITLE); lvglSetPos(clockL, 8, 6);
lvglSetText(clockL, "--");
int i = 0;
while (i < N) {
int y = 40 + i * ROWH;
rowC[i] = lvglObj(0);
lvglSetPos(rowC[i], 6, y); lvglSetSize(rowC[i], 468, ROWH - 3);
lvglSetBgColor(rowC[i], C_CARD);
lvglSetStyleInt(rowC[i], ST_RADIUS, 6);
lvglSetStyleInt(rowC[i], ST_BORDER, 1);
lvglSetStyleInt(rowC[i], ST_BOPA, 255);
lvglSetStyleInt(rowC[i], ST_BCOLOR, C_CARDB);
strToken(g_nm, names, '|', i + 1);
rowN[i] = lvglLabel(0);
lvglSetText(rowN[i], g_nm); lvglSetFont(rowN[i], 18); lvglSetTextColor(rowN[i], C_TITLE);
lvglSetPos(rowN[i], 16, y + 2);
rowV[i] = lvglLabel(0);
lvglSetFont(rowV[i], 18); lvglSetTextColor(rowV[i], C_SUB);
lvglSetPos(rowV[i], 280, y + 2); // left-shifted to make room for the mute switch
lvglSetText(rowV[i], "--");
// per-row mute switch (LCD touch): a small button parked off-screen; EverySecond
// slides it in (x=414) only while the row alarms, and a tap toggles the mute.
muteBtn[i] = lvglButton(0);
lvglSetSize(muteBtn[i], 50, ROWH - 7);
lvglSetPos(muteBtn[i], 520, y); // off-screen = hidden
lvglEventEnable(muteBtn[i], EV_CLICKED);
muteLbl[i] = lvglLabel(muteBtn[i]);
lvglSetFont(muteLbl[i], 12); lvglAlign(muteLbl[i], AL_CENTER, 0, 0);
lvglSetText(muteLbl[i], "Ruhe");
viol[i] = 0; alm[i] = 0; spk[i] = -100000; lastv[i] = 0.0; lastchg[i] = 0;
seen[i] = 0; liv[i] = 0; btnSt[i] = -2;
i = i + 1;
}
addLog("house_monitor: ready");
return 0;
}
int isBad(int i, int day, int active) {
int t = typ[i]; float v = val[i]; float a = p1[i]; float b = p2[i];
// float compares live inside if() only — never `return <float compare>`.
if (t == INFO) { return 0; } // display only
if (t == SOCLIVE) { return liv[i]; } // UDP-liveness (computed in EverySecond)
if (t == ENALIVE) { return liv[i]; } // UDP-liveness (computed in EverySecond)
if (t == TMPLIVE) { return liv[i]; } // temp value + UDP-liveness (.150 Solarspeicher)
if (t == PEER) {
if (grp_nprod < peer_min) { return 0; } // too few delivering -> can't judge (dark/overcast)
if (v < dead_w) { return 1; } // flat while >= peer_min peers deliver -> dead
return 0;
}
if (t == DAYMIN) {
if (day == 0 || active == 0) { return 0; }
if (v < a) { return 1; }
return 0;
}
if (t == RANGE) {
if (v < a) { return 1; }
if (v > b) { return 1; }
return 0;
}
if (t == FROZEN) {
if (v != lastv[i]) { lastv[i] = v; lastchg[i] = tasm_uptime; return 0; }
int elapsed = tasm_uptime - lastchg[i];
if (elapsed >= (int)(a * 60.0)) { return 1; }
return 0;
}
return 0;
}
void problemPhrase(int i) { // -> g_s
int t = typ[i];
if (t == DAYMIN || t == PEER) { strcpy(g_s, "liefert keine Leistung"); }
else if (t == SOCLIVE) { strcpy(g_s, "sendet keine Daten mehr"); }
else if (t == ENALIVE) { strcpy(g_s, "Energiemonitor tot"); }
else if (t == TMPLIVE) { strcpy(g_s, "sendet keine Daten mehr"); }
else if (t == FROZEN) { strcpy(g_s, "haengt fest"); }
else if (val[i] > p2[i]) { strcpy(g_s, "zu hoch"); }
else { strcpy(g_s, "zu niedrig"); }
}
// One UDP-liveness row: prime seen[] on the first tick, refresh it whenever a new
// packet arrived (written()), then flag stalled if nothing has arrived for
// stale_min. `fired` is the caller's written(var) result (written() can't take a
// dynamic arg, so the caller passes it in and snapshots on its own).
void liveTick(int i, int fired, int stale_min) {
if (seen[i] == 0) { seen[i] = tasm_uptime; } // prime on first tick (boot grace)
if (fired) { seen[i] = tasm_uptime; }
liv[i] = 0;
int age = tasm_uptime - seen[i];
if (age >= stale_min * 60) { liv[i] = 1; }
}
void EverySecond() {
if (tasm_year < 2025) { return; } // wait for NTP
// clock header — date + time WITH seconds
strToken(g_nm, wdays, '|', tasm_wday); // tasm_wday 1=So..7=Sa
sprintf(g_s, "%s %02d.%02d.%04d %02d:%02d:%02d",
g_nm, tasm_day, tasm_month, tasm_year, tasm_hour, tasm_minute, tasm_second);
lvglSetText(clockL, g_s);
readVals();
// daylight + is-it-sunny gate (peak of the DAYMIN rules)
int now = tasm_time; int rise = tasm_sunrise; int set = tasm_sunset;
if (set <= 0) { rise = 420; set = 1140; } // 07:00..19:00 fallback if USE_SUNRISE off
int day = (now > rise + DAY_MARGIN) && (now < set - DAY_MARGIN);
float pmax = -99999.0; int i = 0;
while (i < N) { if (typ[i] == DAYMIN && val[i] > pmax) { pmax = val[i]; } i = i + 1; }
// NOTE: TinyC miscompiles `int x = <float compare>` — do the compare inside if().
float actthr = SOLAR_ACTIVE_W;
int active = 0;
if (pmax > actthr) { active = 1; }
// PEER producer count: how many solar inverters are actually delivering.
grp_nprod = 0; int jp = 0;
while (jp < N) {
if (typ[jp] == PEER) {
if (val[jp] > producing_w) { grp_nprod = grp_nprod + 1; }
}
jp = jp + 1;
}
// ── UDP-liveness rows — written(x) fires on every inbound packet (even a flat
// SOC / a night-time 0), so it's a true "is the device still broadcasting"
// signal. snapshot(x) clears the flag for the next detection. The three
// watched vars are read explicitly (written() needs a literal arg).
if (written(pwl)) { snapshot(pwl); liveTick(4, 1, PW_STALE_MIN); } else { liveTick(4, 0, PW_STALE_MIN); }
if (written(msoc)) { snapshot(msoc); liveTick(5, 1, MA_STALE_MIN); } else { liveTick(5, 0, MA_STALE_MIN); }
if (written(sedc)) { snapshot(sedc); liveTick(6, 1, EN_STALE_MIN); } else { liveTick(6, 0, EN_STALE_MIN); }
if (written(scol)) { snapshot(scol); liveTick(7, 1, SC_STALE_MIN); } else { liveTick(7, 0, SC_STALE_MIN); }
val[6] = (float)(tasm_uptime - seen[6]); // Energiemonitor row shows the packet age
i = 0;
while (i < N) {
int bad = isBad(i, day, active);
badf[i] = bad; // publish live state for WebCall()
if (bad) {
viol[i] = viol[i] + 1;
if (viol[i] >= DEBOUNCE_S) {
// speak on first confirm + every REALARM_S — UNLESS acknowledged (muted).
if (acked[i] == 0 && (alm[i] == 0 || (tasm_uptime - spk[i]) >= REALARM_S)) {
strToken(g_nm, names, '|', i + 1); problemPhrase(i);
sprintf(g_msg, "Achtung! %s %s", g_nm, g_s);
speak(g_msg);
spk[i] = tasm_uptime;
}
alm[i] = 1; // confirmed alarm (shown) whether or not we spoke
}
} else {
alm[i] = 0; viol[i] = 0;
}
// Auto re-arm: a muted PEER inverter that starts delivering again clears its
// own mute, so a FUTURE failure alarms with sound (no stale mute lingering).
if (acked[i] == 1 && typ[i] == PEER) {
if (val[i] > producing_w) {
acked[i] = 0; saveVars();
strToken(g_nm, names, '|', i + 1);
sprintf(g_msg, "HM: %s liefert wieder - Ton reaktiviert", g_nm); addLog(g_msg);
}
}
// row value + colour. SOC rows show "%.0f%%", the liveness row its age in
// seconds, the rest a bare number. RED when alarming; AMBER when muted
// (acknowledged) so a known fault reads as "seen, silenced"; green = ok.
if (typ[i] == SOCLIVE) { sprintf(g_s, "%.0f%%", val[i]); }
else if (typ[i] == ENALIVE) { sprintf(g_s, "%.0fs", val[i]); }
else if (typ[i] == TMPLIVE) { sprintf(g_s, "%.0f C", val[i]); }
else { sprintf(g_s, "%.0f", val[i]); }
lvglSetText(rowV[i], g_s);
if ((bad || alm[i] == 1) && acked[i] == 1) { lvglSetTextColor(rowV[i], C_AMBER); }
else if (bad || alm[i] == 1) { lvglSetTextColor(rowV[i], C_RED); }
else { lvglSetTextColor(rowV[i], C_GREEN); }
if (alm[i] == 1 && acked[i] == 0) { lvglSetBgColor(rowC[i], C_ALARMBG); }
else { lvglSetBgColor(rowC[i], C_CARD); }
// on-screen (touch) mute switch: visible only while the row alarms. Label +
// colour show the ACTION (like the web pill): red "Ruhe" to silence, green
// "Ton" to re-enable sound. Only touch LVGL when the state changes (btnSt).
int want = -1; // hidden
if (badf[i] == 1 || alm[i] == 1) { want = acked[i]; } // 0=loud, 1=muted
if (want != btnSt[i]) {
btnSt[i] = want;
if (want < 0) { lvglSetPos(muteBtn[i], 520, 40 + i * ROWH); } // slide off-screen
else {
lvglSetPos(muteBtn[i], 414, 40 + i * ROWH);
if (want == 1) { lvglSetText(muteLbl[i], "Ton"); lvglSetBgColor(muteBtn[i], C_GREEN); }
else { lvglSetText(muteLbl[i], "Ruhe"); lvglSetBgColor(muteBtn[i], C_RED); }
}
}
i = i + 1;
}
// DEBUG (syslog, ~4x/min): PEER count + the three liveness ages/flags.
if (tasm_second % 15 == 0) {
sprintf(g_msg, "HMdbg nprod=%d pwl=%.0f(age%d st%d) msoc=%.0f(age%d st%d) en_age=%d(st%d)",
grp_nprod, pwl, tasm_uptime - seen[4], liv[4],
msoc, tasm_uptime - seen[5], liv[5],
tasm_uptime - seen[6], liv[6]);
addLog(g_msg);
}
}
// LCD touch: poll the LVGL event queue for taps on a row's mute switch and toggle
// that row's mute (persisted). Runs on the 100 ms tick for snappy touch response.
void Every100ms() {
while (lvglEvent()) {
if (lvglEventCode() == EV_CLICKED) {
int o = lvglEventObj();
int j = 0;
while (j < N) {
if (o == muteBtn[j]) {
acked[j] = 1 - acked[j]; saveVars();
btnSt[j] = -2; // force switch label/colour refresh
strToken(g_nm, names, '|', j + 1);
if (acked[j] == 1) { sprintf(g_msg, "HM: %s stumm (touch)", g_nm); }
else { sprintf(g_msg, "HM: %s laut (touch)", g_nm); }
addLog(g_msg);
}
j = j + 1;
}
}
}
}
// ── Web dashboard — all monitored variables, colour-coded by alarm state ──
// Renders inline on the main Tasmota page (mirrors the LVGL cards): green = ok,
// amber = detected/debouncing, red = alarm; INFO rows are neutral (display-only).
void WebCall() {
int nalm = 0; int nmute = 0; int i = 0;
while (i < N) {
if (badf[i] || alm[i] == 1) { nalm = nalm + 1; if (acked[i] == 1) { nmute = nmute + 1; } }
i = i + 1;
}
sprintf(g_cmd, "{s}<b>Haus-Monitor</b>{m}%02d:%02d:%02d %02d.%02d.{e}",
tasm_hour, tasm_minute, tasm_second, tasm_day, tasm_month);
webSend(g_cmd);
if (nalm > 0) {
sprintf(g_cmd, "{s}Status{m}<span style='color:#ea4335;font-weight:bold;'>%d ALARM</span> <span style='color:#9aa0a6;'>(%d stumm)</span>{e}", nalm, nmute);
} else {
strcpy(g_cmd, "{s}Status{m}<span style='color:#34a853;font-weight:bold;'>alles OK</span>{e}");
}
webSend(g_cmd);
sprintf(g_cmd, "{s}Solar aktiv{m}%d von 4{e}", grp_nprod);
webSend(g_cmd);
i = 0;
while (i < N) {
strToken(g_nm, names, '|', i + 1);
if (typ[i] == SOCLIVE) { sprintf(g_s, "%.0f %%", val[i]); }
else if (typ[i] == ENALIVE) { sprintf(g_s, "vor %.0f s", val[i]); } // liveness: age of last packet
else if (typ[i] == TMPLIVE) { sprintf(g_s, "%.1f °C", val[i]); } // temperature row (.150 Solarspeicher)
else { sprintf(g_s, "%.0f W", val[i]); }
int alarming = 0; if (badf[i] || alm[i] == 1) { alarming = 1; }
if (typ[i] == INFO) {
sprintf(g_cmd, "{s}%s{m}<span style='color:#9aa0a6;'>%s</span>{e}", g_nm, g_s);
} else if (alarming == 1 && acked[i] == 1) {
// muted alarm: amber value + bell-off marker + a compact un-mute ("Ton")
// pill. A <span> (not a <button>) avoids Tasmota's full-size button style,
// which overflowed the card.
sprintf(g_btn, "<span onclick=\"fetch('/cm?cmnd=HM%%20ack%%20%d')\" style='cursor:pointer;margin-left:10px;padding:1px 8px;font-size:12px;border-radius:9px;background:#2f7d3a;color:#fff'>🔔 Ton</span>", i);
sprintf(g_cmd, "{s}%s{m}<span style='color:#fbbc04;font-weight:bold;'>%s 🔕</span>%s{e}", g_nm, g_s, g_btn);
} else if (alarming == 1) {
// active alarm: red value + a compact mute ("Ruhe") pill — calms the audio.
sprintf(g_btn, "<span onclick=\"fetch('/cm?cmnd=HM%%20ack%%20%d')\" style='cursor:pointer;margin-left:10px;padding:1px 8px;font-size:12px;border-radius:9px;background:#b23b32;color:#fff'>🔕 Ruhe</span>", i);
sprintf(g_cmd, "{s}%s{m}<span style='color:#ea4335;font-weight:bold;'>%s !</span>%s{e}", g_nm, g_s, g_btn);
} else if (viol[i] > 0) {
sprintf(g_cmd, "{s}%s{m}<span style='color:#fbbc04;'>%s ?</span>{e}", g_nm, g_s);
} else {
sprintf(g_cmd, "{s}%s{m}<span style='color:#34a853;'>%s</span>{e}", g_nm, g_s);
}
webSend(g_cmd);
i = i + 1;
}
}
// Command "HM" — subcommands parsed from the payload (after "HM "):
// HM ack <row> toggle the mute (calm the spoken alarm) for a row — PERSISTED
// HM test speak a test phrase
// HM stat log every row's state
void Command(char cmd[]) {
int i = 0; while (cmd[i] == ' ') { i = i + 1; }
if (cmd[i] == 'a') { // ack <row>
char nb[8]; strSub(nb, cmd, i + 4, 3); int r = atoi(nb);
if (r >= 0 && r < N) {
acked[r] = 1 - acked[r]; saveVars(); // persist so it survives reboots
strToken(g_nm, names, '|', r + 1);
if (acked[r] == 1) { sprintf(g_msg, "HM: %s stumm (Ruhe)", g_nm); }
else { sprintf(g_msg, "HM: %s wieder laut", g_nm); }
addLog(g_msg); responseCmnd(g_msg); return;
}
responseCmnd("HM: ack <0..N-1>"); return;
}
if (cmd[i] == 't') { speak("Haus Monitor Test"); responseCmnd("HM: test spoken"); return; }
if (cmd[i] == 's') { // stat
int k = 0;
while (k < N) {
strToken(g_nm, names, '|', k + 1);
sprintf(g_msg, "%s=%.0f typ=%d viol=%d alm=%d ack=%d", g_nm, val[k], typ[k], viol[k], alm[k], acked[k]);
addLog(g_msg); k = k + 1;
}
responseCmnd("HM: state logged"); return;
}
responseCmnd("HM: ack <n> | test | stat");
}