heatpump_map_full.tc¶
heatpump_map.tc — Modbus-RTU sniffer + control for chinese heat pumps
// =================================================================
// 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 °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 °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 °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 °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 °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 °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 °C{e}", wtemp);
webSend(g_row);
sprintf(g_row, "{s}Schlafzimmer{m}%.1f °C{e}", aztemp);
webSend(g_row);
sprintf(g_row, "{s}Keller{m}%.1f °C{e}", ktmp);
webSend(g_row);
sprintf(g_row, "{s}Außen (Bresser){m}%.1f °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 °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 °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 °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 °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 °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 <small>addr %d–%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>Δ 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 → %d (%+d)</b>", snap[a] & 0xFFFF, v, delta);
strcpy(rowcls, " class='hpm-chg'");
} else {
strcpy(g_dcell, "·");
}
} else {
strcpy(g_dcell, "–");
}
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>🔥 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> last: addr <b>%d (0x%04x)</b> val <b>%d</b> %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>🔥 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;
}