Zum Inhalt

house_monitor.tc

house_monitor.tc — compact, table-driven house monitor with an LVGL dashboard

Source on GitHub

// 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 &deg;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'>&#128276; Ton</span>", i);
            sprintf(g_cmd, "{s}%s{m}<span style='color:#fbbc04;font-weight:bold;'>%s &#128277;</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'>&#128277; 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");
}