forked from arendst/Tasmota
-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathheatpump_test.tc
More file actions
450 lines (398 loc) · 18.2 KB
/
Copy pathheatpump_test.tc
File metadata and controls
450 lines (398 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
// ============================================================================
// 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;
}