Zum Inhalt

heatpump_map_full.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     = 14;
int  tx_pin     = 15;
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

// Mailer state — Command() builds the params line into mailer_params and
// sets mail_pending; TaskLoop() (which runs in tc_vm_task with 12 KB stack,
// same context Scripter uses successfully for SendMail) does the actual
// mailSend. Calling mailSend from inside Command() runs on the web/main
// task with vm_mutex held → reboot. The flag-based handoff avoids that.
char mailer_params[140];   // full sendmail params "[*:*:*:*:*:to:subject]body"
int  mail_pending = 0;     // 1 = TaskLoop should send, then clear

// ── Watchdog-hunt instrumentation (2026-05-04) ────────────────────────────
//
// Mail watchdog ruled out (never fired in this user's deployment), so the
// >5 s blocker that triggers the ESP32 RTC WDT must be elsewhere. To find
// it we measure two complementary signals and surface the worst values
// seen since boot in the WebCall row list:
//
//   1. Per-callback duration: time each hot callback (Every50ms, EverySecond,
//      WebCall, JsonCall, TaskLoop's mailSend, hp_send_modbus). We keep the
//      max ever seen of each — so the row that shows "Every50ms max=4321 ms"
//      tells us exactly which callback blocked.
//
//   2. Inter-tick gap: time between consecutive Every50ms invocations. If
//      that gap is >1 s, *something* held the VM mutex (or hogged CPU on
//      the loop task) for that long. Catches blockers we forgot to wrap.
//
// Console command  HPLATRESET  zeros all maxima for a clean window.
int  cb_50_max_ms       = 0;   // max duration of a single Every50ms() call
int  cb_sec_max_ms      = 0;   // max duration of a single EverySecond() call
int  cb_web_max_ms      = 0;   // max duration of a single WebCall() call
int  cb_json_max_ms     = 0;   // max duration of a single JsonCall() call
int  mail_last_ms       = 0;   // most recent mailSend duration
int  mail_max_ms        = 0;   // longest mailSend ever
int  mail_calls         = 0;
int  mb_last_ms         = 0;   // most recent hp_send_modbus duration
int  mb_max_ms          = 0;   // longest hp_send_modbus ever
int  mb_calls           = 0;
int  tick_gap_max_ms    = 0;   // max gap between two Every50ms invocations
int  last_50ms_run_ms   = 0;   // when Every50ms last ran (set at TOP of fn)
int  long_call_warns    = 0;   // count of >1 s events of any kind
#define LONG_CALL_WARN_MS  1000   // 1 s — log a warning
#define LONG_CALL_CRIT_MS  4000   // 4 s — log CRITICAL (1 s shy of WDT)

// Cap bytes drained from UART per Every50ms tick. At 19200 baud max
// ~95 bytes can arrive in 50 ms, so 192 leaves 2× headroom while still
// preventing a single tick from doing many-100s-of-bytes of work after
// the loop unfreezes from elsewhere (which would itself add ~100 ms+
// of parse work on top of whatever caused the original freeze, possibly
// triggering the watchdog by itself in a feedback loop).
#define E50_MAX_BYTES_PER_TICK  192
int  e50_bytes_max      = 0;   // diagnostic: peak bytes processed in one tick

// ── Currently-running-callback tracker (for watchdog post-mortem) ────
// Each callback sets cb_active_id at its TOP and clears at its bottom.
// On a watchdog trip the loop task is hung — whatever value is here is
// the most-recently-entered callback that hasn't returned, i.e. the
// likely culprit. Captured into the ring buffer pre-Restart.
//   0 = idle / between callbacks
//   1 = Every50ms
//   2 = EverySecond
//   3 = WebCall
//   4 = JsonCall
//   5 = Command
//   6 = OnMqttData (unused here but reserved)
//   7 = TaskLoop (won't show — runs in tc_vm_task)
int  cb_active_id      = 0;

// ── Loop-task watchdog (via TaskLoop) ───────────────────────────────
// Loop task can hang for minutes inside Tasmota web handlers / lwIP /
// other subsystems we can't instrument from script-side. Observed twice
// on .31: 250 s and 270 s gaps after web-menu navigation — heap fine,
// stack fine, no chip-WDT fire (some IDLE thread keeps feeding it),
// device looks alive on the wire but every HTTP request times out.
//
// TaskLoop runs in tc_vm_task — a SEPARATE FreeRTOS task from the loop
// task — so it keeps running while the loop task is hung. We use it as
// an external watchdog: every iteration it compares millis() against
// last_50ms_run_ms (set by Every50ms which runs ON the loop task). If
// the loop task hasn't ticked Every50ms in WDOG_LOOP_REBOOT_MS, force
// a chip restart via tasmCmd("Restart 1"). Recovery time drops from
// "stuck for minutes" to ~10 s reboot.
//
// Threshold history:
//   60 s → 20 s (2026-05-04). At 60 s a 36-second freeze (observed via
//   gap=36361 in MBUSLAT) self-recovered before tripping, so the ring
//   buffer never captured the cb_active_id snapshot. 20 s catches the
//   30–60 s freezes the user feels as "stuck" while still leaving 4×
//   headroom over the longest legitimate callback (peak EverySecond
//   ~600 ms, peak Every50ms 21 ms, web/json single-digit ms).
//
// Disable from console: MBUSWDOG OFF (sets wdog_loop_enable=0).
// Enable:               MBUSWDOG ON
#define WDOG_LOOP_REBOOT_MS 20000   // 20 s
int  wdog_loop_enable    = 1;        // 1 = active, 0 = off (toggle via cmd)
// Persist across reboots so the trip counter survives our own forced
// restart. saveVars() called explicitly in the wdog handler before the
// Restart 1 fires — without that, the auto-save-on-stop path doesn't
// run because Tasmota's restart-shutdown is dispatched on the (hung)
// loop task. Last-trip + ring-buffer history give us a record of WHEN
// each freeze happened so we can correlate with periodic events.
persist int wdog_loop_trips        = 0;   // total trips since deploy
persist int wdog_last_silent_ms    = 0;   // duration of the last detected silence
persist int wdog_last_trip_uptime  = 0;   // tasm_uptime at last trip
// Ring buffer of last 8 trips — each trip records:
//   *_freeze_start_unix   tasm_time (minutes-since-midnight) at trip — kept
//                         for back-compat; freeze-start computation is bogus
//                         (subtracts seconds from minutes), use *_taskloop_ms
//                         + *_uptime_s to characterise the trip instead.
//   *_silent_ms           how long the loop task was silent (Every50ms gap)
//   *_freeze_uptime_s     uptime at start of freeze (≈ triggering event)
//   *_active_cb           cb_active_id at trip time (which callback was hung;
//                         0 = no TinyC loop-task callback was running)
//   *_taskloop_ms         millis()-since last TaskLoop body iteration:
//                           small value (< 200 ms) → TaskLoop healthy →
//                                                    freeze is in Tasmota
//                           ≈ silent_ms              → TaskLoop stuck →
//                                                    freeze is in a TinyC
//                                                    syscall holding vm_mutex
//   *_e50/e1s/web/json/mb peak callback durations at trip time
//
// IMPORTANT: declared WITHOUT `= {0,0,...}` initializers. TinyC's array
// initializer is emitted as runtime store-loop code that runs at script
// start AFTER tc_persist_load — so an explicit `={...}` would zero the
// arrays *every boot* and discard the persisted trip data. Bare
// declarations get zero-init at heap allocation time (one-time, before
// persist load), which is exactly what we want.
//
// idx points to the NEXT slot to write (so most-recent is at idx-1 mod 8).
//
// Refactored to a struct in TinyC 1.4.0+: replaces 10 parallel persist
// `int wdog_hist_X[8]` arrays with a single `persist WdogTrip wdog_hist[8]`.
// Same on-disk size (10 × 8 × 4 bytes), but ONE persist-layout entry
// instead of ten — and impossible to forget to add the matching `[slot]=`
// line when introducing a new field, since every read/write goes through
// the struct.
//
// NOTE on persist hash and field reordering: the v1.4 persist hash
// includes the struct's slotCount but NOT field-name list, so silently
// reordering fields in WdogTrip after persist data exists won't
// invalidate the .pvs file (the saved bytes would be loaded with shifted
// field offsets). Workaround if you reorder: bump WDOG_HIST or rename
// the var briefly to force re-init. v2 persist hash will include field
// names.
#define WDOG_HIST  8
struct WdogTrip {
    int freeze_unix;     // tasm_time at the start of the silent window (back-dated)
    int silent_ms;       // duration of the loop-task silence
    int uptime_s;        // tasm_uptime at the start of the silent window
    int active_cb;       // cb_active_id captured pre-Restart (1=Every50ms 2=EverySecond …)
    int e50_max;         // peak Every50ms duration this boot
    int e1s_max;         // peak EverySecond duration
    int web_max;         // peak WebCall duration
    int json_max;        // peak JsonCall duration
    int mb_max;          // peak hp_send_modbus duration
    int taskloop_ms;     // 1.x: TaskLoop liveness (ms since last TaskLoop tick)
}
persist int       wdog_hist_idx;
persist WdogTrip  wdog_hist[8];

// TaskLoop liveness: updated at top of every TaskLoop iteration. The trip
// handler captures (millis() - last_task_loop_run_ms) into the ring buffer
// to tell apart "TaskLoop stuck (TinyC syscall holding mutex)" vs
// "Tasmota loop-task stuck (freeze outside TinyC)". Non-persist — fresh
// on every boot.
int  last_task_loop_run_ms = 0;

// ── 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 × 4 ints = ~256 B), enough to catch a complete cloud on/off interaction.
//
// Refactored to a struct in TinyC 1.4.0+: replaces the four parallel
// int wlog_addr[16] / int wlog_val[16] / int wlog_ms[16] / char wlog_src[16]
// arrays with a single array of WriteEvent records — eliminates the
// "arrays got out of sync" bug class entirely (e.g. miss adding a
// wlog_addr[p]= line and the slot's address silently stays from the
// previous occupant of the slot).
struct WriteEvent {
    int  addr;     // Modbus register / coil address
    int  val;      // value written
    int  ms;       // millis() at capture
    char src;      // 'O' = observed (cloud/external), 'M' = me (this script's send)
}

WriteEvent wlog[16];
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) °C
global float wtemp  = 20.0;   // Wohnzimmer (living room) °C
global float ktmp   = 14.0;   // Keller (cellar — typically colder) °C
global float rtemp  = 10.0;   // Außentemperatur (Bresser sensor) °C
global float hwp    =  0.0;   // Heat-pump current power consumption (W)

// Heat-pump live state — broadcast when store_reg() lands a value on
// the matching Modbus register. Lets other devices on the LAN
// (energy_dashboard, sub-displays, etc.) show the heat-pump's
// own readings without each having to talk Modbus.
global float hp_run  = 0.0;   // 1.0 = running, 0.0 = off  (from r217 enum)
global float hp_in   = 0.0;   // Water back from heating circuit / Puffer (°C, r188)
global float hp_out  = 0.0;   // Supply water / Ausgangstemp (°C, r191)
global float hp_at   = 0.0;   // Outside-air sensor on the HP itself (°C, r190)

// ── 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

// ── 4-hour temperature historian (1-minute granularity) ──────
// Two ring buffers feeding the WebPage charts: pump buffer tank
// (r188 Puffer) and supply water out (r191 Ausgangstemp). 240
// slots × 1 min = 4 h. Persist'd so a quick restart preserves
// the trend; on cold boot the slots default to 0.0 and WebPage
// hides those (treats 0.0 as "no sample") to avoid a fake
// baseline drop. Cursor advances each new minute (edge-detected
// on `tasm_minute`) and wraps modulo 240.
// Chart data — kept OUTSIDE persist on purpose. The persist file is
// keyed by layout-hash and gets discarded on any persist-var add/move/
// remove; that nukes hours of accumulated chart history every time we
// edit anything else in this script. Instead, store charts in a
// dedicated /heatpump_map.charts file via simple subroutines (see
// load_charts / save_charts below). Saved once per new sample (~1×/min)
// so a hard reboot loses at most ~60 s of history.
//
// On-disk format (binary, little-endian, 1924 bytes):
//   [240 × float32 pf_hist]   [240 × float32 ow_hist]   [int32 chart_pos]
// Uses the binary array I/O syscalls fileReadBin / fileWriteBin
// (TC_RELEASE 1.3.37+) — same syscall serves int[] and float[] alike
// since both are int32 in memory. No manual byte-pack/unpack needed.
float pf_hist[240];                 // Puffer °C ring (r188 / 10)
float ow_hist[240];                 // Ausgang °C ring (r191 / 10)
int   chart_pos = 0;                // next slot to write (0..239)
int   chart_pos_arr[1];             // 1-elem buffer for fileReadBin/WriteBin
int last_sample_min = -1;                   // edge-detect for the 1-min sampler

// 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[p].addr = addr;
    wlog[p].val  = val;
    wlog[p].ms   = millis();
    wlog[p].src  = 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;
    reg_writes = reg_writes + 1;   // life-indicator counter (read in WebCall)
    if (known[addr] == 0) {
        known[addr] = 1;
        registers_known = registers_known + 1;
    }
    reg[addr] = val;

    // ── Mirror the four headline registers onto UDP-broadcast globals
    //    so neighbour scripts (energy_dashboard, etc.) see fresh values
    //    without polling Modbus themselves. Sign-extension matches the
    //    parsing in WebCall / JsonCall: r188 / r190 / r191 are signed
    //    ×10 °C; r217 is an enum (0=boot, 1=running, 6=remote-off).
    if (addr == 188 || addr == 190 || addr == 191) {
        int s = val & 0xFFFF;
        if (s >= 32768) s = s - 65536;
        float t = s / 10.0;
        if      (addr == 188) hp_in  = t;
        else if (addr == 190) hp_at  = t;
        else if (addr == 191) hp_out = t;
    } else if (addr == 217) {
        hp_run = ((val & 0xFFFF) == 1) ? 1.0 : 0.0;
    }
}

// ------------------------------------------------------------
// 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;
    // ── Fast-path: pure FC03 read frame → skip the 248-iteration embedded
    //    write scan below. The scan exists to catch FC05/06/15/16 writes
    //    embedded within MERGED frames (cloud sends FC06 within <4 ms of a
    //    prior FC03 poll tail). For a clean FC03 REQ (8 bytes) or merged
    //    FC03 REQ+RSP (size matches exactly), there's no embedded write
    //    to find — running the scan anyway costs ~120 ms on a 256-byte
    //    buffer for nothing.
    //
    //    Heuristic: if we start with `01 03` AND the size matches one
    //    of the three valid FC03 patterns exactly, we know there's no
    //    write piggy-backing. If size is off, fall through to the full
    //    scan (which catches merged-with-write or partial-frame cases).
    int skip_write_scan = 0;
    if (sl == 1 && fc == 0x03) {
        // Lone REQ: 8 bytes
        if (buf_len == 8) skip_write_scan = 1;
        else if (buf_len >= 5) {
            // Lone RSP: 5 + byte_count
            int bc_rsp = buf[2] & 0xFF;
            if (buf_len == 5 + bc_rsp) skip_write_scan = 1;
            // Merged REQ + RSP: 8 + 5 + bc, where bc = req_qty*2
            else if (buf_len >= 16) {
                int req_qty = ((buf[4] & 0xFF) << 8) | (buf[5] & 0xFF);
                int bc_merged = (buf[10] & 0xFF);
                if (buf_len == 13 + bc_merged && bc_merged == req_qty * 2) {
                    skip_write_scan = 1;
                }
            }
        }
    }
    // ── 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). CRC validation eliminates byte-pattern false positives
    // that would otherwise appear at random within FC03 response data.
    char crc_buf[6];
    for (int i = 0; !skip_write_scan && 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

        // CRC validation for FC05/FC06 (fixed 8-byte frames) — eliminates
        // byte-pattern false positives that otherwise appear randomly
        // within FC03 response data. FC15/FC16 have variable-length frames
        // so their CRC isn't at bytes 6-7; we keep their weaker (qty range)
        // sanity check below.
        int crc_ok = 0;
        if (f2 == 0x05 || f2 == 0x06) {
            for (int k = 0; k < 6; k++) crc_buf[k] = buf[i+k] & 0xFF;
            int expected = modbus_crc16(crc_buf, 6);
            int got = ((buf[i+7] & 0xFF) << 8) | (buf[i+6] & 0xFF);
            if (expected == got) crc_ok = 1;
        }

        if (f2 == 0x05) {
            if (!crc_ok) continue;
            // 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) {
            if (!crc_ok) continue;
            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);
        addLog("MB f#%d %dB hex=%s", frames_seen, buf_len, g_hex);
    }
}

// ------------------------------------------------------------
// 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() {
    cb_active_id = 1;
    int t_50 = millis();
    // Detect inter-tick gaps. Any value > 1000 ms tells us the VM was
    // blocked (e.g. a long syscall, a held mutex, or another callback
    // ran long). last_50ms_run_ms == 0 on the first invocation; skip.
    if (last_50ms_run_ms > 0) {
        int gap = t_50 - last_50ms_run_ms;
        if (gap > tick_gap_max_ms) tick_gap_max_ms = gap;
        if (gap > LONG_CALL_CRIT_MS) {
            long_call_warns = long_call_warns + 1;
            sprintf(g_tmp, "TICK ⚠ CRIT gap=%d ms (within %d ms of WDT)",
                    gap, 5000 - gap);
            addLog(g_tmp);
        } else if (gap > LONG_CALL_WARN_MS) {
            long_call_warns = long_call_warns + 1;
            sprintf(g_tmp, "TICK ⚠ slow gap=%d ms", gap);
            addLog(g_tmp);
        }
    }
    last_50ms_run_ms = t_50;

    if (ser < 0) {
        int dt0 = millis() - t_50;
        if (dt0 > cb_50_max_ms) cb_50_max_ms = dt0;
        cb_active_id = 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 && got < E50_MAX_BYTES_PER_TICK) {
        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 > e50_bytes_max) e50_bytes_max = got;
    if (got == 0 && buf_len > 0) {
        // No new bytes this tick + we have data → end of frame
        flush_frame();
        buf_len = 0;
    }
    int dt_50 = millis() - t_50;
    if (dt_50 > cb_50_max_ms) cb_50_max_ms = dt_50;
    cb_active_id = 0;
}

// ------------------------------------------------------------
// Snapshot current registers as baseline (for diff highlighting)
// ------------------------------------------------------------
// ------------------------------------------------------------
// Chart persistence — write/read all chart rings to a single file
// (/heatpump_map.charts), independent of TinyC's persist mechanism.
//
// Why not `persist`: the .pvs file is keyed by an FNV-1a hash of the
// persist-var layout (count + index + slot count). Adding any new
// `persist int x` elsewhere in the script changes that hash → the
// whole file is discarded → all chart history zeroed. Storing
// charts in a NAMED, dedicated file (read by position) makes them
// survive any unrelated persist-var edits.
//
// File layout (little-endian, total 1924 B):
//   [0..959]      240 × float32  pf_hist (°C)
//   [960..1919]   240 × float32  ow_hist (°C)
//   [1920..1923]  int32          chart_pos
//
// Sampling cadence is once per minute (240 slots = 4 hours), so the
// save runs ~1×/min — roughly 100 KB/day of LittleFS writes, well
// within wear budget.
//
// Uses the binary array file I/O syscalls fileReadBin / fileWriteBin
// (TC_RELEASE 1.3.37+). Same syscall handles int[] and float[] alike
// since both are int32 in memory; the on-disk bit pattern is the
// same. No manual byte-pack/unpack needed.
//
// Pattern is reusable: copy these two functions to any script that
// has chart arrays, change the array names + count + filename, done.
// ------------------------------------------------------------
void save_charts() {
    int h = fileOpen("/heatpump_map.charts", "w");
    if (h < 0) {
        addLog("save_charts: fileOpen failed");
        return;
    }
    fileWriteBin(h, pf_hist, 240);
    fileWriteBin(h, ow_hist, 240);
    chart_pos_arr[0] = chart_pos;
    fileWriteBin(h, chart_pos_arr, 1);
    fileClose(h);
}

void load_charts() {
    int sz = fileSize("/heatpump_map.charts");
    if (sz < 1924) {
        // Missing or wrong-size file — could be a fresh install OR an
        // older int16-format file from before TC_RELEASE 1.3.37. Either
        // way, leave arrays zero-initialized; new samples will start
        // populating from chart_pos=0.
        sprintf(g_tmp, "load_charts: file size %d != 1924, starting fresh", sz);
        addLog(g_tmp);
        return;
    }
    int h = fileOpen("/heatpump_map.charts", "r");
    if (h < 0) {
        addLog("load_charts: fileOpen failed");
        return;
    }
    fileReadBin(h, pf_hist, 240);
    fileReadBin(h, ow_hist, 240);
    if (fileReadBin(h, chart_pos_arr, 1) == 1) {
        chart_pos = chart_pos_arr[0];
        if (chart_pos < 0 || chart_pos >= 240) chart_pos = 0;
    }
    fileClose(h);
    sprintf(g_tmp, "load_charts: restored %d B from /heatpump_map.charts", sz);
    addLog(g_tmp);
}

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;
        }
    }
    addLog("snapshot taken: %d registers", n);
}

// ------------------------------------------------------------
// 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) {
    int t_mb = millis();   // instrumentation: time the whole call

    // 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');

    int dt_mb = millis() - t_mb;
    mb_last_ms = dt_mb;
    if (dt_mb > mb_max_ms) mb_max_ms = dt_mb;
    mb_calls = mb_calls + 1;
    if (dt_mb > LONG_CALL_CRIT_MS) {
        long_call_warns = long_call_warns + 1;
    } else if (dt_mb > LONG_CALL_WARN_MS) {
        long_call_warns = long_call_warns + 1;
    }

    addLog("hp_send_modbus: fc=%02x reg=%d val=%d (0x%04x) in %d ms", fc, regaddr, val, val, dt_mb);
    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).
// ------------------------------------------------------------
// TaskLoop — runs in the dedicated tc_vm_task FreeRTOS task with 12 KB
// stack (same context Scripter uses for SendMail, which is known to work).
// Polls the mail_pending flag set by wd_send_alert and runs mailSend when
// triggered. delay() yields the vm_mutex so other callbacks can interleave.
void TaskLoop() {
    // Liveness beacon — read by the trip handler to compute taskloop_ms
    // (small = TaskLoop healthy, large = TaskLoop itself was stuck inside
    // a long syscall holding vm_mutex). Set FIRST so the value reflects
    // the current iteration even if a syscall later in this body hangs.
    last_task_loop_run_ms = millis();

    // ── Loop-task watchdog ──────────────────────────────────────────
    // TaskLoop runs in tc_vm_task, separate from Tasmota's loop task.
    // If the loop task is hung, last_50ms_run_ms stays frozen. We catch
    // that and force a reboot rather than wait minutes for self-recovery.
    // Skip until the loop task has run at least once (last_50ms_run_ms>0)
    // so we don't trip during early boot before Every50ms ever fires.
    if (wdog_loop_enable && last_50ms_run_ms > 0) {
        int loop_silent_ms = millis() - last_50ms_run_ms;
        if (loop_silent_ms > WDOG_LOOP_REBOOT_MS) {
            wdog_loop_trips       = wdog_loop_trips + 1;
            wdog_last_silent_ms   = loop_silent_ms;
            wdog_last_trip_uptime = tasm_uptime;
            // Record into ring buffer. freeze_unix and freeze_uptime are
            // BACK-DATED to when the freeze actually began (= now minus
            // silent duration). That makes the histogram reflect the
            // triggering event, not when we noticed.
            int silent_s = loop_silent_ms / 1000;
            int slot = wdog_hist_idx;
            if (slot < 0 || slot >= WDOG_HIST) slot = 0;
            wdog_hist[slot].freeze_unix = tasm_time - silent_s;
            wdog_hist[slot].silent_ms   = loop_silent_ms;
            wdog_hist[slot].uptime_s    = tasm_uptime - silent_s;
            // Diagnostic snapshot — captured BEFORE Restart 1 so we
            // can identify the culprit on next boot. cb_active_id of
            //   1=Every50ms 2=EverySecond 3=WebCall 4=JsonCall
            //   0=between callbacks (suggests a non-VM stall, e.g.
            //     Tasmota's lwIP / web / MQTT layer holding the loop)
            // The peaks are this-boot maxima; if one is huge, that's
            // the recurring slow path.
            wdog_hist[slot].active_cb = cb_active_id;
            wdog_hist[slot].e50_max   = cb_50_max_ms;
            wdog_hist[slot].e1s_max   = cb_sec_max_ms;
            wdog_hist[slot].web_max   = cb_web_max_ms;
            wdog_hist[slot].json_max  = cb_json_max_ms;
            wdog_hist[slot].mb_max    = mb_max_ms;
            // TaskLoop liveness: how long since last TaskLoop iteration.
            // Should always be SMALL when this code runs (we're literally
            // inside TaskLoop right now and updated last_task_loop_run_ms
            // at the top), unless something earlier in this iteration
            // (a syscall that holds the mutex without yielding) blocked
            // for the full silent window. Practically:
            //   small (< 200 ms) → freeze is OUTSIDE TinyC (Tasmota stuck)
            //   ≈ silent_ms       → freeze is IN a TinyC syscall (TaskLoop's)
            wdog_hist[slot].taskloop_ms = millis() - last_task_loop_run_ms;
            wdog_hist_idx = (slot + 1) & 7;     // wrap at 8
            // Persist updated counters BEFORE forcing the reboot — the
            // auto-save-on-stop path won't run because Tasmota's
            // shutdown dispatches on the (hung) loop task.
            saveVars();
            sprintf(g_tmp, "WDOG ⚠ loop task silent for %d ms — forcing Restart 1 (trips=%d)",
                    loop_silent_ms, wdog_loop_trips);
            addLog(g_tmp);
            // Longer delay so the log message has a chance to reach
            // serial console (web log is RAM-only and doesn't survive
            // the reboot — only serial console captures these warnings).
            delay(500);
            char wdog_resp[16];
            tasmCmd("Restart 1", wdog_resp);
            // Restart 1 is async; sleep until it actually fires.
            delay(5000);
        }
    }

    if (mail_pending) {
        mail_pending = 0;
        addLog("MAILER: starting send");
        addLog(mailer_params);
        int t_mail = millis();
        int rc = mailSend(mailer_params);
        int dt = millis() - t_mail;
        mail_last_ms = dt;
        if (dt > mail_max_ms) mail_max_ms = dt;
        mail_calls = mail_calls + 1;
        if (dt > LONG_CALL_CRIT_MS) {
            long_call_warns = long_call_warns + 1;
            sprintf(g_tmp, "MAILER ⚠ CRITICAL: mailSend took %d ms (rc=%d) — within %d ms of WDT",
                    dt, rc, 5000 - dt);
            addLog(g_tmp);
        } else if (dt > LONG_CALL_WARN_MS) {
            long_call_warns = long_call_warns + 1;
            sprintf(g_tmp, "MAILER ⚠ slow: mailSend took %d ms (rc=%d)", dt, rc);
            addLog(g_tmp);
        } else {
            sprintf(g_tmp, "MAILER: mailSend rc=%d in %d ms", rc, dt);
            addLog(g_tmp);
        }
    }
    delay(100);   // yield + lighten the polling load
}

// wd_send_alert — builds the params, sets a flag. TaskLoop picks it up and
// runs the actual mailSend in tc_vm_task context.  Calling mailSend
// directly from Command() runs on web/main task with vm_mutex held and
// blows the loop watchdog. Scripter's `mail` command works because it
// calls SendMail() directly from its own tick, on a 12 KB-stack task —
// TaskLoop is our equivalent context.
void wd_send_alert(char subj_part[]) {
    if (strlen(wd_email_to) == 0) return;   // not configured
    if (mail_pending) {
        addLog("HP WD: previous mail still pending, skipping");
        return;
    }

    // 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; }

    // mailSend takes just the bracketed params, no leading "sendmail "
    sprintf(mailer_params, "[*:*:*:*:*:%s:HP Watchdog %s]", wd_email_to, subj_part);
    sprintf(g_tmp, "trips=%d puff=%.1fC out=%.1fC P=%.0fW",
            wd_trigger_count, puffer_c, ausgang_c, hwp);
    strcat(mailer_params, g_tmp);

    addLog("HP WD: queuing mail for TaskLoop");
    mail_pending = 1;
}

// ------------------------------------------------------------
// Tasmota main-page sensor rows. Outputs "{s}label{m}value{e}" lines
// which the main web view renders as a clean sensor table.
// ------------------------------------------------------------
int  web_tick = 0;     // increments each WebCall poll — live indicator
int  parse_tick_at_last_web = 0;  // reg-store activity sampled per poll
int  reg_writes = 0;   // total store_reg() invocations since boot
void WebCall() {
    cb_active_id = 3;
    int t_web = millis();
    // 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];

    // ── Latency tripwire ROW — first thing rendered so it's always visible
    //    even if some later sprintf throws. Maxima persist across page
    //    refreshes and are only zeroed by HPLATRESET console command.
    //    Each value is "max ms (last ms / total calls)" where applicable.
    sprintf(g_row, "{s}<b style='color:#a85;'>WDT-hunt</b>{m}gap=%d  e50=%d  e1s=%d  web=%d  json=%d  mb=%d  mail=%d  warns=%d{e}",
            tick_gap_max_ms, cb_50_max_ms, cb_sec_max_ms, cb_web_max_ms,
            cb_json_max_ms, mb_max_ms, mail_max_ms, long_call_warns);
    webSend(g_row);

    // ── Life indicator: web_tick proves the AJAX poll is firing,
    //    Δreg_writes proves the Modbus parser is still seeing frames.
    //    A flat web_tick = WebUI dead. A flat Δreg_writes with web_tick
    //    advancing = sniffer wedged but firmware still alive.
    web_tick = web_tick + 1;
    int dreg = reg_writes - parse_tick_at_last_web;
    parse_tick_at_last_web = reg_writes;
    sprintf(g_row, "{s}<b style='color:#0a8;'>HP-Map</b> tick %d{m}%d Modbus reg/poll{e}", web_tick, dreg);
    webSend(g_row);

    // ── Free-heap row — this is a low-heap device (EPD-47 ESP32),
    //    keep an eye on it. Colour the value red when it dips below
    //    8 KB (close-to-OOM range for further allocations).
    float fh_kb = tasm_heap / 1024.0;
    if (fh_kb < 8.0) {
        sprintf(g_row, "{s}Free heap{m}<b style='color:#e74c3c;'>%.1f KB</b>{e}", fh_kb);
    } else {
        sprintf(g_row, "{s}Free heap{m}%.1f KB{e}", fh_kb);
    }
    webSend(g_row);

    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);
    // Heat-pump current power consumption (UDP-shared from energy monitor)
    sprintf(g_row, "{s}HP Leistung{m}%.0f W{e}", hwp);
    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);
    }
    int dt_web = millis() - t_web;
    if (dt_web > cb_web_max_ms) cb_web_max_ms = dt_web;
    if (dt_web > LONG_CALL_CRIT_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "WebCall ⚠ CRIT %d ms", dt_web);
        addLog(g_tmp);
    } else if (dt_web > LONG_CALL_WARN_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "WebCall ⚠ slow %d ms", dt_web);
        addLog(g_tmp);
    }
    cb_active_id = 0;
}

// ------------------------------------------------------------
// JSON telemetry — for MQTT teleperiod publishing. Same data,
// machine-readable shape.
// ------------------------------------------------------------
void JsonCall() {
    cb_active_id = 4;
    int t_json = millis();
    if (registers_known < 5) { cb_active_id = 0; 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 + heat-pump power — useful in the same JSON
    // for HA / Node-RED 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);
    sprintf(g_buf, "%s\"Power\":%.0f", sep, hwp);           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("}");
    int dt_json = millis() - t_json;
    if (dt_json > cb_json_max_ms) cb_json_max_ms = dt_json;
    if (dt_json > LONG_CALL_CRIT_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "JsonCall ⚠ CRIT %d ms", dt_json);
        addLog(g_tmp);
    } else if (dt_json > LONG_CALL_WARN_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "JsonCall ⚠ slow %d ms", dt_json);
        addLog(g_tmp);
    }
    cb_active_id = 0;
}

// ------------------------------------------------------------
// 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() {
    cb_active_id = 2;
    int t_sec = millis();
    // ── 1-minute historian for the 4-hour Puffer / Ausgang charts ──
    // Edge-detect on tasm_minute so we sample exactly once per minute
    // regardless of how the EverySecond ticks line up with the wall
    // clock. The 60-minute wrap (59→0) is naturally a "different
    // value" so it triggers a sample like every other minute change.
    // Skip when the register is unknown (warm-up period); leaves the
    // ring slot at its previous value rather than poisoning with 0.0.
    int cur_min = tasm_minute;
    if (cur_min != last_sample_min) {
        last_sample_min = cur_min;
        if (known[188]) {
            int u188 = reg[188] & 0xFFFF; int s188 = u188; if (s188 >= 32768) s188 = s188 - 65536;
            pf_hist[chart_pos] = s188 / 10.0;
        }
        if (known[191]) {
            int u191 = reg[191] & 0xFFFF; int s191 = u191; if (s191 >= 32768) s191 = s191 - 65536;
            ow_hist[chart_pos] = s191 / 10.0;
        }
        chart_pos = (chart_pos + 1) % 240;
        // Flush charts to /heatpump_map.charts after each new sample.
        // ~1×/min ≈ 50 KB/day of LittleFS writes. Cheap, and a hard
        // reboot now loses at most one minute of history instead of
        // everything since the last persist save (which only happened
        // on TinyCStop / OTA).
        save_charts();
    }

    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");
                    }
                }
            }
        }
    }
    int dt_sec = millis() - t_sec;
    if (dt_sec > cb_sec_max_ms) cb_sec_max_ms = dt_sec;
    if (dt_sec > LONG_CALL_CRIT_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "EverySecond ⚠ CRIT %d ms", dt_sec);
        addLog(g_tmp);
    } else if (dt_sec > LONG_CALL_WARN_MS) {
        long_call_warns = long_call_warns + 1;
        sprintf(g_tmp, "EverySecond ⚠ slow %d ms", dt_sec);
        addLog(g_tmp);
    }
    cb_active_id = 0;
}

// ------------------------------------------------------------
// Charts (rendered once on full page load — must live in WebPage,
// not WebCall, because Tasmota's `?m=1` AJAX poll setting
// innerHTML PARSES <script> tags but does NOT execute them, so
// Google-Charts containers placed in WebCall would render empty.
// Same trick energy_dashboard.tc uses.
// ------------------------------------------------------------
void WebPage() {
    // ottelo's chart-centering compensation (margin-left:-30px wrapper)
    webSend("<div style='margin-left:-30px'>");
    // Auto-fit y-axis from non-zero samples (0.0 = "no data yet"
    // because cold-boot fills persist with zeros and the sampler
    // refuses to write when the reg is unknown). If no samples yet,
    // fall back to a sensible heat-pump range so the empty chart
    // still has labelled axes.
    float pf_min = 999.0; float pf_max = -999.0;
    float ow_min = 999.0; float ow_max = -999.0;
    int   pf_n = 0;       int   ow_n = 0;
    for (int i = 0; i < 240; i = i + 1) {
        if (pf_hist[i] != 0.0) {
            if (pf_hist[i] < pf_min) pf_min = pf_hist[i];
            if (pf_hist[i] > pf_max) pf_max = pf_hist[i];
            pf_n = pf_n + 1;
        }
        if (ow_hist[i] != 0.0) {
            if (ow_hist[i] < ow_min) ow_min = ow_hist[i];
            if (ow_hist[i] > ow_max) ow_max = ow_hist[i];
            ow_n = ow_n + 1;
        }
    }
    if (pf_n == 0) { pf_min = 20.0; pf_max = 50.0; }
    if (ow_n == 0) { ow_min = 20.0; ow_max = 50.0; }

    // Pad the y-range by 10 % on each side so the curve doesn't
    // glue itself to the chart edges. Floor the span at 5 °C so a
    // very steady-state pump still has a legible y-axis.
    float pf_span = pf_max - pf_min; if (pf_span < 5.0) pf_span = 5.0;
    float ow_span = ow_max - ow_min; if (ow_span < 5.0) ow_span = 5.0;
    float pf_lo = pf_min - pf_span * 0.1;
    float pf_hi = pf_max + pf_span * 0.1;
    float ow_lo = ow_min - ow_span * 0.1;
    float ow_hi = ow_max + ow_span * 0.1;

    // Both charts at 640×240 — same width as energy_dashboard's
    // charts so they line up if both slots run on the same device.
    // decimals=1, smoothing on (1|8). Time-step = 1 minute.
    WebChartSize(640, 240);
    WebChart(0, "HP Puffer letzte 4h", "°C",
             0x3498db, chart_pos, 240, pf_hist,
             1 | 8, 1, pf_lo, pf_hi);

    WebChartSize(640, 240);
    WebChart(0, "HP Ausgang letzte 4h", "°C",
             0xe67e22, chart_pos, 240, ow_hist,
             1 | 8, 1, ow_lo, ow_hi);
    webSend("</div>");
}

// ------------------------------------------------------------
// Console commands
// ------------------------------------------------------------
void Command(char cmd[]) {
    // Command tracking deliberately omitted — many `return;` paths
    // make a clean clear-on-exit fragile, and Command is rarely the
    // multi-minute-freeze culprit (only runs on user-issued console
    // commands). The four periodic callbacks (Every50ms / EverySecond
    // / WebCall / JsonCall) are the main suspects.
    // using global g_resp

    // DIAG: log the exact cmd string seen so we can verify dispatch
    sprintf(g_tmp, "CMD: '%s' len=%d", cmd, strlen(cmd));
    addLog(g_tmp);

    // ── 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
    // HPLATRESET — zero all watchdog-hunt latency maxima for a fresh window
    if (strFind(cmd, "LATRESET") == 0 && strlen(cmd) == 8) {
        cb_50_max_ms = 0; cb_sec_max_ms = 0; cb_web_max_ms = 0;
        cb_json_max_ms = 0; mail_max_ms = 0; mb_max_ms = 0;
        tick_gap_max_ms = 0; long_call_warns = 0;
        e50_bytes_max = 0;
        responseCmnd("LAT maxima reset");
        return;
    }
    // HPLAT — print one-line latency snapshot to console + responseCmnd
    if (strFind(cmd, "LAT") == 0 && strlen(cmd) == 3) {
        sprintf(g_resp, "gap=%d e50=%d e50b=%d e1s=%d web=%d json=%d mb=%d/%d mail=%d/%d warns=%d wdog=%d/%d",
                tick_gap_max_ms, cb_50_max_ms, e50_bytes_max,
                cb_sec_max_ms, cb_web_max_ms,
                cb_json_max_ms, mb_max_ms, mb_calls, mail_max_ms, mail_calls,
                long_call_warns, wdog_loop_enable, wdog_loop_trips);
        responseCmnd(g_resp);
        return;
    }
    // MBUSHIST — dump the wdog trip ring buffer (last up-to-8 freezes).
    // For each slot, prints freeze-start unix time + uptime-into-boot +
    // freeze duration (ms) + which callback was running + peak callback
    // durations at trip time. Most-recent first.
    //
    // Output goes BOTH via responseCmnd (for /cm HTTP API consumers)
    // AND via addLog (for the web console at /cs). The /cm response
    // is built into hbuf — capped at 700 bytes for Tasmota's MQTT
    // payload limit, so on a heavily-tripped buffer the older slots
    // may truncate; the addLog calls always produce the full set.
    if (strFind(cmd, "HIST") == 0 && strlen(cmd) == 4) {
        char hbuf[720];
        // Header line including raw idx + trips so we can distinguish
        // "no trips recorded" (idx=0, trips=0) from "persist failed to
        // load" (would be ambiguous without this) and from "trip just
        // happened, idx=1, trips=1".
        sprintf(hbuf, "freeze history (idx=%d, trips=%d):\n",
                wdog_hist_idx, wdog_loop_trips);
        // Walk backwards from idx-1 (newest) through 8 slots
        for (int n = 0; n < WDOG_HIST; n = n + 1) {
            int slot = (wdog_hist_idx - 1 - n) & 7;
            int unix_t   = wdog_hist[slot].freeze_unix;
            int silent_m = wdog_hist[slot].silent_ms;
            int uptime_s = wdog_hist[slot].uptime_s;
            if (unix_t == 0 && silent_m == 0) continue;     // unused slot
            int cb       = wdog_hist[slot].active_cb;
            // Map cb_id to a readable name (kept short for line budget)
            char cbname[12];
            if (cb == 1) strcpy(cbname, "Every50ms");
            else if (cb == 2) strcpy(cbname, "EverySec");
            else if (cb == 3) strcpy(cbname, "WebCall");
            else if (cb == 4) strcpy(cbname, "JsonCall");
            else if (cb == 5) strcpy(cbname, "Command");
            else if (cb == 0) strcpy(cbname, "idle/other");
            else              strcpy(cbname, "?");
            // tlms = "TaskLoop ms ago" at trip; small = TaskLoop healthy
            // (freeze is in Tasmota), large = TaskLoop itself was stuck.
            sprintf(g_resp,
                "  [%d] up=%ds sil=%dms tlms=%d in=%s e50=%d e1s=%d web=%d json=%d mb=%d",
                n, uptime_s, silent_m, wdog_hist[slot].taskloop_ms, cbname,
                wdog_hist[slot].e50_max, wdog_hist[slot].e1s_max,
                wdog_hist[slot].web_max, wdog_hist[slot].json_max,
                wdog_hist[slot].mb_max);
            addLog(g_resp);
            // Also append to /cm response (truncates older slots if oversized)
            if (strlen(hbuf) + strlen(g_resp) + 2 < 700) {
                strcat(hbuf, g_resp);
                strcat(hbuf, "\n");
            }
        }
        sprintf(g_tmp, "(total trips since deploy: %d)", wdog_loop_trips);
        if (strlen(hbuf) + strlen(g_tmp) + 1 < 720) strcat(hbuf, g_tmp);
        responseCmnd(hbuf);
        return;
    }
    // MBUSWDOG ON|OFF — toggle the loop-task watchdog (TaskLoop-driven
    // emergency restart when loop task is silent for 60 s). Disable
    // temporarily during long debug ops you don't want to be killed by
    // the watchdog.
    if (strFind(cmd, "WDOG") == 0) {
        if (strFind(cmd, "WDOG ON") == 0) {
            wdog_loop_enable = 1;
            responseCmnd("WDOG enabled");
            return;
        } else if (strFind(cmd, "WDOG OFF") == 0) {
            wdog_loop_enable = 0;
            responseCmnd("WDOG disabled");
            return;
        } else if (strlen(cmd) == 4) {
            char wlabel[8];
            if (wdog_loop_enable) strcpy(wlabel, "ON");
            else                  strcpy(wlabel, "OFF");
            int silent = -1;
            if (last_50ms_run_ms > 0) silent = millis() - last_50ms_run_ms;
            // wdog_last_trip_uptime is from the BOOT THAT TRIPPED, so
            // showing seconds-into-boot tells us "how long was the
            // device alive before the trip" historically.
            sprintf(g_resp, "WDOG %s, trips=%d, current_silent_ms=%d, last_trip: silent=%d ms after %d s of uptime",
                    wlabel, wdog_loop_trips, silent,
                    wdog_last_silent_ms, wdog_last_trip_uptime);
            responseCmnd(g_resp);
            return;
        }
    }

    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.
        if (strlen(wd_email_to) == 0) {
            responseCmnd("MAILTEST: NO RECIPIENT SET — use MBUSWD MAIL <addr> first");
        } else {
            wd_send_alert("MAILTEST");
            sprintf(g_resp, "MAILTEST: dispatched to %s", wd_email_to);
            responseCmnd(g_resp);
        }

    } 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[idx].ms) / 1000;
                char src_label[12];
                if (wlog[idx].src == '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[idx].addr, wlog[idx].addr,
                        wlog[idx].val, wlog[idx].val, 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() {
    // Plain main() — no startup delay needed since TinyC 1.3.36.
    // The runtime defers autoexec slot spawn from FUNC_INIT to a
    // FUNC_LOOP iteration gated on TasmotaGlobal.uptime ≥ 3 s, so
    // by the time main() runs Tasmota's Wi-Fi / RF coex / late
    // driver init has fully settled. `serialBegin` here works the
    // same as in any in-tree Tasmota driver — no race, no
    // `delay(15000)` workaround, no separate BootInit hook needed.
    addLog("heatpump_map: opening serial at uptime %d s", tasm_uptime);

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

    // Restore chart history from /heatpump_map.charts. Independent of
    // TinyC's persist mechanism, so it survives unrelated persist-var
    // edits (the persist file's layout-hash invalidation kept blowing
    // away hours of data on every script edit). See save_charts /
    // load_charts comments for the on-disk format.
    load_charts();

    // 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;
}