Zum Inhalt

heatpump_test.tc

heatpump_test.tc — bare-bones heatpump Modbus sniffer (2026-05-11, threaded)

Source on GitHub

// ============================================================================
// 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;'>&#9679; %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;
}