heatpump_map.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 = 4;
int tx_pin = 5;
int baud = 19200;
int cfg = 7; // 8N2
int silence_ms = 3; // inter-frame gap
int ser = -1;
int log_on = 0; // raw frame logging — OFF by default (every cycle
// would otherwise spam ~10 lines/sec). Use
// MBUSCAPTURE for a brief window, or MBUSLOG ON
// for a permanent toggle.
int log_until_ms = 0; // millis() target at which a temporary capture
// turns log_on back to 0; 0 = no auto-off
// Frame buffer
char buf[260];
int buf_len = 0;
int last_ms = 0;
// ── Shared scratch buffers for hot callbacks ──
// Hoisted to globals so we don't burn a heap handle per call. The auto-heap
// threshold is 16 elements; anything bigger uses a handle, and we have a
// hard cap of 128. WebCall fires every ~2 s, JsonCall every TelePeriod,
// flush_frame multiple times per second — those allocations add up fast.
char g_row[320]; // WebCall + render_named + render_block row builder
char g_buf[260]; // JsonCall row builder
char g_resp[140]; // Command response builder
char g_hex[140]; // bin2hex output (covers up to 64 input bytes ×2 + NUL)
char g_tmp[80]; // generic short-lived helper
char g_dcell[64]; // diff cell for render_block (was per-iteration leak)
char g_hdr[120]; // block header for render_block
// ── Register store ─────────────────────────────────────────
// 500 covers 0..338 + 432..493 with headroom.
int reg[500];
int known[500];
int snap[500];
int snapped[500]; // 1 if this slot has a snapshot baseline
// Counters
int frames_seen = 0;
int bytes_seen = 0;
int rsp_parsed = 0;
int rsp_failed = 0;
int registers_known = 0;
int last_block_ms = 0;
// FC06 / FC16 write capture — when cloud (or panel) writes to the heat pump,
// the register address tells us what control we'd target ourselves.
int writes_seen = 0;
int last_write_addr = -1;
int last_write_val = 0;
int last_write_ms = 0;
// Circular buffer of last 16 write events (own + observed). Small footprint
// (16 × 3 ints = ~192 B), enough to catch a complete cloud on/off interaction.
int wlog_addr[16];
int wlog_val[16];
int wlog_ms[16];
char wlog_src[16]; // 'O' = observed (cloud/external), 'M' = me (this script's send)
int wlog_pos = 0; // next slot to write
int wlog_count = 0;
// ── UDP-shared globals from other Tasmota devices ────────────
// `global float` = auto-syncs via UDP multicast 239.255.255.250:1999.
// Initial values are sane defaults until first packet arrives. Names must
// match exactly what the publishing devices send.
global float aztemp = 20.0; // Schlafzimmer (sleeping room)
global float wtemp = 20.0; // Wohnzimmer (living room)
global float ktmp = 14.0; // Keller (cellar — typically colder)
global float rtemp = 10.0; // Außentemperatur (Bresser sensor)
// ── Watchdog: cycle the heat pump if it's stuck in winter ────
// 2-3 times a year the pump locks up and won't restart on its own. If
// that happens while you're traveling the house cools down. The
// watchdog correlates 4 independent signals to detect "should be heating
// but isn't", then automatically does an off/on cycle to recover.
//
// All 6 thresholds + enable + grace-min are persist'd so they survive
// reboots (and your tuning experiments). Adjust via console:
// MBUSWD ON|OFF enable / disable the whole watchdog
// MBUSWD STAT show state + next-action time
// MBUSWD TEST simulate a trigger (skip grace period)
persist int wd_enable = 1;
persist float wd_room_min = 17.0; // °C — heated rooms must drop below
persist float wd_cellar_min = 14.0; // °C — cellar (different baseline)
persist float wd_outside_max = 5.0; // °C — only watch when actually cold
persist float wd_ausgang_min = 25.0; // °C — pump output must be at least this when running
persist int wd_grace_min = 15; // minutes of sustained alert before action
persist int wd_cooldown_min = 60; // minutes of silence after a recovery cycle
persist char wd_email_to[64]; // recipient — empty means email disabled
// Watchdog state machine (in-RAM, resets on reboot — that's fine)
int wd_state = 0; // 0=monitor 1=alerted 2=just-sent-OFF 3=cooldown
int wd_alert_started_ms = 0;
int wd_off_sent_ms = 0;
int wd_cooldown_until_ms = 0;
int wd_trigger_count = 0; // total times watchdog fired this boot
int wd_last_trigger_ms = 0; // timestamp of previous trigger (escalation gate)
int wd_test_active = 0; // 1 = next cooldown is shortened (test mode)
void wlog_push(int addr, int val, char src) {
int p = wlog_pos;
wlog_addr[p] = addr;
wlog_val[p] = val;
wlog_ms[p] = millis();
wlog_src[p] = src;
wlog_pos = (p + 1) % 16;
if (wlog_count < 16) wlog_count = wlog_count + 1;
}
// Track the most recent REQ so we can pair it with a lone RSP that arrives
// in the next captured frame. Most cloud polls land as REQ+RSP merged in
// one buffer, but occasionally the silence detector splits them.
int last_req_addr = -1;
int last_req_qty = 0;
// ------------------------------------------------------------
// Store a register reading; track first-seen and changes.
// ------------------------------------------------------------
void store_reg(int addr, int val) {
if (addr < 0 || addr >= 500) return;
if (known[addr] == 0) {
known[addr] = 1;
registers_known = registers_known + 1;
}
reg[addr] = val;
}
// ------------------------------------------------------------
// Parse one frame into register store. Handles three shapes:
// A) Lone 8-byte REQ — record addr/qty for the next RSP
// B) Lone RSP (slave fc bc data crc) — paired with last_req
// C) Merged REQ+RSP (8 + 5+2N bytes)
// All other frames are ignored (probably noise / collisions /
// non-FC03 frames we don't care about for mapping).
// ------------------------------------------------------------
void parse_frame() {
if (buf_len < 5) return;
int sl = buf[0] & 0xFF;
int fc = buf[1] & 0xFF;
// ── Scan the WHOLE buffer for any embedded WRITE function code ──
// FC05 = write single coil (8 bytes: 01 05 addr_hi addr_lo val_hi val_lo crc crc;
// val=FF00 means ON, 0000 means OFF)
// FC06 = write single register (8 bytes: 01 06 addr_hi addr_lo val_hi val_lo crc crc)
// FC15 = write multiple coils (>=9 bytes: 01 0F addr ... bc data crc)
// FC16 = write multiple regs (>=9 bytes: 01 10 addr ... bc data crc)
//
// Walking the buffer catches writes embedded within merged frames
// (cloud often sends an FC06 write within <4 ms of a prior FC03
// poll tail).
for (int i = 0; i + 8 <= buf_len; i++) {
int s2 = buf[i] & 0xFF;
int f2 = buf[i+1] & 0xFF;
if (s2 != 1) continue; // only slave 1 commands matter
if (f2 == 0x05) {
// FC05: write single coil
int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
int wval = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
// Sanity: coil addr typically < 1000, val must be 0xFF00 or 0x0000
if (waddr < 1000 && (wval == 0xFF00 || wval == 0x0000)) {
last_write_addr = waddr;
last_write_val = wval;
last_write_ms = millis();
writes_seen = writes_seen + 1;
wlog_push(waddr, wval, 'O');
char w[160];
char st[8];
if (wval == 0xFF00) strcpy(st, "ON"); else strcpy(st, "OFF");
sprintf(w, "*** WRITE FC05 (coil) *** addr=%d (0x%04x) → %s",
waddr, waddr, st);
addLog(w);
}
} else if (f2 == 0x06) {
int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
int wval = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
if (waddr < 1000) {
last_write_addr = waddr;
last_write_val = wval;
last_write_ms = millis();
writes_seen = writes_seen + 1;
wlog_push(waddr, wval, 'O');
char w[160];
sprintf(w, "*** WRITE FC06 (reg) *** addr=%d (0x%04x) val=%d (0x%04x)",
waddr, waddr, wval, wval);
addLog(w);
}
} else if (f2 == 0x0F && i + 9 <= buf_len) {
// FC15: write multiple coils
int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
int wqty = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
if (waddr < 1000 && wqty > 0 && wqty < 256) {
last_write_addr = waddr;
last_write_ms = millis();
writes_seen = writes_seen + 1;
wlog_push(waddr, wqty, 'O');
char w[160];
sprintf(w, "*** WRITE FC15 (coils) *** addr=%d qty=%d",
waddr, wqty);
addLog(w);
}
} else if (f2 == 0x10 && i + 9 <= buf_len) {
int waddr = ((buf[i+2] & 0xFF) << 8) | (buf[i+3] & 0xFF);
int wqty = ((buf[i+4] & 0xFF) << 8) | (buf[i+5] & 0xFF);
if (waddr < 1000 && wqty > 0 && wqty < 32) {
last_write_addr = waddr;
last_write_ms = millis();
writes_seen = writes_seen + 1;
wlog_push(waddr, wqty, 'O');
char w[160];
sprintf(w, "*** WRITE FC16 (regs) *** addr=%d qty=%d",
waddr, wqty);
addLog(w);
}
}
}
if (sl != 1 || fc != 0x03) return; // only slave 1 FC03 carries our data
// Case A: lone REQ
if (buf_len == 8) {
last_req_addr = ((buf[2] & 0xFF) << 8) | (buf[3] & 0xFF);
last_req_qty = ((buf[4] & 0xFF) << 8) | (buf[5] & 0xFF);
return;
}
// Case C: merged REQ + RSP — start with REQ
if (buf_len >= 15) {
int req_addr = ((buf[2] & 0xFF) << 8) | (buf[3] & 0xFF);
int req_qty = ((buf[4] & 0xFF) << 8) | (buf[5] & 0xFF);
int rsp_off = 8;
if ((buf[rsp_off] & 0xFF) == 1 && (buf[rsp_off+1] & 0xFF) == 0x03) {
int bc = buf[rsp_off+2] & 0xFF;
if (bc == req_qty * 2 && buf_len >= rsp_off + 3 + bc) {
// Valid merged frame; extract qty registers
for (int r = 0; r < req_qty; r++) {
int hi = buf[rsp_off + 3 + r*2] & 0xFF;
int lo = buf[rsp_off + 4 + r*2] & 0xFF;
store_reg(req_addr + r, (hi << 8) | lo);
}
rsp_parsed = rsp_parsed + 1;
last_block_ms = millis();
return;
}
}
}
// Case B: lone RSP — pair with most recent REQ
if (last_req_addr >= 0 && buf_len >= 5) {
int bc = buf[2] & 0xFF;
if (bc == last_req_qty * 2 && buf_len >= 3 + bc) {
for (int r = 0; r < last_req_qty; r++) {
int hi = buf[3 + r*2] & 0xFF;
int lo = buf[4 + r*2] & 0xFF;
store_reg(last_req_addr + r, (hi << 8) | lo);
}
rsp_parsed = rsp_parsed + 1;
last_block_ms = millis();
last_req_addr = -1; // consume
return;
}
}
rsp_failed = rsp_failed + 1;
}
// ------------------------------------------------------------
// Flush completed frame: log raw (if enabled) + parse into store
// ------------------------------------------------------------
void flush_frame() {
if (buf_len == 0) return;
bytes_seen = bytes_seen + buf_len;
frames_seen = frames_seen + 1;
parse_frame();
if (log_on) {
int n = buf_len;
if (n > 64) n = 64;
bin2hex(buf, n, g_hex);
sprintf(g_row, "MB f#%d %dB hex=%s", frames_seen, buf_len, g_hex);
addLog(g_row);
}
}
// ------------------------------------------------------------
// Tick-based UART drain (50 ms cadence).
//
// Why a tick rather than TaskLoop / spawnTask:
// The cloud's polling cycle is ~8 s between bursts; each burst itself
// is a few-millisecond burst followed by silence. So if we get ZERO
// new bytes in a 50 ms tick AND have data buffered, we're in
// inter-frame silence — flush the frame.
//
// This also means we never hold the VM mutex for long, so WebCall /
// JsonCall / Command callbacks always get a window — no flicker on
// the main page.
//
// At 19200 8N2, max ~95 bytes can arrive in 50 ms; TasmotaSerial's
// 1 KB UART RX buffer handles that easily.
// ------------------------------------------------------------
void Every50ms() {
if (ser < 0) return;
// Auto-disable temporary capture window
if (log_until_ms > 0 && millis() >= log_until_ms) {
log_on = 0;
log_until_ms = 0;
addLog("HP: capture window ended, frame logging OFF");
}
int avail = serialAvailable(ser);
int got = 0;
int b;
while (avail > 0) {
b = serialRead(ser);
if (b < 0) break;
if (buf_len < 256) {
buf[buf_len] = b;
buf_len = buf_len + 1;
}
avail = avail - 1;
got = got + 1;
if (buf_len >= 256) break;
}
if (got == 0 && buf_len > 0) {
// No new bytes this tick + we have data → end of frame
flush_frame();
buf_len = 0;
}
}
// ------------------------------------------------------------
// Snapshot current registers as baseline (for diff highlighting)
// ------------------------------------------------------------
void take_snapshot() {
int n = 0;
for (int i = 0; i < 500; i++) {
if (known[i]) {
snap[i] = reg[i];
snapped[i] = 1;
n = n + 1;
}
}
char m[80];
sprintf(m, "snapshot taken: %d registers", n);
addLog(m);
}
// ------------------------------------------------------------
// Clear snapshot diffs (so highlighted rows return to normal)
// ------------------------------------------------------------
void clear_snapshot() {
for (int i = 0; i < 500; i++) snapped[i] = 0;
addLog("snapshot cleared");
}
// ------------------------------------------------------------
// WebUI form button state
// ------------------------------------------------------------
int snap_btn = 0;
int clear_btn = 0;
int prev_snap = 0;
int prev_clear = 0;
int hp_toggle_btn = 0; // single toggle button — label flips with state
int prev_hp_toggle = 0;
// ------------------------------------------------------------
// Modbus CRC16 (poly 0xA001) for outgoing FC06 frames.
// ------------------------------------------------------------
int modbus_crc16(char b[], int len) {
int crc = 0xFFFF;
for (int i = 0; i < len; i++) {
crc = crc ^ (b[i] & 0xFF);
for (int j = 0; j < 8; j++) {
if (crc & 1) crc = (crc >> 1) ^ 0xA001;
else crc = (crc >> 1);
}
}
return crc & 0xFFFF;
}
// ------------------------------------------------------------
// Send FC06 "write single holding register" via the EW11 Socket B.
// Returns 1 on send-OK (no response confirmation; the sniffer's parser
// will see the actual write on the bus + any reply, providing a free
// out-of-band check).
// ------------------------------------------------------------
// Architecture: sniffer ESP is fully self-contained. EW11 stays on the bus
// for the cloud's polling traffic (we don't touch its config), but we never
// talk to it ourselves — neither for reads (passive RS485 tap covers that)
// nor for writes (direct UART via auto-direction RS485 covers that). This
// keeps a single dependency: just the wire tap. The earlier dual-path
// attempt (UART vs EW11 Socket B) was abandoned because EW11 doesn't relay
// pump responses back to its TCP-Server sockets — verification would have
// required the sniffer anyway.
// fc6=0x06 to write a holding register, fc5=0x05 to write a single coil.
// Same 8-byte wire format, just different fc byte.
int hp_send_modbus(int fc, int regaddr, int val) {
// Build the 8-byte FC05 / FC06 frame
char frame[8];
frame[0] = 1; // slave id
frame[1] = fc & 0xFF; // function code
frame[2] = (regaddr >> 8) & 0xFF;
frame[3] = regaddr & 0xFF;
frame[4] = (val >> 8) & 0xFF;
frame[5] = val & 0xFF;
int crc = modbus_crc16(frame, 6);
frame[6] = crc & 0xFF; // CRC low byte first (Modbus LE)
frame[7] = (crc >> 8) & 0xFF;
// ── Direct UART send via the auto-direction RS485 module ──
if (ser < 0) {
addLog("hp_send_modbus: serial not open");
return 0;
}
// Listen-before-talk: wait for ≥4 ms bus silence before TX.
// Bus has a 1 Hz cadence (cloud polls one block per second; each
// burst is ~50-100 ms followed by ~900 ms of silence). 4 ms is the
// Modbus standard 3.5-char inter-frame gap at 19200 baud + margin.
// Cap the wait at 1500 ms to be sure we catch a quiet window even
// mid-burst.
int q_start = millis();
int wait_cap = millis() + 1500;
while (millis() < wait_cap) {
if (serialAvailable(ser) > 0) {
// Drain — DON'T parse here, let Every50ms framer do it.
// We just need to know the bus had activity to reset the
// silence timer.
while (serialAvailable(ser) > 0) {
int discard = serialRead(ser);
if (buf_len < 256) { buf[buf_len] = discard; buf_len = buf_len + 1; }
}
q_start = millis();
}
if (millis() - q_start >= 4) break;
delay(1);
}
// Send the 8 bytes. The auto-direction RS485 module enables its
// driver while bytes flow on TX and switches back to RX afterward.
for (int i = 0; i < 8; i++) {
serialWriteByte(ser, frame[i] & 0xFF);
}
// Wait for TX FIFO to drain + Modbus inter-frame gap so the pump
// recognises the frame end (8 B × ~0.6 ms = ~5 ms + 5 ms margin).
delay(10);
wlog_push(regaddr, val, 'M');
char m[120];
sprintf(m, "hp_send_modbus: fc=%02x reg=%d val=%d (0x%04x)",
fc, regaddr, val, val);
addLog(m);
return 1;
}
// ------------------------------------------------------------
// Watchdog email alert — uses Tasmota's native mailSend (USE_SENDMAIL).
// Recipient comes from the persist'd `wd_email_to` setting; SMTP server,
// port, user, password, from-address are taken from Tasmota's device-wide
// SmtpHost/SmtpUser/SmtpPwd/SmtpFrom config (the "*" placeholders).
// Set the recipient via console: MBUSWD MAIL <user@example.com>
// Empty recipient → no email sent (silent fallback).
// ------------------------------------------------------------
void wd_send_alert(char subj_part[]) {
if (strlen(wd_email_to) == 0) return; // not configured
// Snapshot current readings so the email captures the moment
int u; int s;
float ausgang_c = 0.0;
float puffer_c = 0.0;
if (known[191]) { u = reg[191] & 0xFFFF; s = u; if (s >= 32768) s -= 65536; ausgang_c = s / 10.0; }
if (known[188]) { u = reg[188] & 0xFFFF; s = u; if (s >= 32768) s -= 65536; puffer_c = s / 10.0; }
// Reuse g_row (320) for body, g_resp (140) for params — both larger
// than what we need, no extra heap handles burned.
sprintf(g_row, "HP watchdog: %s<br>Trip count: %d<br><br>HP-Ausgang: %.1f C<br>HP-Puffer: %.1f C<br>Wohnzimmer: %.1f C<br>Schlafzimmer: %.1f C<br>Keller: %.1f C<br>Aussen (Bresser): %.1f C<br>",
subj_part, wd_trigger_count,
ausgang_c, puffer_c, wtemp, aztemp, ktmp, rtemp);
mailBody(g_row);
sprintf(g_resp, "[*:*:*:*:*:%s:HP Watchdog %s]", wd_email_to, subj_part);
mailSend(g_resp);
addLog("HP WATCHDOG: email alert dispatched");
}
// ------------------------------------------------------------
// Tasmota main-page sensor rows. Outputs "{s}label{m}value{e}" lines
// which the main web view renders as a clean sensor table.
// ------------------------------------------------------------
void WebCall() {
// Use global g_row instead of `char row[200]` (heap handle leak fix).
// Tiny scratch strings (<=16) stay local since they don't use a handle.
char st[16];
if (known[217]) {
int v = reg[217] & 0xFFFF;
if (v == 0) strcpy(st, "booting");
else if (v == 1) strcpy(st, "RUNNING");
else if (v == 6) strcpy(st, "OFF");
else sprintf(st, "?(%d)", v);
sprintf(g_row, "{s}HP Power{m}<b>%s</b>{e}", st);
webSend(g_row);
}
// Target temp setpoint (×10 °C, signed)
if (known[1]) {
int u = reg[1] & 0xFFFF;
int s = u; if (s >= 32768) s = s - 65536;
sprintf(g_row, "{s}HP Zieltemp{m}%.1f °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);
// ── Watchdog status row ──
if (wd_enable) {
char wst[40];
if (wd_state == 0) strcpy(wst, "monitor");
else if (wd_state == 1) {
int mins = (millis() - wd_alert_started_ms) / 60000;
sprintf(wst, "ALERT %d min", mins);
}
else if (wd_state == 2) strcpy(wst, "cycling OFF");
else if (wd_state == 3) {
int mins_left = (wd_cooldown_until_ms - millis()) / 60000;
sprintf(wst, "cooldown %d min", mins_left);
}
else strcpy(wst, "?");
sprintf(g_row, "{s}HP Watchdog{m}%s (%d trips){e}", wst, wd_trigger_count);
webSend(g_row);
}
}
// ------------------------------------------------------------
// JSON telemetry — for MQTT teleperiod publishing. Same data,
// machine-readable shape.
// ------------------------------------------------------------
void JsonCall() {
if (registers_known < 5) return; // not warmed up yet
// using global g_buf
char sep[2];
int n = 0;
int u = 0;
int s = 0;
strcpy(sep, "");
responseAppend(",\"HP\":{");
if (known[217]) {
sprintf(g_buf, "%s\"State\":%d", sep, reg[217] & 0xFFFF);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[1]) {
u = reg[1] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Target\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[188]) {
u = reg[188] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Puffer\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[191]) {
u = reg[191] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Ausgang\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[192]) {
u = reg[192] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Ansauggas\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[206]) {
u = reg[206] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Verdampfer\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[190]) {
u = reg[190] & 0xFFFF; s = u; if (s >= 32768) s -= 65536;
sprintf(g_buf, "%s\"Aussen\":%.1f", sep, s / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[193]) {
sprintf(g_buf, "%s\"SuctPress\":%.1f", sep, (reg[193] & 0xFFFF) / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
if (known[194]) {
sprintf(g_buf, "%s\"DischPress\":%.1f", sep, (reg[194] & 0xFFFF) / 10.0);
responseAppend(g_buf); n = 1; strcpy(sep, ",");
}
// UDP-shared room temps — useful in the same JSON for HA / dashboards
sprintf(g_buf, "%s\"Wohnzimmer\":%.1f", sep, wtemp); responseAppend(g_buf); strcpy(sep, ",");
sprintf(g_buf, "%s\"Schlafzimmer\":%.1f", sep, aztemp); responseAppend(g_buf);
sprintf(g_buf, "%s\"Keller\":%.1f", sep, ktmp); responseAppend(g_buf);
sprintf(g_buf, "%s\"Aussen2\":%.1f", sep, rtemp); responseAppend(g_buf);
// Watchdog status — primary alert channel via teleperiod MQTT
sprintf(g_buf, "%s\"WdState\":%d,\"WdTrips\":%d", sep, wd_state, wd_trigger_count);
responseAppend(g_buf);
responseAppend("}");
}
// ------------------------------------------------------------
// Render the headline "labeled values" panel — what the user actually
// cares about. Strong-confidence mappings only.
// ------------------------------------------------------------
void render_named() {
// Use hoisted globals where possible (g_row, g_tmp). pwr/pcls are
// ≤16 chars so they stay on the stack with no heap handle.
char pwr[16];
char pcls[16];
// Power state — r217 is enum: 0=booting, 1=running, 6=off
int pst = -1;
if (known[217]) {
pst = reg[217] & 0xFFFF;
if (pst == 0) strcpy(pwr, "booting");
else if (pst == 1) strcpy(pwr, "RUNNING");
else if (pst == 6) strcpy(pwr, "OFF");
else sprintf(pwr, "?(%d)", pst);
} else {
strcpy(pwr, "(unknown)");
}
if (pst == 1) strcpy(pcls, "hpm-pwr-on");
else if (pst == 6) strcpy(pcls, "hpm-pwr-off");
else strcpy(pcls, "hpm-pwr-other");
webSend("<table class='hpm-named'>");
// Power + setpoint — top row
sprintf(g_row, "<tr><th>Power state</th><td class='%s'><b>%s</b></td><td class='reg'>r217 = %d</td></tr>",
pcls, pwr, pst);
webSend(g_row);
if (known[1]) {
float v = (reg[1] & 0xFFFF) / 10.0;
sprintf(g_row, "<tr><th>Zieltemp (target)</th><td><b>%.1f °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() {
if (snap_btn == 1 && prev_snap == 0) {
take_snapshot();
snap_btn = 0;
}
prev_snap = snap_btn;
if (clear_btn == 1 && prev_clear == 0) {
clear_snapshot();
clear_btn = 0;
}
prev_clear = clear_btn;
// Pump toggle — single button. Decide direction based on current state:
// running (1) → click = OFF (0x0000)
// off (6) → click = ON (0xFF00)
// booting (0) / unknown → default to ON (idempotent if already on)
if (hp_toggle_btn == 1 && prev_hp_toggle == 0) {
int cur = 0;
if (known[217]) cur = reg[217] & 0xFFFF;
if (cur == 1) {
addLog("HP: web button → Pump OFF");
hp_send_modbus(0x05, 40, 0x0000);
} else {
addLog("HP: web button → Pump ON");
hp_send_modbus(0x05, 40, 0xFF00);
}
hp_toggle_btn = 0;
}
prev_hp_toggle = hp_toggle_btn;
// ── Watchdog state machine ──
// Runs every second; transitions are millis-driven so a missed tick
// is harmless (next tick catches up). All actions are non-blocking;
// the OFF→ON gap and the cooldown both use millis() comparisons.
if (wd_enable) {
int now = millis();
// State 3: cooldown (silent after a recovery cycle)
if (wd_state == 3) {
if (now >= wd_cooldown_until_ms) {
wd_state = 0;
addLog("HP WATCHDOG: cooldown ended, monitoring resumed");
}
}
// State 2: just sent OFF, send ON 30 s later
else if (wd_state == 2) {
if (now - wd_off_sent_ms >= 30000) {
hp_send_modbus(0x05, 40, 0xFF00);
addLog("HP WATCHDOG: cycle ON sent (FC05 coil 40 = FF00)");
wd_state = 3;
if (wd_test_active) {
// Test mode — 30 s cooldown so we're back to monitor
// quickly. No real-life consequences since this was
// an artificial trigger.
wd_cooldown_until_ms = now + 30000;
wd_test_active = 0;
addLog("HP WATCHDOG: TEST cooldown 30s (would normally be wd_cooldown_min)");
} else {
wd_cooldown_until_ms = now + (wd_cooldown_min * 60000);
}
}
}
// States 0/1: monitor + alert. Need the named registers populated.
else if (known[217] && known[191]) {
int hp_state = reg[217] & 0xFFFF;
int u = reg[191] & 0xFFFF;
int s = u; if (s >= 32768) s = s - 65536;
float ausgang_c = s / 10.0;
// All trigger conditions must hold simultaneously
int conds = 0;
if (hp_state == 1 &&
rtemp < wd_outside_max &&
ausgang_c < wd_ausgang_min &&
aztemp < wd_room_min &&
wtemp < wd_room_min &&
ktmp < wd_cellar_min) {
conds = 1;
}
if (wd_state == 0) {
if (conds) {
wd_state = 1;
wd_alert_started_ms = now;
addLog("HP WATCHDOG: alert started — all signals say pump is stuck");
}
} else if (wd_state == 1) {
if (!conds) {
wd_state = 0;
addLog("HP WATCHDOG: alert cleared (conditions normalised)");
} else if (now - wd_alert_started_ms >= wd_grace_min * 60000) {
// Grace expired — cycle the pump
hp_send_modbus(0x05, 40, 0x0000);
addLog("HP WATCHDOG: TRIGGERED — cycle OFF sent (FC05 coil 40 = 0000)");
wd_state = 2;
wd_off_sent_ms = now;
wd_trigger_count = wd_trigger_count + 1;
// Escalation: if a previous trigger was recent (within
// 3× cooldown_min), the previous recovery cycle clearly
// didn't help — send the URGENT email instead of the
// routine "cycle triggered" one.
int rapid_repeat = 0;
if (wd_last_trigger_ms > 0 &&
(now - wd_last_trigger_ms) < (wd_cooldown_min * 60000 * 3)) {
rapid_repeat = 1;
}
wd_last_trigger_ms = now;
if (rapid_repeat) {
addLog("HP WATCHDOG: ESCALATION — previous cycle did not help, pump may need physical reset");
wd_send_alert("URGENT: pump stuck, recovery failed");
} else {
wd_send_alert("auto-recovery cycle triggered");
}
}
}
}
}
}
// ------------------------------------------------------------
// Console commands
// ------------------------------------------------------------
void Command(char cmd[]) {
// using global g_resp
// ── HEAT-PUMP CONTROL ──
// Coil 40 (FC05) is the actual on/off switch — discovered by sniffing the
// cloud's commands. r217 is just a read-only status reflection.
// coil 40 = 0xFF00 → pump ON
// coil 40 = 0x0000 → pump OFF
if (strFind(cmd, "ON") == 0 && strlen(cmd) == 2) {
if (hp_send_modbus(0x05, 40, 0xFF00)) {
responseCmnd("HP ON sent (FC05 coil 40 = 0xFF00)");
} else {
responseCmnd("HP ON FAILED");
}
} else if (strFind(cmd, "OFF") == 0 && strlen(cmd) == 3) {
if (hp_send_modbus(0x05, 40, 0x0000)) {
responseCmnd("HP OFF sent (FC05 coil 40 = 0x0000)");
} else {
responseCmnd("HP OFF FAILED");
}
} else if (strFind(cmd, "SET ") == 0) {
// HPSET <decimal °C> — write target water temp to r1, scaled ×10
// e.g. "MBUSSET 32" → write 320 to r1 (=32.0 °C)
// TinyC atoi() needs a char[] var (no pointer arithmetic), so copy
// the digits after "SET " into a small buffer first.
char arg[16];
int n = strlen(cmd);
int j = 0;
for (int i = 4; i < n; i++) {
if (j < 15) {
arg[j] = cmd[i];
j = j + 1;
}
}
arg[j] = 0;
int t10 = atoi(arg) * 10;
if (t10 < 100 || t10 > 600) {
sprintf(g_resp, "HPSET: out of range (10..60 °C), got %d", t10/10);
responseCmnd(g_resp);
} else if (hp_send_modbus(0x06, 1, t10)) {
sprintf(g_resp, "HP target %d.0 °C sent (FC06 r1=%d)", t10/10, t10);
responseCmnd(g_resp);
} else {
responseCmnd("HPSET FAILED");
}
} else if (strFind(cmd, "WD ON") == 0 && strlen(cmd) == 5) {
wd_enable = 1;
// Reset any leftover state machine residue (e.g. stuck state=2 from
// an interrupted TEST while watchdog was disabled).
wd_state = 0;
wd_alert_started_ms = 0;
wd_off_sent_ms = 0;
wd_cooldown_until_ms = 0;
saveVars();
responseCmnd("watchdog ENABLED (state reset to monitor)");
} else if (strFind(cmd, "WD OFF") == 0 && strlen(cmd) == 6) {
wd_enable = 0; saveVars();
responseCmnd("watchdog DISABLED");
} else if (strFind(cmd, "WD STAT") == 0 && strlen(cmd) == 7) {
char st[24];
if (wd_state == 0) strcpy(st, "monitor");
else if (wd_state == 1) strcpy(st, "alerting");
else if (wd_state == 2) strcpy(st, "cycling-off");
else if (wd_state == 3) strcpy(st, "cooldown");
else strcpy(st, "?");
int now = millis();
int aging = 0;
if (wd_state == 1) aging = (now - wd_alert_started_ms) / 60000;
if (wd_state == 3) aging = (wd_cooldown_until_ms - now) / 60000;
sprintf(g_resp, "wd=%d state=%s timer=%dmin triggers=%d rooms=%.1f/%.1f/%.1f outside=%.1f",
wd_enable, st, aging, wd_trigger_count,
aztemp, wtemp, ktmp, rtemp);
responseCmnd(g_resp);
} else if (strFind(cmd, "WD TEST URGENT") == 0 && strlen(cmd) == 14) {
// End-to-end self-test simulating an ESCALATION (rapid repeat trigger
// means the previous recovery cycle didn't help). Sends URGENT email,
// cycles the pump off/on, uses short cooldown.
addLog("HP WATCHDOG: TEST URGENT — simulating rapid-repeat escalation");
wd_last_trigger_ms = millis() - 60000; // pretend prev trigger was 1 min ago
wd_trigger_count = wd_trigger_count + 1;
wd_send_alert("URGENT: pump stuck, recovery failed (TEST)");
hp_send_modbus(0x05, 40, 0x0000);
addLog("HP WATCHDOG: TEST URGENT — FC05 OFF sent, ON in ~2s via state machine");
wd_state = 2;
wd_off_sent_ms = millis() - 28000; // ON fires 2 s after this tick
wd_test_active = 1; // → 30 s cooldown
responseCmnd("TEST URGENT: URGENT email + cycle off, full recovery in ~32s");
} else if (strFind(cmd, "WD TEST") == 0 && strlen(cmd) == 7) {
// End-to-end self-test of the NORMAL recovery path. Walks through
// exactly what happens at a real trigger: email → cycle OFF → wait
// → cycle ON → cooldown. Cooldown shortened to 30 s for testing.
addLog("HP WATCHDOG: TEST — simulating normal cycle trigger");
wd_trigger_count = wd_trigger_count + 1;
wd_send_alert("auto-recovery cycle triggered (TEST)");
hp_send_modbus(0x05, 40, 0x0000);
addLog("HP WATCHDOG: TEST — FC05 OFF sent, ON in ~2s via state machine");
wd_state = 2;
wd_off_sent_ms = millis() - 28000; // ON fires 2 s after this tick
wd_test_active = 1; // → 30 s cooldown
responseCmnd("TEST: email + cycle off, full recovery in ~32s");
} else if (strFind(cmd, "WD MAIL ") == 0) {
// Set recipient: MBUSWD MAIL <addr> (or 'CLEAR' to disable)
char arg[64];
int n = strlen(cmd);
int j = 0;
for (int i = 8; i < n; i++) {
if (j < 63) { arg[j] = cmd[i]; j = j + 1; }
}
arg[j] = 0;
if (strFind(arg, "CLEAR") == 0 && strlen(arg) == 5) {
strcpy(wd_email_to, "");
saveVars();
responseCmnd("watchdog email recipient cleared (alerts disabled)");
} else {
strcpy(wd_email_to, arg);
saveVars();
sprintf(g_resp, "watchdog email recipient set: %s", wd_email_to);
responseCmnd(g_resp);
}
} else if (strFind(cmd, "WD MAIL") == 0 && strlen(cmd) == 7) {
// Show current recipient
if (strlen(wd_email_to) == 0) {
responseCmnd("watchdog email: not configured (use MBUSWD MAIL <addr>)");
} else {
sprintf(g_resp, "watchdog email recipient: %s", wd_email_to);
responseCmnd(g_resp);
}
} else if (strFind(cmd, "WD MAILTEST") == 0 && strlen(cmd) == 11) {
// Send a test email NOW with current state — proves SMTP works
wd_send_alert("MAILTEST (manual)");
responseCmnd("test email sent (check inbox + log)");
} else if (strFind(cmd, "STAT") == 0 && strlen(cmd) == 4) {
sprintf(g_resp, "frames=%d parsed=%d failed=%d known=%d log=%d writes=%d wd=%d",
frames_seen, rsp_parsed, rsp_failed, registers_known, log_on,
writes_seen, wd_state);
responseCmnd(g_resp);
} else if (strFind(cmd, "SNAP") == 0 && strlen(cmd) == 4) {
take_snapshot();
responseCmnd("snapshot taken");
} else if (strFind(cmd, "CLEAR") == 0 && strlen(cmd) == 5) {
for (int i = 0; i < 500; i++) {
known[i] = 0; reg[i] = 0; snap[i] = 0; snapped[i] = 0;
}
registers_known = 0;
frames_seen = 0; bytes_seen = 0; rsp_parsed = 0; rsp_failed = 0;
responseCmnd("cleared");
} else if (strFind(cmd, "WRITES") == 0 && strlen(cmd) == 6) {
// Dump the circular buffer of recent FC06/FC16 writes (own + observed)
if (wlog_count == 0) {
responseCmnd("no writes captured yet");
} else {
char line[120];
int now = millis();
sprintf(line, "%d write events:", wlog_count);
addLog(line);
// Iterate from oldest to newest
int start = 0;
if (wlog_count == 16) start = wlog_pos;
for (int k = 0; k < wlog_count; k++) {
int idx = (start + k) % 16;
int age_s = (now - wlog_ms[idx]) / 1000;
char src_label[12];
if (wlog_src[idx] == 'M') strcpy(src_label, "OWN ");
else strcpy(src_label, "OBS ");
sprintf(line, " %s addr=%d (0x%04x) val=%d (0x%04x) %ds ago",
src_label, wlog_addr[idx], wlog_addr[idx],
wlog_val[idx], wlog_val[idx], age_s);
addLog(line);
}
sprintf(line, "%d events dumped to log", wlog_count);
responseCmnd(line);
}
} else if (strFind(cmd, "DUMP") == 0 && strlen(cmd) == 4) {
char line[120];
for (int i = 0; i < 500; i = i + 8) {
int any = 0;
for (int j = 0; j < 8; j++) if (i+j < 500 && known[i+j]) any = 1;
if (!any) continue;
sprintf(line, "r%03d:", i);
char tmp[32];
for (int j = 0; j < 8; j++) {
if (i+j < 500 && known[i+j]) {
sprintf(tmp, " %04x", reg[i+j] & 0xFFFF);
} else {
strcpy(tmp, " ----");
}
strcat(line, tmp);
}
addLog(line);
}
responseCmnd("dumped to log");
} else if (strFind(cmd, "LOG ON") == 0 && strlen(cmd) == 6) {
log_on = 1;
log_until_ms = 0; // permanent
responseCmnd("frame logging ON (permanent — use MBUSLOG OFF to stop)");
} else if (strFind(cmd, "LOG OFF") == 0 && strlen(cmd) == 7) {
log_on = 0;
log_until_ms = 0;
responseCmnd("frame logging OFF");
} else if (strFind(cmd, "CAPTURE") == 0 && strlen(cmd) == 7) {
// Brief capture window — turns logging ON for ~10 s (covers a full
// cloud-poll cycle of ~8 s plus headroom), then auto-disables.
log_on = 1;
log_until_ms = millis() + 10000;
responseCmnd("capture: frame logging ON for 10s");
} else {
responseCmnd("MBUS: ON|OFF | SET <T> | WD ON|OFF|STAT|TEST|TEST URGENT | WD MAIL [<addr>|CLEAR] | WD MAILTEST | STAT | SNAP | CLEAR | DUMP | WRITES | CAPTURE | LOG ON|OFF");
}
}
// ------------------------------------------------------------
int main() {
ser = serialBegin(rx_pin, tx_pin, baud, cfg, 1024);
if (ser < 0) {
addLog("heatpump_map: serialBegin FAILED");
return 0;
}
char m[120];
sprintf(m, "heatpump_map ready: ser=%d rx=%d tx=%d baud=%d 8N2 open /tc_ui",
ser, rx_pin, tx_pin, baud);
addLog(m);
addCommand("MBUS");
webPageLabel(0, "Heat-pump map");
// Sniffing happens in Every50ms — no task needed. main() exits and
// the VM halts; the tick callback gets the bytes from TasmotaSerial's
// 1 KB ring buffer at 50 ms cadence (max ~95 B per tick).
return 0;
}