heatpump_test.tc¶
heatpump_test.tc — bare-bones heatpump Modbus sniffer (2026-05-11, threaded)
// ============================================================================
// heatpump_test.tc — bare-bones heatpump Modbus sniffer (2026-05-11, threaded)
//
// .31 (M5EPD-47) sniffs the heatpump's Modbus-RTU bus passively and
// republishes the values that other house devices want as UDP-multicast
// globals (`global float`). NOTHING ELSE — no display, no UI, no
// charts, no snapshots, no console commands, no per-tick latency
// tripwires. The display, dashboards, snapshot/diff tooling, etc., now
// live on a different device that subscribes to these globals.
//
// History: the bare-bones version that ran the serial drain from
// Every50ms was still sluggish on the web UI (Tasmota's main loop and
// the script's Every50ms share the same task — any serial work there
// directly delays /, /cm, etc.). 2026-05-11: moved the UART drain into
// `TaskLoop`, which the TinyC runtime hosts on its own FreeRTOS task.
// Web UI now competes only with Tasmota itself for the main loop;
// serial parsing runs concurrently.
//
// Wire-up (M5EPD-47):
// GPIO14 = RX (heatpump TX → MAX3485 RX → ESP)
// GPIO15 = TX (unused on a passive sniffer; left configured so a
// future write feature can be re-added without
// re-deploying)
// 19200 8N2 — matches the Wolf/StiebelEltron-class control bus
//
// Frame assembly: silence-based, now millis()-driven. Modbus-RTU's
// inter-frame gap is >= 3.5 char times; at 19200 8N2 that's ~2 ms.
// We flush when (millis() - last_byte_ms) >= SILENCE_MS AND buffer is
// non-empty. SILENCE_MS = 4 to absorb USB-jitter on the MAX3485 link.
//
// Parsing: ONLY FC03 (read holding registers). Three frame shapes
// supported (lone REQ / lone RSP / merged REQ+RSP). No CRC validation
// on the FC03 path — silence-delimiter already gates noise.
//
// Embedded-write scan (FC05/06/15/16 inside merged frames) was in the
// previous version for the heatpump-control feature. Removed here:
// .31 isn't doing the toggling anymore.
//
// Concurrency notes:
// - TaskLoop is the ONLY writer of buf/buf_len/counters/registers.
// WebCall reads `frames_seen` / `rsp_parsed` / `rsp_failed`; int32
// reads on ESP32 are atomic so a torn read can't happen — worst
// case the row shows last-tick values.
// - Global float writes from TaskLoop fan out to UDP via the VM's
// own bus; the VM serialises these internally.
// - No sensorGet, no JsonCall, no MqttShowSensor fan-out from the
// worker — this is the pattern that's been documented to hang
// (see epaper42_sensorget_pattern memory). We only call
// serialAvailable / serialRead / millis / delay here.
// ============================================================================
// ── UART / framing ──────────────────────────────────────────────
int rx_pin = 14;
int tx_pin = 15;
int baud = 19200;
int cfg = 7; // 8N2
int ser = -1;
char buf[260];
int buf_len = 0;
// Silence threshold for end-of-frame detection (ms).
//
// Modbus-RTU spec floor: 3.5 char times = ~2.0 ms @ 19200 8N2.
//
// Why 20 ms (not 4): the Wolf/StiebelEltron slave inserts occasional
// inter-byte gaps of 5-15 ms inside long RSPs (the master polls 50-120
// registers at a time, so RSPs hit 100-250 bytes; the slave's UART or
// CPU stutters while pumping them out). With SILENCE_MS=4 we were
// flushing mid-frame — 14 of 22 FC03+slave-1 frames per minute went
// to rsp_failed as "truncated long RSP". Confirmed by hex dump in
// log_failed_frame: `01 03 [bc] ...` with bc = last_req_qty * 2 every
// time, but buf_len < 3 + bc.
//
// 20 ms is 38 char times — way above any intra-frame stutter we've
// seen, but well below the master's ~100-200 ms cycle gap between
// transactions, so frames still separate cleanly.
int SILENCE_MS = 50;
int last_byte_ms = 0;
// ── Register store ──────────────────────────────────────────────
// 500 slots covers 0..338 + 432..493 (the cloud poll range) with
// headroom. reg[] holds the raw 16-bit value as read; known[] flags
// whether we've ever seen it (so consumers can distinguish "unread"
// from "zero").
int reg[500];
int known[500];
// Minimal counters — kept for the WebCall row + occasional sanity.
int frames_seen = 0;
int rsp_parsed = 0;
int rsp_failed = 0;
// Failure-hex-dump throttle (kept compiled-in for future debugging).
// Set FAIL_LOG_MAX>0 to enable; 0 = silent. After the 2026-05-11
// rx_full_thresh fix this stayed at failed=0 in soak so we ship
// with the diagnostic off. Re-enable if a new failure pattern
// shows up (raise to 30 → next 30 failures get logged with hex).
int fail_log_count = 0;
int FAIL_LOG_MAX = 0;
// Lone-RSP pairing — REQ from previous flush pairs with RSP next flush
// when the framer splits them across the silence boundary.
int last_req_addr = -1;
int last_req_qty = 0;
// ── UDP-broadcast globals ───────────────────────────────────────
// `global float` auto-broadcasts on every write via Tasmota's UDP
// multicast bus (239.255.255.250:1999). Other house devices declare
// the same names and receive these on the next packet. Names must
// stay stable across the LAN — the dashboard device on the receiving
// end keys on these.
//
// Mapping (signed ×10 °C unless noted):
// r1 → hp_tgt target temperature (Zieltemp)
// r188 → hp_in return / buffer (Puffer)
// r190 → hp_at outside-air sensor (Aussen)
// r191 → hp_out supply / output (Ausgang)
// r192 → hp_sug suction-gas temp (Ansauggas)
// r206 → hp_evp evaporator temp (Verdampfer)
// r193 → hp_psh suction pressure ×10 bar
// r194 → hp_pdh discharge pressure ×10 bar
// r217 → hp_run 1.0 = running, 0.0 = off (enum: 0=boot,1=run,6=remote-off)
global float hp_tgt = 0.0;
global float hp_in = 0.0;
global float hp_at = 0.0;
global float hp_out = 0.0;
global float hp_sug = 0.0;
global float hp_evp = 0.0;
global float hp_psh = 0.0;
global float hp_pdh = 0.0;
global float hp_run = 0.0;
// ── Helper: scale a register to signed/unsigned ×10 float ───────
float regs10(int val, int signedp) {
int u = val & 0xFFFF;
if (signedp && u >= 32768) u = u - 65536;
return u / 10.0;
}
// ── store_reg — record value + republish on UDP if mapped ───────
// `global float` writes auto-broadcast via Tasmota's UDP multicast bus
// (239.255.255.250:1999). Earlier (2026-05-11) we toggled this off via
// a DIAG_NO_UDP flag to test whether per-frame UDP bursts were causing
// the bimodal ~2 s latency on /cm — confirmed NOT the cause (slowness
// persisted with UDP off AND with the script entirely stopped). Flag
// removed; UDP is back on.
void store_reg(int addr, int val) {
if (addr < 0 || addr >= 500) return;
known[addr] = 1;
reg[addr] = val;
if (addr == 1) hp_tgt = regs10(val, 1);
else if (addr == 188) hp_in = regs10(val, 1);
else if (addr == 190) hp_at = regs10(val, 1);
else if (addr == 191) hp_out = regs10(val, 1);
else if (addr == 192) hp_sug = regs10(val, 1);
else if (addr == 206) hp_evp = regs10(val, 1);
else if (addr == 193) hp_psh = regs10(val, 0);
else if (addr == 194) hp_pdh = regs10(val, 0);
else if (addr == 217) hp_run = ((val & 0xFFFF) == 1) ? 1.0 : 0.0;
}
// ── parse_frame — FC03 only, three shapes ───────────────────────
// A) lone REQ 8 bytes → remember addr/qty for next RSP
// B) lone RSP 5 + 2N bytes → pair with last REQ
// C) merged REQ + RSP 8 + (5 + 2N) → start with REQ, parse trailing RSP
// Anything else (other slave, other FC, malformed) is silently ignored.
void parse_frame() {
if (buf_len < 5) return;
int sl = buf[0] & 0xFF;
int fc = buf[1] & 0xFF;
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
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) {
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;
return;
}
}
}
// Case B — lone RSP, pair with the most recently seen 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_req_addr = -1;
return;
}
}
rsp_failed = rsp_failed + 1;
log_failed_frame();
}
// ── log_failed_frame — diagnostic hex dump on rsp_failed++ ──────
// Emits the first 12 bytes of the unrecognised frame + buf_len + the
// most recent REQ we'd been hoping to pair an RSP with. Lets us see
// at a glance whether the failure is:
// - merged REQ+REQ pair : bytes [01 03 .. .. .. .. crc crc 01 03 ..]
// - lone RSP with no matching REQ: lastreq addr/qty != bc/2
// - fragment / split mid-frame : buf_len < 5
// - other (exception RSP, etc.) : something else entirely
void log_failed_frame() {
if (fail_log_count >= FAIL_LOG_MAX) return;
fail_log_count = fail_log_count + 1;
char hex[40];
char b[6];
hex[0] = 0;
int n = buf_len;
if (n > 12) n = 12;
for (int i = 0; i < n; i++) {
sprintf(b, "%02x ", buf[i] & 0xFF);
strcat(hex, b);
}
addLog("HP fail #%d len=%d [%s] lastreq addr=%d qty=%d", fail_log_count, buf_len, hex, last_req_addr, last_req_qty);
}
void flush_frame() {
if (buf_len == 0) return;
frames_seen = frames_seen + 1;
parse_frame();
buf_len = 0;
}
// ── TaskLoop — dedicated worker task for UART drain + framing ───
// Runs on its own FreeRTOS task. `delay()` here yields to the
// scheduler without blocking Tasmota's main loop.
//
// Drain shape (after diagnosing under-read at the 128-byte cutoff):
//
// while (avail = serialAvailable() > 0) drain into buf
//
// Re-checking avail INSIDE the drain (vs sampling once at the top)
// matters — at 19200 baud ~2 bytes arrive per ms, so calling
// serialAvailable a single time exits the loop with bytes still
// streaming. Re-checking turns this into a tight drain that empties
// the IDF UART ring buffer as it fills.
//
// Post-drain we also do a small spin-check (up to SPIN_CHECKS more
// reads with no yield), so any bytes the IDF driver copies into the
// ring buffer in the microseconds right after our last serialRead
// don't get dropped on the floor.
//
// Because this is the only TaskLoop callsite, no joins / handshakes
// with EverySecond / WebCall are needed — they read scalars only.
int SPIN_CHECKS = 4; // post-drain pickup attempts before yield
void TaskLoop() {
int b;
int avail;
while (1) {
if (ser < 0) {
delay(100);
continue;
}
int got = 0;
// Primary drain — re-check serialAvailable each iteration so
// we don't exit with bytes still landing in the ring buffer.
while (buf_len < 256) {
avail = serialAvailable(ser);
if (avail <= 0) break;
while (avail > 0 && buf_len < 256) {
b = serialRead(ser);
if (b < 0) { avail = 0; break; }
buf[buf_len] = b;
buf_len = buf_len + 1;
got = got + 1;
avail = avail - 1;
}
}
// Spin-pickup — give the IDF driver a few microseconds to
// surface any bytes still in the HW FIFO. No yield in this
// loop; bounded by SPIN_CHECKS so we can't get stuck.
int spins = 0;
while (spins < SPIN_CHECKS && buf_len < 256) {
avail = serialAvailable(ser);
if (avail <= 0) { spins = spins + 1; continue; }
spins = 0; // got something → reset and keep draining
while (avail > 0 && buf_len < 256) {
b = serialRead(ser);
if (b < 0) { avail = 0; break; }
buf[buf_len] = b;
buf_len = buf_len + 1;
got = got + 1;
avail = avail - 1;
}
}
int now = millis();
if (got > 0) {
last_byte_ms = now;
}
// End-of-frame: buffer has data AND we've been silent long
// enough. Also force-flush if buffer is full to keep the
// pipeline moving even on a misframe.
if (buf_len > 0) {
if (buf_len >= 256 || (now - last_byte_ms) >= SILENCE_MS) {
flush_frame();
}
}
// Yield. 1 ms is plenty fast for 19200 baud; bumps to 5 ms
// when idle to keep CPU available for WiFi / web.
if (got == 0 && buf_len == 0) {
delay(5);
} else {
delay(1);
}
}
}
// ── WebCall — activity + heap-health row on Tasmota's main page ──
// Surfaces sniffer counters + on-device heap diagnostics in one row.
// frames/parsed/failed — sniffer activity (TaskLoop alive + parsing)
// free — free heap, KB (tasm_heap / 1024)
// maxb — largest contiguous free heap block, KB (tasm_maxblock / 1024)
// frag — fragmentation %, computed by Tasmota: 100 - maxblock*100/free
// Watch for: free dropping toward 50 KB OR frag climbing past ~30% —
// both correlate with the kind of / sluggishness seen on .31. With
// the on-device readout, no out-of-band /in scraping is needed.
// Web-clock tick — increments on every WebCall fire. Visible as the
// trailing "● N" counter in the clock row, so a glance confirms the
// device is still updating (not just serving a cached page).
int web_clock_tick = 0;
void web_clock_header() {
web_clock_tick = web_clock_tick + 1;
char wd_names[] = "So|Mo|Di|Mi|Do|Fr|Sa";
char mo_names[] = "Jan|Feb|Mar|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez";
char wd_label[4];
char mo_label[4];
strToken(wd_label, wd_names, '|', tasm_wday);
strToken(mo_label, mo_names, '|', tasm_month);
char scratch[256];
sprintf(scratch, "<tr><td colspan=2 style='text-align:center;background:#333;padding:8px;border-radius:8px'><span style='color:green;font-size:40px;font-weight:bold'>%02d:%02d:%02d</span><br>%s %d. %s %d <span style='font-size:0.7em;color:#888;'>● %d</span></td></tr>",
tasm_hour, tasm_minute, tasm_second,
wd_label, tasm_day, mo_label, tasm_year, web_clock_tick);
webSend(scratch);
}
void WebCall() {
char row[120];
// Big green clock with date + WebCall tick counter — at-a-glance
// proof that the device is alive and the page is fresh.
web_clock_header();
// Heat-pump state (mirrors what we publish via UDP globals). Run state
// first so it's the most prominent — at a glance you see "Ein"/"Aus"
// and the target temperature. TinyC's sprintf %s wants a char[] var,
// not a ternary expression — copy into a local first.
char status[8];
if (hp_run > 0.5) strcpy(status, "Ein");
else strcpy(status, "Aus");
sprintf(row, "{s}WP Status{m}%s Soll %.1f°C{e}", status, hp_tgt);
webSend(row);
// Temperatures (°C ×10 → float)
sprintf(row, "{s}WP Außen / Puffer{m}%.1f°C / %.1f°C{e}", hp_at, hp_in);
webSend(row);
sprintf(row, "{s}WP Ausgang / Ansauggas{m}%.1f°C / %.1f°C{e}", hp_out, hp_sug);
webSend(row);
sprintf(row, "{s}WP Verdampfer{m}%.1f°C{e}", hp_evp);
webSend(row);
// Pressures (×10 bar → float)
sprintf(row, "{s}WP Druck Saug / Hoch{m}%.1f bar / %.1f bar{e}", hp_psh, hp_pdh);
webSend(row);
// Sniffer activity + heap health — last so it doesn't crowd the
// heat-pump readout.
sprintf(row, "{s}HP sniffer{m}frames=%d parsed=%d failed=%d free=%dkb maxb=%dkb frag=%d%%{e}",
frames_seen, rsp_parsed, rsp_failed,
tasm_heap / 1024, tasm_maxblock / 1024, tasm_frag);
webSend(row);
}
// ── main ────────────────────────────────────────────────────────
// 2026-05-11: isolation test confirmed serial work is NOT the cause
// of intermittent sluggishness (NO-SERIAL build was equally slow
// before a restart, fully fast after). Serial is restored here;
// soak across reboot to see when/if sluggishness returns.
int main() {
char m[120];
sprintf(m, "heatpump_test: opening serial at uptime %d s", tasm_uptime);
addLog(m);
// Use the largest RX ring buffer TinyC's syscall accepts (2048).
// At 19200 8N2 the longest single RSP we've seen is ~247 B and the
// longest merged Case C is ~255 B, so 1024 should be plenty — but
// the IDF UART driver's HW-FIFO→ring-buffer copy interacts with
// its rx_full_thresh / rx_timeout in a way that benefits from
// having extra headroom (bytes queued earlier can stay queued
// longer without backpressure).
ser = serialBegin(rx_pin, tx_pin, baud, cfg, 2048);
if (ser < 0) {
addLog("heatpump_test: serialBegin FAILED");
return 0;
}
last_byte_ms = millis();
sprintf(m, "heatpump_test ready (threaded sniffer): ser=%d rx=%d tx=%d 19200 8N2",
ser, rx_pin, tx_pin);
addLog(m);
return 0;
}