Zum Inhalt

heatpump_map.tc

heatpump_map.tc — Modbus-RTU sniffer + control for chinese heat pumps

Source on GitHub

// =================================================================
// heatpump_map.tc — Modbus-RTU sniffer + control for chinese heat pumps
//
// Hardware: ESP32 with auto-direction RS485 module (e.g. MAX13487, SP3485).
// RX on GPIO 4, TX on GPIO 5 (no DE/RE pin needed). Tap in parallel with
// any existing cloud bridge (EW11 etc.) — we read passively and write
// with listen-before-talk silence detection to avoid bus collisions.
//
// Architecture:
//   - All reads via passive RS485 tap (Every50ms tick drains UART, parser
//     scans buffer for FC01/02/03/05/06/15/16 patterns including merged
//     REQ+RSP and embedded writes).
//   - All writes via direct UART (auto-direction module enables driver
//     while bytes flow, returns to RX afterward).
//   - No dependency on the cloud's gateway — sniffer is fully self-contained.
//
// Confirmed registers (Fantastic 11 kW heat pump, slave 1, 19200 8N2):
//   r1   Zieltemp / target water temp        ×10 °C
//   r188 Puffer / buffer tank temp           ×10 °C
//   r190 Aussentemp / outside air            ×10 °C  (drives heat curve)
//   r191 Ausgangstemp / supply water         ×10 °C
//   r192 Ansauggas / suction gas             ×10 °C
//   r193 Saugdruck / suction pressure        ×10 bar
//   r194 Auslassdruck / discharge pressure   ×10 bar
//   r206 Verdampfer / evaporator coil        ×10 °C
//   r217 Power state (read-only):  0=boot, 1=running, 6=remote-off
//   coil 40 (FC05): write 0xFF00 = ON, 0x0000 = OFF (the actual on/off
//                   control — r217 just reflects the result)
//
// Console (MBUS prefix):
//   MBUS ON        write FC05 coil 40 = 0xFF00
//   MBUS OFF       write FC05 coil 40 = 0x0000
//   MBUS SET <T>   write FC06 r1 = T*10  (target water temp in °C)
//   MBUS STAT      summary of frames/parses/known-registers
//   MBUS WRITES    dump captured FC05/06/15/16 events (own + observed)
//   MBUS DUMP      dump all known registers
//   MBUS SNAP      snapshot baseline for diff highlighting in /tc_ui
//   MBUS CLEAR     reset captured registers + counters
//   MBUS LOG ON|OFF  toggle raw frame logging
//
// Web UI:  http://<ip>/tc_ui  — register tables with diff-vs-snapshot
//          highlighting (used during the original mapping work)
// Main page rows + MQTT JSON publish the named values:
//   HP State, Zieltemp, Puffer, Ausgang, Ansauggas, Verdampfer, Aussen,
//   Saugdruck, Auslassdruck
//
// To map further registers (e.g. fault code, mode enum, hot-water target):
//   1. open /tc_ui, click "Snapshot baseline"
//   2. change ONE setting on the heat-pump panel (or trigger an event)
//   3. wait ~10 s (one cloud poll cycle), refresh
//   4. registers highlighted yellow are the ones that changed
// =================================================================

// ── UART / framing ─────────────────────────────────────────
int  rx_pin     = 4;
int  tx_pin     = 5;
int  baud       = 19200;
int  cfg        = 7;       // 8N2
int  silence_ms = 3;       // inter-frame gap

int  ser     = -1;
int  log_on  = 0;          // raw frame logging — OFF by default (every cycle
                           // would otherwise spam ~10 lines/sec). Use
                           // MBUSCAPTURE for a brief window, or MBUSLOG ON
                           // for a permanent toggle.
int  log_until_ms = 0;     // millis() target at which a temporary capture
                           // turns log_on back to 0; 0 = no auto-off

// Frame buffer
char buf[260];
int  buf_len = 0;
int  last_ms = 0;

// ── Shared scratch buffers for hot callbacks ──
// Hoisted to globals so we don't burn a heap handle per call. The auto-heap
// threshold is 16 elements; anything bigger uses a handle, and we have a
// hard cap of 128. WebCall fires every ~2 s, JsonCall every TelePeriod,
// flush_frame multiple times per second — those allocations add up fast.
char g_row[320];        // WebCall + render_named + render_block row builder
char g_buf[260];        // JsonCall row builder
char g_resp[140];       // Command response builder
char g_hex[140];        // bin2hex output (covers up to 64 input bytes ×2 + NUL)
char g_tmp[80];         // generic short-lived helper
char g_dcell[64];       // diff cell for render_block (was per-iteration leak)
char g_hdr[120];        // block header for render_block

// ── Register store ─────────────────────────────────────────
// 500 covers 0..338 + 432..493 with headroom.
int  reg[500];
int  known[500];
int  snap[500];
int  snapped[500];     // 1 if this slot has a snapshot baseline

// Counters
int  frames_seen = 0;
int  bytes_seen  = 0;
int  rsp_parsed  = 0;
int  rsp_failed  = 0;
int  registers_known = 0;
int  last_block_ms = 0;

// FC06 / FC16 write capture — when cloud (or panel) writes to the heat pump,
// the register address tells us what control we'd target ourselves.
int  writes_seen = 0;
int  last_write_addr = -1;
int  last_write_val  = 0;
int  last_write_ms   = 0;

// Circular buffer of last 16 write events (own + observed). Small footprint
// (16 × 3 ints = ~192 B), enough to catch a complete cloud on/off interaction.
int  wlog_addr[16];
int  wlog_val[16];
int  wlog_ms[16];
char wlog_src[16];     // 'O' = observed (cloud/external), 'M' = me (this script's send)
int  wlog_pos = 0;     // next slot to write
int  wlog_count = 0;

// ── UDP-shared globals from other Tasmota devices ────────────
// `global float` = auto-syncs via UDP multicast 239.255.255.250:1999.
// Initial values are sane defaults until first packet arrives. Names must
// match exactly what the publishing devices send.
global float aztemp = 20.0;   // Schlafzimmer (sleeping room)
global float wtemp  = 20.0;   // Wohnzimmer (living room)
global float ktmp   = 14.0;   // Keller (cellar — typically colder)
global float rtemp  = 10.0;   // Außentemperatur (Bresser sensor)

// ── Watchdog: cycle the heat pump if it's stuck in winter ────
// 2-3 times a year the pump locks up and won't restart on its own. If
// that happens while you're traveling the house cools down. The
// watchdog correlates 4 independent signals to detect "should be heating
// but isn't", then automatically does an off/on cycle to recover.
//
// All 6 thresholds + enable + grace-min are persist'd so they survive
// reboots (and your tuning experiments). Adjust via console:
//   MBUSWD ON|OFF             enable / disable the whole watchdog
//   MBUSWD STAT               show state + next-action time
//   MBUSWD TEST               simulate a trigger (skip grace period)
persist int   wd_enable           = 1;
persist float wd_room_min         = 17.0;   // °C — heated rooms must drop below
persist float wd_cellar_min       = 14.0;   // °C — cellar (different baseline)
persist float wd_outside_max      =  5.0;   // °C — only watch when actually cold
persist float wd_ausgang_min      = 25.0;   // °C — pump output must be at least this when running
persist int   wd_grace_min        = 15;     // minutes of sustained alert before action
persist int   wd_cooldown_min     = 60;     // minutes of silence after a recovery cycle
persist char  wd_email_to[64];              // recipient — empty means email disabled

// Watchdog state machine (in-RAM, resets on reboot — that's fine)
int wd_state              = 0;   // 0=monitor 1=alerted 2=just-sent-OFF 3=cooldown
int wd_alert_started_ms   = 0;
int wd_off_sent_ms        = 0;
int wd_cooldown_until_ms  = 0;
int wd_trigger_count      = 0;   // total times watchdog fired this boot
int wd_last_trigger_ms    = 0;   // timestamp of previous trigger (escalation gate)
int wd_test_active        = 0;   // 1 = next cooldown is shortened (test mode)

void wlog_push(int addr, int val, char src) {
    int p = wlog_pos;
    wlog_addr[p] = addr;
    wlog_val[p]  = val;
    wlog_ms[p]   = millis();
    wlog_src[p]  = src;
    wlog_pos = (p + 1) % 16;
    if (wlog_count < 16) wlog_count = wlog_count + 1;
}

// Track the most recent REQ so we can pair it with a lone RSP that arrives
// in the next captured frame. Most cloud polls land as REQ+RSP merged in
// one buffer, but occasionally the silence detector splits them.
int  last_req_addr = -1;
int  last_req_qty  = 0;

// ------------------------------------------------------------
// Store a register reading; track first-seen and changes.
// ------------------------------------------------------------
void store_reg(int addr, int val) {
    if (addr < 0 || addr >= 500) return;
    if (known[addr] == 0) {
        known[addr] = 1;
        registers_known = registers_known + 1;
    }
    reg[addr] = val;
}

// ------------------------------------------------------------
// Parse one frame into register store. Handles three shapes:
//   A) Lone 8-byte REQ — record addr/qty for the next RSP
//   B) Lone RSP (slave fc bc data crc) — paired with last_req
//   C) Merged REQ+RSP (8 + 5+2N bytes)
// All other frames are ignored (probably noise / collisions /
// non-FC03 frames we don't care about for mapping).
// ------------------------------------------------------------
void parse_frame() {
    if (buf_len < 5) return;
    int sl = buf[0] & 0xFF;
    int fc = buf[1] & 0xFF;
    // ── Scan the WHOLE buffer for any embedded WRITE function code ──
    // FC05 = write single coil    (8 bytes: 01 05 addr_hi addr_lo val_hi val_lo crc crc;
    //                               val=FF00 means ON, 0000 means OFF)
    // FC06 = write single register (8 bytes: 01 06 addr_hi addr_lo val_hi val_lo crc crc)
    // FC15 = write multiple coils  (>=9 bytes: 01 0F addr ... bc data crc)
    // FC16 = write multiple regs   (>=9 bytes: 01 10 addr ... bc data crc)
    //
    // Walking the buffer catches writes embedded within merged frames
    // (cloud often sends an FC06 write within <4 ms of a prior FC03
    // poll tail).
    for (int i = 0; i + 8 <= buf_len; i++) {
        int s2 = buf[i] & 0xFF;
        int f2 = buf[i+1] & 0xFF;
        if (s2 != 1) continue;                  // only slave 1 commands matter

        if (f2 == 0x05) {
            // FC05: write single coil
            int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
            int wval  = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
            // Sanity: coil addr typically < 1000, val must be 0xFF00 or 0x0000
            if (waddr < 1000 && (wval == 0xFF00 || wval == 0x0000)) {
                last_write_addr = waddr;
                last_write_val  = wval;
                last_write_ms   = millis();
                writes_seen     = writes_seen + 1;
                wlog_push(waddr, wval, 'O');
                char w[160];
                char st[8];
                if (wval == 0xFF00) strcpy(st, "ON"); else strcpy(st, "OFF");
                sprintf(w, "*** WRITE FC05 (coil) *** addr=%d (0x%04x) → %s",
                        waddr, waddr, st);
                addLog(w);
            }
        } else if (f2 == 0x06) {
            int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
            int wval  = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
            if (waddr < 1000) {
                last_write_addr = waddr;
                last_write_val  = wval;
                last_write_ms   = millis();
                writes_seen     = writes_seen + 1;
                wlog_push(waddr, wval, 'O');
                char w[160];
                sprintf(w, "*** WRITE FC06 (reg) *** addr=%d (0x%04x) val=%d (0x%04x)",
                        waddr, waddr, wval, wval);
                addLog(w);
            }
        } else if (f2 == 0x0F && i + 9 <= buf_len) {
            // FC15: write multiple coils
            int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
            int wqty  = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
            if (waddr < 1000 && wqty > 0 && wqty < 256) {
                last_write_addr = waddr;
                last_write_ms   = millis();
                writes_seen     = writes_seen + 1;
                wlog_push(waddr, wqty, 'O');
                char w[160];
                sprintf(w, "*** WRITE FC15 (coils) *** addr=%d qty=%d",
                        waddr, wqty);
                addLog(w);
            }
        } else if (f2 == 0x10 && i + 9 <= buf_len) {
            int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
            int wqty  = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
            if (waddr < 1000 && wqty > 0 && wqty < 32) {
                last_write_addr = waddr;
                last_write_ms   = millis();
                writes_seen     = writes_seen + 1;
                wlog_push(waddr, wqty, 'O');
                char w[160];
                sprintf(w, "*** WRITE FC16 (regs) *** addr=%d qty=%d",
                        waddr, wqty);
                addLog(w);
            }
        }
    }

    if (sl != 1 || fc != 0x03) return;   // only slave 1 FC03 carries our data

    // Case A: lone REQ
    if (buf_len == 8) {
        last_req_addr = ((buf[2] & 0xFF) << 8) | (buf[3] & 0xFF);
        last_req_qty  = ((buf[4] & 0xFF) << 8) | (buf[5] & 0xFF);
        return;
    }

    // Case C: merged REQ + RSP — start with REQ
    if (buf_len >= 15) {
        int req_addr = ((buf[2] & 0xFF) << 8) | (buf[3] & 0xFF);
        int req_qty  = ((buf[4] & 0xFF) << 8) | (buf[5] & 0xFF);
        int rsp_off  = 8;
        if ((buf[rsp_off] & 0xFF) == 1 && (buf[rsp_off+1] & 0xFF) == 0x03) {
            int bc = buf[rsp_off+2] & 0xFF;
            if (bc == req_qty * 2 && buf_len >= rsp_off + 3 + bc) {
                // Valid merged frame; extract qty registers
                for (int r = 0; r < req_qty; r++) {
                    int hi = buf[rsp_off + 3 + r*2] & 0xFF;
                    int lo = buf[rsp_off + 4 + r*2] & 0xFF;
                    store_reg(req_addr + r, (hi << 8) | lo);
                }
                rsp_parsed = rsp_parsed + 1;
                last_block_ms = millis();
                return;
            }
        }
    }

    // Case B: lone RSP — pair with most recent REQ
    if (last_req_addr >= 0 && buf_len >= 5) {
        int bc = buf[2] & 0xFF;
        if (bc == last_req_qty * 2 && buf_len >= 3 + bc) {
            for (int r = 0; r < last_req_qty; r++) {
                int hi = buf[3 + r*2] & 0xFF;
                int lo = buf[4 + r*2] & 0xFF;
                store_reg(last_req_addr + r, (hi << 8) | lo);
            }
            rsp_parsed = rsp_parsed + 1;
            last_block_ms = millis();
            last_req_addr = -1;   // consume
            return;
        }
    }

    rsp_failed = rsp_failed + 1;
}

// ------------------------------------------------------------
// Flush completed frame: log raw (if enabled) + parse into store
// ------------------------------------------------------------
void flush_frame() {
    if (buf_len == 0) return;
    bytes_seen  = bytes_seen + buf_len;
    frames_seen = frames_seen + 1;
    parse_frame();
    if (log_on) {
        int n = buf_len;
        if (n > 64) n = 64;
        bin2hex(buf, n, g_hex);
        sprintf(g_row, "MB f#%d %dB hex=%s", frames_seen, buf_len, g_hex);
        addLog(g_row);
    }
}

// ------------------------------------------------------------
// Tick-based UART drain (50 ms cadence).
//
// Why a tick rather than TaskLoop / spawnTask:
//   The cloud's polling cycle is ~8 s between bursts; each burst itself
//   is a few-millisecond burst followed by silence. So if we get ZERO
//   new bytes in a 50 ms tick AND have data buffered, we're in
//   inter-frame silence — flush the frame.
//
//   This also means we never hold the VM mutex for long, so WebCall /
//   JsonCall / Command callbacks always get a window — no flicker on
//   the main page.
//
//   At 19200 8N2, max ~95 bytes can arrive in 50 ms; TasmotaSerial's
//   1 KB UART RX buffer handles that easily.
// ------------------------------------------------------------
void Every50ms() {
    if (ser < 0) return;
    // Auto-disable temporary capture window
    if (log_until_ms > 0 && millis() >= log_until_ms) {
        log_on = 0;
        log_until_ms = 0;
        addLog("HP: capture window ended, frame logging OFF");
    }
    int avail = serialAvailable(ser);
    int got = 0;
    int b;
    while (avail > 0) {
        b = serialRead(ser);
        if (b < 0) break;
        if (buf_len < 256) {
            buf[buf_len] = b;
            buf_len = buf_len + 1;
        }
        avail = avail - 1;
        got = got + 1;
        if (buf_len >= 256) break;
    }
    if (got == 0 && buf_len > 0) {
        // No new bytes this tick + we have data → end of frame
        flush_frame();
        buf_len = 0;
    }
}

// ------------------------------------------------------------
// Snapshot current registers as baseline (for diff highlighting)
// ------------------------------------------------------------
void take_snapshot() {
    int n = 0;
    for (int i = 0; i < 500; i++) {
        if (known[i]) {
            snap[i] = reg[i];
            snapped[i] = 1;
            n = n + 1;
        }
    }
    char m[80];
    sprintf(m, "snapshot taken: %d registers", n);
    addLog(m);
}

// ------------------------------------------------------------
// Clear snapshot diffs (so highlighted rows return to normal)
// ------------------------------------------------------------
void clear_snapshot() {
    for (int i = 0; i < 500; i++) snapped[i] = 0;
    addLog("snapshot cleared");
}

// ------------------------------------------------------------
// WebUI form button state
// ------------------------------------------------------------
int snap_btn  = 0;
int clear_btn = 0;
int prev_snap  = 0;
int prev_clear = 0;
int hp_toggle_btn  = 0;     // single toggle button — label flips with state
int prev_hp_toggle = 0;

// ------------------------------------------------------------
// Modbus CRC16 (poly 0xA001) for outgoing FC06 frames.
// ------------------------------------------------------------
int modbus_crc16(char b[], int len) {
    int crc = 0xFFFF;
    for (int i = 0; i < len; i++) {
        crc = crc ^ (b[i] & 0xFF);
        for (int j = 0; j < 8; j++) {
            if (crc & 1) crc = (crc >> 1) ^ 0xA001;
            else         crc = (crc >> 1);
        }
    }
    return crc & 0xFFFF;
}

// ------------------------------------------------------------
// Send FC06 "write single holding register" via the EW11 Socket B.
// Returns 1 on send-OK (no response confirmation; the sniffer's parser
// will see the actual write on the bus + any reply, providing a free
// out-of-band check).
// ------------------------------------------------------------
// Architecture: sniffer ESP is fully self-contained. EW11 stays on the bus
// for the cloud's polling traffic (we don't touch its config), but we never
// talk to it ourselves — neither for reads (passive RS485 tap covers that)
// nor for writes (direct UART via auto-direction RS485 covers that). This
// keeps a single dependency: just the wire tap. The earlier dual-path
// attempt (UART vs EW11 Socket B) was abandoned because EW11 doesn't relay
// pump responses back to its TCP-Server sockets — verification would have
// required the sniffer anyway.

// fc6=0x06 to write a holding register, fc5=0x05 to write a single coil.
// Same 8-byte wire format, just different fc byte.
int hp_send_modbus(int fc, int regaddr, int val) {
    // Build the 8-byte FC05 / FC06 frame
    char frame[8];
    frame[0] = 1;                          // slave id
    frame[1] = fc & 0xFF;                  // function code
    frame[2] = (regaddr >> 8) & 0xFF;
    frame[3] = regaddr & 0xFF;
    frame[4] = (val >> 8) & 0xFF;
    frame[5] = val & 0xFF;
    int crc = modbus_crc16(frame, 6);
    frame[6] = crc & 0xFF;                 // CRC low byte first (Modbus LE)
    frame[7] = (crc >> 8) & 0xFF;

    // ── Direct UART send via the auto-direction RS485 module ──
    if (ser < 0) {
        addLog("hp_send_modbus: serial not open");
        return 0;
    }
    // Listen-before-talk: wait for ≥4 ms bus silence before TX.
    // Bus has a 1 Hz cadence (cloud polls one block per second; each
    // burst is ~50-100 ms followed by ~900 ms of silence). 4 ms is the
    // Modbus standard 3.5-char inter-frame gap at 19200 baud + margin.
    // Cap the wait at 1500 ms to be sure we catch a quiet window even
    // mid-burst.
    int q_start = millis();
    int wait_cap = millis() + 1500;
    while (millis() < wait_cap) {
        if (serialAvailable(ser) > 0) {
            // Drain — DON'T parse here, let Every50ms framer do it.
            // We just need to know the bus had activity to reset the
            // silence timer.
            while (serialAvailable(ser) > 0) {
                int discard = serialRead(ser);
                if (buf_len < 256) { buf[buf_len] = discard; buf_len = buf_len + 1; }
            }
            q_start = millis();
        }
        if (millis() - q_start >= 4) break;
        delay(1);
    }
    // Send the 8 bytes. The auto-direction RS485 module enables its
    // driver while bytes flow on TX and switches back to RX afterward.
    for (int i = 0; i < 8; i++) {
        serialWriteByte(ser, frame[i] & 0xFF);
    }
    // Wait for TX FIFO to drain + Modbus inter-frame gap so the pump
    // recognises the frame end (8 B × ~0.6 ms = ~5 ms + 5 ms margin).
    delay(10);
    wlog_push(regaddr, val, 'M');
    char m[120];
    sprintf(m, "hp_send_modbus: fc=%02x reg=%d val=%d (0x%04x)",
            fc, regaddr, val, val);
    addLog(m);
    return 1;
}

// ------------------------------------------------------------
// Watchdog email alert — uses Tasmota's native mailSend (USE_SENDMAIL).
// Recipient comes from the persist'd `wd_email_to` setting; SMTP server,
// port, user, password, from-address are taken from Tasmota's device-wide
// SmtpHost/SmtpUser/SmtpPwd/SmtpFrom config (the "*" placeholders).
// Set the recipient via console:  MBUSWD MAIL <user@example.com>
// Empty recipient → no email sent (silent fallback).
// ------------------------------------------------------------
void wd_send_alert(char subj_part[]) {
    if (strlen(wd_email_to) == 0) return;   // not configured

    // Snapshot current readings so the email captures the moment
    int u; int s;
    float ausgang_c = 0.0;
    float puffer_c  = 0.0;
    if (known[191]) { u = reg[191] & 0xFFFF; s = u; if (s >= 32768) s -= 65536; ausgang_c = s / 10.0; }
    if (known[188]) { u = reg[188] & 0xFFFF; s = u; if (s >= 32768) s -= 65536; puffer_c  = s / 10.0; }

    // Reuse g_row (320) for body, g_resp (140) for params — both larger
    // than what we need, no extra heap handles burned.
    sprintf(g_row, "HP watchdog: %s<br>Trip count: %d<br><br>HP-Ausgang: %.1f C<br>HP-Puffer: %.1f C<br>Wohnzimmer: %.1f C<br>Schlafzimmer: %.1f C<br>Keller: %.1f C<br>Aussen (Bresser): %.1f C<br>",
            subj_part, wd_trigger_count,
            ausgang_c, puffer_c, wtemp, aztemp, ktmp, rtemp);
    mailBody(g_row);

    sprintf(g_resp, "[*:*:*:*:*:%s:HP Watchdog %s]", wd_email_to, subj_part);
    mailSend(g_resp);
    addLog("HP WATCHDOG: email alert dispatched");
}

// ------------------------------------------------------------
// Tasmota main-page sensor rows. Outputs "{s}label{m}value{e}" lines
// which the main web view renders as a clean sensor table.
// ------------------------------------------------------------
void WebCall() {
    // Use global g_row instead of `char row[200]` (heap handle leak fix).
    // Tiny scratch strings (<=16) stay local since they don't use a handle.
    char st[16];
    if (known[217]) {
        int v = reg[217] & 0xFFFF;
        if      (v == 0) strcpy(st, "booting");
        else if (v == 1) strcpy(st, "RUNNING");
        else if (v == 6) strcpy(st, "OFF");
        else             sprintf(st, "?(%d)", v);
        sprintf(g_row, "{s}HP Power{m}<b>%s</b>{e}", st);
        webSend(g_row);
    }

    // Target temp setpoint (×10 °C, signed)
    if (known[1]) {
        int u = reg[1] & 0xFFFF;
        int s = u; if (s >= 32768) s = s - 65536;
        sprintf(g_row, "{s}HP Zieltemp{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }

    // Live temperatures (×10 °C, all sign-extended)
    if (known[188]) {
        int u = reg[188] & 0xFFFF; int s = u; if (s >= 32768) s -= 65536;
        sprintf(g_row, "{s}HP Puffer{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }
    if (known[191]) {
        int u = reg[191] & 0xFFFF; int s = u; if (s >= 32768) s -= 65536;
        sprintf(g_row, "{s}HP Ausgang{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }
    if (known[192]) {
        int u = reg[192] & 0xFFFF; int s = u; if (s >= 32768) s -= 65536;
        sprintf(g_row, "{s}HP Ansauggas{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }
    if (known[206]) {
        int u = reg[206] & 0xFFFF; int s = u; if (s >= 32768) s -= 65536;
        sprintf(g_row, "{s}HP Verdampfer{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }
    // Aussentemp — drives the heat curve / target setpoint shifts
    if (known[190]) {
        int u = reg[190] & 0xFFFF; int s = u; if (s >= 32768) s -= 65536;
        sprintf(g_row, "{s}HP Aussentemp{m}%.1f &deg;C{e}", s / 10.0);
        webSend(g_row);
    }

    // Pressures (×10 bar, unsigned)
    if (known[193]) {
        sprintf(g_row, "{s}HP Saugdruck{m}%.1f bar{e}", (reg[193] & 0xFFFF) / 10.0);
        webSend(g_row);
    }
    if (known[194]) {
        sprintf(g_row, "{s}HP Auslassdruck{m}%.1f bar{e}", (reg[194] & 0xFFFF) / 10.0);
        webSend(g_row);
    }

    // ── UDP-shared room temperatures (from other Tasmota devices) ──
    sprintf(g_row, "{s}Wohnzimmer{m}%.1f &deg;C{e}", wtemp);
    webSend(g_row);
    sprintf(g_row, "{s}Schlafzimmer{m}%.1f &deg;C{e}", aztemp);
    webSend(g_row);
    sprintf(g_row, "{s}Keller{m}%.1f &deg;C{e}", ktmp);
    webSend(g_row);
    sprintf(g_row, "{s}Außen (Bresser){m}%.1f &deg;C{e}", rtemp);
    webSend(g_row);

    // ── Watchdog status row ──
    if (wd_enable) {
        char wst[40];
        if      (wd_state == 0) strcpy(wst, "monitor");
        else if (wd_state == 1) {
            int mins = (millis() - wd_alert_started_ms) / 60000;
            sprintf(wst, "ALERT %d min", mins);
        }
        else if (wd_state == 2) strcpy(wst, "cycling OFF");
        else if (wd_state == 3) {
            int mins_left = (wd_cooldown_until_ms - millis()) / 60000;
            sprintf(wst, "cooldown %d min", mins_left);
        }
        else strcpy(wst, "?");
        sprintf(g_row, "{s}HP Watchdog{m}%s (%d trips){e}", wst, wd_trigger_count);
        webSend(g_row);
    }
}

// ------------------------------------------------------------
// JSON telemetry — for MQTT teleperiod publishing. Same data,
// machine-readable shape.
// ------------------------------------------------------------
void JsonCall() {
    if (registers_known < 5) return;   // not warmed up yet
    // using global g_buf
    char sep[2];
    int n = 0;
    int u = 0;
    int s = 0;
    strcpy(sep, "");

    responseAppend(",\"HP\":{");
    if (known[217]) {
        sprintf(g_buf, "%s\"State\":%d", sep, reg[217] & 0xFFFF);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[1]) {
        u = reg[1] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Target\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[188]) {
        u = reg[188] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Puffer\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[191]) {
        u = reg[191] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Ausgang\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[192]) {
        u = reg[192] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Ansauggas\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[206]) {
        u = reg[206] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Verdampfer\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[190]) {
        u = reg[190] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
        sprintf(g_buf, "%s\"Aussen\":%.1f", sep, s / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[193]) {
        sprintf(g_buf, "%s\"SuctPress\":%.1f", sep, (reg[193] & 0xFFFF) / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    if (known[194]) {
        sprintf(g_buf, "%s\"DischPress\":%.1f", sep, (reg[194] & 0xFFFF) / 10.0);
        responseAppend(g_buf); n = 1; strcpy(sep, ",");
    }
    // UDP-shared room temps — useful in the same JSON for HA / dashboards
    sprintf(g_buf, "%s\"Wohnzimmer\":%.1f", sep, wtemp);     responseAppend(g_buf); strcpy(sep, ",");
    sprintf(g_buf, "%s\"Schlafzimmer\":%.1f", sep, aztemp);   responseAppend(g_buf);
    sprintf(g_buf, "%s\"Keller\":%.1f", sep, ktmp);          responseAppend(g_buf);
    sprintf(g_buf, "%s\"Aussen2\":%.1f", sep, rtemp);        responseAppend(g_buf);
    // Watchdog status — primary alert channel via teleperiod MQTT
    sprintf(g_buf, "%s\"WdState\":%d,\"WdTrips\":%d", sep, wd_state, wd_trigger_count);
    responseAppend(g_buf);
    responseAppend("}");
}

// ------------------------------------------------------------
// Render the headline "labeled values" panel — what the user actually
// cares about. Strong-confidence mappings only.
// ------------------------------------------------------------
void render_named() {
    // Use hoisted globals where possible (g_row, g_tmp). pwr/pcls are
    // ≤16 chars so they stay on the stack with no heap handle.
    char pwr[16];
    char pcls[16];

    // Power state — r217 is enum: 0=booting, 1=running, 6=off
    int pst = -1;
    if (known[217]) {
        pst = reg[217] & 0xFFFF;
        if      (pst == 0) strcpy(pwr, "booting");
        else if (pst == 1) strcpy(pwr, "RUNNING");
        else if (pst == 6) strcpy(pwr, "OFF");
        else               sprintf(pwr, "?(%d)", pst);
    } else {
        strcpy(pwr, "(unknown)");
    }
    if      (pst == 1) strcpy(pcls, "hpm-pwr-on");
    else if (pst == 6) strcpy(pcls, "hpm-pwr-off");
    else               strcpy(pcls, "hpm-pwr-other");

    webSend("<table class='hpm-named'>");

    // Power + setpoint — top row
    sprintf(g_row, "<tr><th>Power state</th><td class='%s'><b>%s</b></td><td class='reg'>r217 = %d</td></tr>",
            pcls, pwr, pst);
    webSend(g_row);

    if (known[1]) {
        float v = (reg[1] & 0xFFFF) / 10.0;
        sprintf(g_row, "<tr><th>Zieltemp (target)</th><td><b>%.1f &deg;C</b></td><td class='reg'>r1 = %d</td></tr>",
                v, reg[1] & 0xFFFF);
        webSend(g_row);
    }

    // Temperatures (×10 °C). Sign-extend the 16-bit raw value so negative
    // outside temps render correctly (e.g. r194=0xff6a → -15.0 °C).
    if (known[188]) {
        int u = reg[188] & 0xFFFF;
        int s = u; if (s >= 32768) s = s - 65536;
        float t = s / 10.0;
        sprintf(g_row, "<tr><th>Puffer (buffer)</th><td><b>%.1f &deg;C</b></td><td class='reg'>r188 = %d</td></tr>", t, u);
        webSend(g_row);
    }
    if (known[191]) {
        int u = reg[191] & 0xFFFF; int s = u; if (s >= 32768) s = s - 65536;
        float t = s / 10.0;
        sprintf(g_row, "<tr><th>Ausgangstemp</th><td><b>%.1f &deg;C</b></td><td class='reg'>r191 = %d</td></tr>", t, u);
        webSend(g_row);
    }
    if (known[192]) {
        int u = reg[192] & 0xFFFF; int s = u; if (s >= 32768) s = s - 65536;
        float t = s / 10.0;
        sprintf(g_row, "<tr><th>Ansauggas</th><td><b>%.1f &deg;C</b></td><td class='reg'>r192 = %d</td></tr>", t, u);
        webSend(g_row);
    }
    if (known[206]) {
        int u = reg[206] & 0xFFFF; int s = u; if (s >= 32768) s = s - 65536;
        float t = s / 10.0;
        sprintf(g_row, "<tr><th>Verdampfer</th><td><b>%.1f &deg;C</b></td><td class='reg'>r206 = %d</td></tr>", t, u);
        webSend(g_row);
    }

    // Pressures (×10 bar — always positive, no sign extend needed)
    if (known[193]) {
        float p = (reg[193] & 0xFFFF) / 10.0;
        sprintf(g_row, "<tr><th>Saugdruck (suction)</th><td><b>%.1f bar</b></td><td class='reg'>r193 = %d</td></tr>", p, reg[193] & 0xFFFF);
        webSend(g_row);
    }
    if (known[194]) {
        float p = (reg[194] & 0xFFFF) / 10.0;
        sprintf(g_row, "<tr><th>Auslassdruck (discharge)</th><td><b>%.1f bar</b></td><td class='reg'>r194 = %d</td></tr>", p, reg[194] & 0xFFFF);
        webSend(g_row);
    }

    webSend("</table>");
}

// ------------------------------------------------------------
// Render one block of registers as an HTML table chunk.
// Highlights rows where value differs from snapshot baseline.
// ------------------------------------------------------------
void render_block(char title[], int start, int end) {
    // Use hoisted globals (g_hdr, g_row, g_dcell) — declaring these as
    // function-locals would have allocated 2 heap handles per call (×6
    // calls per /tc_ui refresh = 12 handles), and the dcell INSIDE the
    // loop was 1 handle per known register (~200 × 6 = 1200 per render).
    // Outright over the 128-handle cap.
    sprintf(g_hdr, "<h4>%s &nbsp;<small>addr %d&ndash;%d</small></h4>",
            title, start, end);
    webSend(g_hdr);
    webSend("<table class='hpm-tbl'><tr><th>Addr</th><th>Hex</th><th>Dec</th><th>Signed</th><th>&Delta; vs snap</th></tr>");

    int sval;
    int delta;
    char rowcls[16];   // exactly 16 — no heap handle, stays on stack
    for (int a = start; a <= end; a++) {
        if (a >= 500) break;
        if (!known[a]) continue;
        int v = reg[a] & 0xFFFF;
        sval = v;
        if (sval >= 0x8000) sval = sval - 0x10000;
        // diff column
        strcpy(rowcls, "");
        if (snapped[a]) {
            delta = v - (snap[a] & 0xFFFF);
            if (delta != 0) {
                sprintf(g_dcell, "<b>%d &rarr; %d (%+d)</b>", snap[a] & 0xFFFF, v, delta);
                strcpy(rowcls, " class='hpm-chg'");
            } else {
                strcpy(g_dcell, "&middot;");
            }
        } else {
            strcpy(g_dcell, "&ndash;");
        }
        sprintf(g_row, "<tr%s><td>%d</td><td>0x%04x</td><td>%d</td><td>%d</td><td>%s</td></tr>",
                rowcls, a, v, v, sval, g_dcell);
        webSend(g_row);
    }
    webSend("</table>");
}

// ------------------------------------------------------------
// Main Web UI
// ------------------------------------------------------------
void WebUI() {
    webSend("<style>.hpm-panel{max-width:780px;margin:8px auto;background:#f0f0f0;color:#000;padding:14px;border:2px solid #ccc;border-radius:6px;text-align:left;font-size:12px}.hpm-panel h3{margin:0 0 6px;font-size:14px}.hpm-panel h4{margin:14px 0 4px;font-size:12px;color:#444}.hpm-tbl{width:100%;border-collapse:collapse;margin-bottom:6px;font-family:monospace}.hpm-tbl th{background:#dfdfdf;padding:2px 4px;border:1px solid #bbb;text-align:right;font-size:11px}.hpm-tbl td{padding:1px 4px;border:1px solid #ddd;text-align:right}.hpm-chg{background:#fff7c4;color:#603}.hpm-stat{padding:6px;background:#dde6f5;color:#103060;border-radius:3px;margin-bottom:8px}.hpm-named{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:14px}.hpm-named th{background:#e8f0d8;padding:6px 8px;border:1px solid #c8d8a8;text-align:left;font-weight:normal;width:35%}.hpm-named td{padding:6px 8px;border:1px solid #d8d8d8;text-align:left}.hpm-named td.reg{font-family:monospace;color:#666;font-size:11px;width:25%}.hpm-pwr-on{background:#dff0d8;color:#205020;font-weight:bold}.hpm-pwr-off{background:#f5d6d6;color:#702020;font-weight:bold}.hpm-pwr-other{background:#fff3d4;color:#664300}</style>");
    webSend("<div class='hpm-panel'>");
    webSend("<h3>&#x1F525; Heat-pump Modbus map</h3>");

    // Status line — reuse g_row (320) instead of allocating st[300]
    int age_ms = 0;
    if (last_block_ms > 0) age_ms = millis() - last_block_ms;
    sprintf(g_row, "<div class='hpm-stat'>frames=%d  parsed=%d  failed=%d  registers=%d  lastblock=%d ms ago</div>",
            frames_seen, rsp_parsed, rsp_failed, registers_known, age_ms);
    webSend(g_row);

    // Write-capture banner — large + colored when a write was seen recently
    if (writes_seen > 0) {
        sprintf(g_row, "<div class='hpm-stat' style='background:#ffe6c4;color:#603'><b>WRITES: %d total.</b> &nbsp; last: addr <b>%d (0x%04x)</b> val <b>%d</b> &nbsp; %d s ago</div>",
                writes_seen, last_write_addr, last_write_addr, last_write_val,
                (millis() - last_write_ms) / 1000);
        webSend(g_row);
    }

    // Headline labeled values (confirmed register mapping)
    webSend("<h4>&#x1F525; Live values</h4>");
    render_named();

    // Pump control — single toggle button, label reflects the action that a
    // click would take (the OPPOSITE of the current state). Since webButton
    // requires a literal label, we render conditionally — both branches use
    // the SAME hp_toggle_btn variable so the click handler is identical.
    webSend("<b>Pump control</b>");
    int v217 = 0;
    if (known[217]) v217 = reg[217] & 0xFFFF;
    if (v217 == 1) {
        webButton(hp_toggle_btn, "Pump → OFF");
    } else if (v217 == 6) {
        webButton(hp_toggle_btn, "Pump → ON");
    } else {
        // Booting (0) or unknown — default label "Pump → ON" (idempotent
        // if already on / starting).
        webButton(hp_toggle_btn, "Pump → ON");
    }
    webSend("<small>Click to toggle. Sends FC05 coil 40 = 0xFF00 (ON) or 0x0000 (OFF) via RS485 with listen-before-talk to avoid cloud-poll collisions.</small>");

    // Snapshot buttons (for register-mapping workflow)
    webSend("<hr><b>Register mapping</b>");
    webButton(snap_btn,  "Snapshot baseline");
    webButton(clear_btn, "Clear snapshot");
    webSend("<small>Take a snapshot, change one thing on the heat-pump panel, wait ~10 s, refresh — changed registers will be highlighted in the per-block tables (view by appending <code>?full=1</code> to this URL or using <code>MBUSDUMP</code> in the console).</small>");

    // Per-register block tables are heavy (each render_block iterates the
    // whole block; under steady AJAX-poll cadence the cumulative heap-handle
    // usage per render approaches the 128 cap). Default render skips them.
    // Append ?full=1 to /tc_ui when you actually want the full register dump
    // for the diff-snapshot mapping workflow.
    char fullarg[8];
    int has_full = webArg("full", fullarg);
    if (has_full > 0) {
        render_block("Block 1",   0,  65);
        render_block("Block 2",  66, 119);
        render_block("Block 3", 120, 179);
        render_block("Block 4", 180, 217);
        render_block("Block 5", 218, 338);
        render_block("Block 6", 432, 493);
    }

    webSend("</div>");
}

// ------------------------------------------------------------
// Edge-detect form buttons each second
// ------------------------------------------------------------
void EverySecond() {
    if (snap_btn == 1 && prev_snap == 0) {
        take_snapshot();
        snap_btn = 0;
    }
    prev_snap = snap_btn;

    if (clear_btn == 1 && prev_clear == 0) {
        clear_snapshot();
        clear_btn = 0;
    }
    prev_clear = clear_btn;

    // Pump toggle — single button. Decide direction based on current state:
    //   running (1) → click = OFF (0x0000)
    //   off (6)     → click = ON  (0xFF00)
    //   booting (0) / unknown → default to ON (idempotent if already on)
    if (hp_toggle_btn == 1 && prev_hp_toggle == 0) {
        int cur = 0;
        if (known[217]) cur = reg[217] & 0xFFFF;
        if (cur == 1) {
            addLog("HP: web button → Pump OFF");
            hp_send_modbus(0x05, 40, 0x0000);
        } else {
            addLog("HP: web button → Pump ON");
            hp_send_modbus(0x05, 40, 0xFF00);
        }
        hp_toggle_btn = 0;
    }
    prev_hp_toggle = hp_toggle_btn;

    // ── Watchdog state machine ──
    // Runs every second; transitions are millis-driven so a missed tick
    // is harmless (next tick catches up). All actions are non-blocking;
    // the OFF→ON gap and the cooldown both use millis() comparisons.
    if (wd_enable) {
        int now = millis();

        // State 3: cooldown (silent after a recovery cycle)
        if (wd_state == 3) {
            if (now >= wd_cooldown_until_ms) {
                wd_state = 0;
                addLog("HP WATCHDOG: cooldown ended, monitoring resumed");
            }
        }
        // State 2: just sent OFF, send ON 30 s later
        else if (wd_state == 2) {
            if (now - wd_off_sent_ms >= 30000) {
                hp_send_modbus(0x05, 40, 0xFF00);
                addLog("HP WATCHDOG: cycle ON sent (FC05 coil 40 = FF00)");
                wd_state = 3;
                if (wd_test_active) {
                    // Test mode — 30 s cooldown so we're back to monitor
                    // quickly. No real-life consequences since this was
                    // an artificial trigger.
                    wd_cooldown_until_ms = now + 30000;
                    wd_test_active = 0;
                    addLog("HP WATCHDOG: TEST cooldown 30s (would normally be wd_cooldown_min)");
                } else {
                    wd_cooldown_until_ms = now + (wd_cooldown_min * 60000);
                }
            }
        }
        // States 0/1: monitor + alert. Need the named registers populated.
        else if (known[217] && known[191]) {
            int hp_state = reg[217] & 0xFFFF;
            int u = reg[191] & 0xFFFF;
            int s = u; if (s >= 32768) s = s - 65536;
            float ausgang_c = s / 10.0;

            // All trigger conditions must hold simultaneously
            int conds = 0;
            if (hp_state == 1 &&
                rtemp  < wd_outside_max &&
                ausgang_c < wd_ausgang_min &&
                aztemp < wd_room_min &&
                wtemp  < wd_room_min &&
                ktmp   < wd_cellar_min) {
                conds = 1;
            }

            if (wd_state == 0) {
                if (conds) {
                    wd_state = 1;
                    wd_alert_started_ms = now;
                    addLog("HP WATCHDOG: alert started — all signals say pump is stuck");
                }
            } else if (wd_state == 1) {
                if (!conds) {
                    wd_state = 0;
                    addLog("HP WATCHDOG: alert cleared (conditions normalised)");
                } else if (now - wd_alert_started_ms >= wd_grace_min * 60000) {
                    // Grace expired — cycle the pump
                    hp_send_modbus(0x05, 40, 0x0000);
                    addLog("HP WATCHDOG: TRIGGERED — cycle OFF sent (FC05 coil 40 = 0000)");
                    wd_state = 2;
                    wd_off_sent_ms = now;
                    wd_trigger_count = wd_trigger_count + 1;

                    // Escalation: if a previous trigger was recent (within
                    // 3× cooldown_min), the previous recovery cycle clearly
                    // didn't help — send the URGENT email instead of the
                    // routine "cycle triggered" one.
                    int rapid_repeat = 0;
                    if (wd_last_trigger_ms > 0 &&
                        (now - wd_last_trigger_ms) < (wd_cooldown_min * 60000 * 3)) {
                        rapid_repeat = 1;
                    }
                    wd_last_trigger_ms = now;

                    if (rapid_repeat) {
                        addLog("HP WATCHDOG: ESCALATION — previous cycle did not help, pump may need physical reset");
                        wd_send_alert("URGENT: pump stuck, recovery failed");
                    } else {
                        wd_send_alert("auto-recovery cycle triggered");
                    }
                }
            }
        }
    }
}

// ------------------------------------------------------------
// Console commands
// ------------------------------------------------------------
void Command(char cmd[]) {
    // using global g_resp

    // ── HEAT-PUMP CONTROL ──
    // Coil 40 (FC05) is the actual on/off switch — discovered by sniffing the
    // cloud's commands. r217 is just a read-only status reflection.
    //   coil 40 = 0xFF00  → pump ON
    //   coil 40 = 0x0000  → pump OFF
    if (strFind(cmd, "ON") == 0 && strlen(cmd) == 2) {
        if (hp_send_modbus(0x05, 40, 0xFF00)) {
            responseCmnd("HP ON sent (FC05 coil 40 = 0xFF00)");
        } else {
            responseCmnd("HP ON FAILED");
        }

    } else if (strFind(cmd, "OFF") == 0 && strlen(cmd) == 3) {
        if (hp_send_modbus(0x05, 40, 0x0000)) {
            responseCmnd("HP OFF sent (FC05 coil 40 = 0x0000)");
        } else {
            responseCmnd("HP OFF FAILED");
        }

    } else if (strFind(cmd, "SET ") == 0) {
        // HPSET <decimal °C> — write target water temp to r1, scaled ×10
        // e.g. "MBUSSET 32" → write 320 to r1 (=32.0 °C)
        // TinyC atoi() needs a char[] var (no pointer arithmetic), so copy
        // the digits after "SET " into a small buffer first.
        char arg[16];
        int n = strlen(cmd);
        int j = 0;
        for (int i = 4; i < n; i++) {
            if (j < 15) {
                arg[j] = cmd[i];
                j = j + 1;
            }
        }
        arg[j] = 0;
        int t10 = atoi(arg) * 10;
        if (t10 < 100 || t10 > 600) {
            sprintf(g_resp, "HPSET: out of range (10..60 °C), got %d", t10/10);
            responseCmnd(g_resp);
        } else if (hp_send_modbus(0x06, 1, t10)) {
            sprintf(g_resp, "HP target %d.0 °C sent (FC06 r1=%d)", t10/10, t10);
            responseCmnd(g_resp);
        } else {
            responseCmnd("HPSET FAILED");
        }

    } else if (strFind(cmd, "WD ON") == 0 && strlen(cmd) == 5) {
        wd_enable = 1;
        // Reset any leftover state machine residue (e.g. stuck state=2 from
        // an interrupted TEST while watchdog was disabled).
        wd_state = 0;
        wd_alert_started_ms = 0;
        wd_off_sent_ms = 0;
        wd_cooldown_until_ms = 0;
        saveVars();
        responseCmnd("watchdog ENABLED (state reset to monitor)");

    } else if (strFind(cmd, "WD OFF") == 0 && strlen(cmd) == 6) {
        wd_enable = 0; saveVars();
        responseCmnd("watchdog DISABLED");

    } else if (strFind(cmd, "WD STAT") == 0 && strlen(cmd) == 7) {
        char st[24];
        if      (wd_state == 0) strcpy(st, "monitor");
        else if (wd_state == 1) strcpy(st, "alerting");
        else if (wd_state == 2) strcpy(st, "cycling-off");
        else if (wd_state == 3) strcpy(st, "cooldown");
        else                    strcpy(st, "?");
        int now = millis();
        int aging = 0;
        if (wd_state == 1) aging = (now - wd_alert_started_ms) / 60000;
        if (wd_state == 3) aging = (wd_cooldown_until_ms - now) / 60000;
        sprintf(g_resp, "wd=%d state=%s timer=%dmin triggers=%d  rooms=%.1f/%.1f/%.1f outside=%.1f",
                wd_enable, st, aging, wd_trigger_count,
                aztemp, wtemp, ktmp, rtemp);
        responseCmnd(g_resp);

    } else if (strFind(cmd, "WD TEST URGENT") == 0 && strlen(cmd) == 14) {
        // End-to-end self-test simulating an ESCALATION (rapid repeat trigger
        // means the previous recovery cycle didn't help). Sends URGENT email,
        // cycles the pump off/on, uses short cooldown.
        addLog("HP WATCHDOG: TEST URGENT — simulating rapid-repeat escalation");
        wd_last_trigger_ms = millis() - 60000;       // pretend prev trigger was 1 min ago
        wd_trigger_count   = wd_trigger_count + 1;
        wd_send_alert("URGENT: pump stuck, recovery failed (TEST)");
        hp_send_modbus(0x05, 40, 0x0000);
        addLog("HP WATCHDOG: TEST URGENT — FC05 OFF sent, ON in ~2s via state machine");
        wd_state = 2;
        wd_off_sent_ms = millis() - 28000;            // ON fires 2 s after this tick
        wd_test_active = 1;                            // → 30 s cooldown
        responseCmnd("TEST URGENT: URGENT email + cycle off, full recovery in ~32s");

    } else if (strFind(cmd, "WD TEST") == 0 && strlen(cmd) == 7) {
        // End-to-end self-test of the NORMAL recovery path. Walks through
        // exactly what happens at a real trigger: email → cycle OFF → wait
        // → cycle ON → cooldown. Cooldown shortened to 30 s for testing.
        addLog("HP WATCHDOG: TEST — simulating normal cycle trigger");
        wd_trigger_count = wd_trigger_count + 1;
        wd_send_alert("auto-recovery cycle triggered (TEST)");
        hp_send_modbus(0x05, 40, 0x0000);
        addLog("HP WATCHDOG: TEST — FC05 OFF sent, ON in ~2s via state machine");
        wd_state = 2;
        wd_off_sent_ms = millis() - 28000;            // ON fires 2 s after this tick
        wd_test_active = 1;                            // → 30 s cooldown
        responseCmnd("TEST: email + cycle off, full recovery in ~32s");

    } else if (strFind(cmd, "WD MAIL ") == 0) {
        // Set recipient: MBUSWD MAIL <addr>  (or 'CLEAR' to disable)
        char arg[64];
        int n = strlen(cmd);
        int j = 0;
        for (int i = 8; i < n; i++) {
            if (j < 63) { arg[j] = cmd[i]; j = j + 1; }
        }
        arg[j] = 0;
        if (strFind(arg, "CLEAR") == 0 && strlen(arg) == 5) {
            strcpy(wd_email_to, "");
            saveVars();
            responseCmnd("watchdog email recipient cleared (alerts disabled)");
        } else {
            strcpy(wd_email_to, arg);
            saveVars();
            sprintf(g_resp, "watchdog email recipient set: %s", wd_email_to);
            responseCmnd(g_resp);
        }

    } else if (strFind(cmd, "WD MAIL") == 0 && strlen(cmd) == 7) {
        // Show current recipient
        if (strlen(wd_email_to) == 0) {
            responseCmnd("watchdog email: not configured (use MBUSWD MAIL <addr>)");
        } else {
            sprintf(g_resp, "watchdog email recipient: %s", wd_email_to);
            responseCmnd(g_resp);
        }

    } else if (strFind(cmd, "WD MAILTEST") == 0 && strlen(cmd) == 11) {
        // Send a test email NOW with current state — proves SMTP works
        wd_send_alert("MAILTEST (manual)");
        responseCmnd("test email sent (check inbox + log)");

    } else if (strFind(cmd, "STAT") == 0 && strlen(cmd) == 4) {
        sprintf(g_resp, "frames=%d parsed=%d failed=%d known=%d log=%d writes=%d wd=%d",
                frames_seen, rsp_parsed, rsp_failed, registers_known, log_on,
                writes_seen, wd_state);
        responseCmnd(g_resp);

    } else if (strFind(cmd, "SNAP") == 0 && strlen(cmd) == 4) {
        take_snapshot();
        responseCmnd("snapshot taken");

    } else if (strFind(cmd, "CLEAR") == 0 && strlen(cmd) == 5) {
        for (int i = 0; i < 500; i++) {
            known[i] = 0; reg[i] = 0; snap[i] = 0; snapped[i] = 0;
        }
        registers_known = 0;
        frames_seen = 0; bytes_seen = 0; rsp_parsed = 0; rsp_failed = 0;
        responseCmnd("cleared");

    } else if (strFind(cmd, "WRITES") == 0 && strlen(cmd) == 6) {
        // Dump the circular buffer of recent FC06/FC16 writes (own + observed)
        if (wlog_count == 0) {
            responseCmnd("no writes captured yet");
        } else {
            char line[120];
            int now = millis();
            sprintf(line, "%d write events:", wlog_count);
            addLog(line);
            // Iterate from oldest to newest
            int start = 0;
            if (wlog_count == 16) start = wlog_pos;
            for (int k = 0; k < wlog_count; k++) {
                int idx = (start + k) % 16;
                int age_s = (now - wlog_ms[idx]) / 1000;
                char src_label[12];
                if (wlog_src[idx] == 'M') strcpy(src_label, "OWN ");
                else                       strcpy(src_label, "OBS ");
                sprintf(line, "  %s addr=%d (0x%04x) val=%d (0x%04x)  %ds ago",
                        src_label, wlog_addr[idx], wlog_addr[idx],
                        wlog_val[idx], wlog_val[idx], age_s);
                addLog(line);
            }
            sprintf(line, "%d events dumped to log", wlog_count);
            responseCmnd(line);
        }

    } else if (strFind(cmd, "DUMP") == 0 && strlen(cmd) == 4) {
        char line[120];
        for (int i = 0; i < 500; i = i + 8) {
            int any = 0;
            for (int j = 0; j < 8; j++) if (i+j < 500 && known[i+j]) any = 1;
            if (!any) continue;
            sprintf(line, "r%03d:", i);
            char tmp[32];
            for (int j = 0; j < 8; j++) {
                if (i+j < 500 && known[i+j]) {
                    sprintf(tmp, " %04x", reg[i+j] & 0xFFFF);
                } else {
                    strcpy(tmp, " ----");
                }
                strcat(line, tmp);
            }
            addLog(line);
        }
        responseCmnd("dumped to log");

    } else if (strFind(cmd, "LOG ON") == 0 && strlen(cmd) == 6) {
        log_on = 1;
        log_until_ms = 0;   // permanent
        responseCmnd("frame logging ON (permanent — use MBUSLOG OFF to stop)");

    } else if (strFind(cmd, "LOG OFF") == 0 && strlen(cmd) == 7) {
        log_on = 0;
        log_until_ms = 0;
        responseCmnd("frame logging OFF");

    } else if (strFind(cmd, "CAPTURE") == 0 && strlen(cmd) == 7) {
        // Brief capture window — turns logging ON for ~10 s (covers a full
        // cloud-poll cycle of ~8 s plus headroom), then auto-disables.
        log_on = 1;
        log_until_ms = millis() + 10000;
        responseCmnd("capture: frame logging ON for 10s");

    } else {
        responseCmnd("MBUS: ON|OFF | SET <T> | WD ON|OFF|STAT|TEST|TEST URGENT | WD MAIL [<addr>|CLEAR] | WD MAILTEST | STAT | SNAP | CLEAR | DUMP | WRITES | CAPTURE | LOG ON|OFF");
    }
}

// ------------------------------------------------------------
int main() {
    ser = serialBegin(rx_pin, tx_pin, baud, cfg, 1024);
    if (ser < 0) {
        addLog("heatpump_map: serialBegin FAILED");
        return 0;
    }
    char m[120];
    sprintf(m, "heatpump_map ready: ser=%d rx=%d tx=%d baud=%d 8N2  open /tc_ui",
            ser, rx_pin, tx_pin, baud);
    addLog(m);
    addCommand("MBUS");
    webPageLabel(0, "Heat-pump map");

    // Sniffing happens in Every50ms — no task needed. main() exits and
    // the VM halts; the tick callback gets the bytes from TasmotaSerial's
    // 1 KB ring buffer at 50 ms cadence (max ~95 B per tick).
    return 0;
}