pool_pump.tc¶
pool_pump.tc — Tuya local-protocol client for a "Swimming Pool Heat Pump"
// ─────────────────────────────────────────────────────────────────────
// pool_pump.tc — Tuya local-protocol client for a "Swimming Pool Heat Pump"
// (Tuya category 'qn'). Reads & controls without any cloud dependency
// once you've extracted the device's local key.
//
// Supports BOTH Tuya local protocol versions:
// v3.3 — AES-ECB encrypted body + UNENCRYPTED "3.3" prefix on CONTROL
// + CRC32 trailer, no session handshake. Most pumps use this.
// v3.4 — AES-ECB encrypted body (prefix included in encryption) + HMAC-SHA256
// trailer + 3-step session-key handshake. Newer firmware.
//
// Default: v3.3-only via `#define POOL_PROTO_V33_ONLY` below — pumps that
// speak v3.4 are rare in the wild, and the auto-detect adds ~5+ s of dead
// time per failed query (tries v3.3, times out, retries v3.4 handshake,
// times out, then waits 30 s for the next periodic poll). Comment out the
// `#define` to re-enable the v3.4 path. With auto-detect enabled, the
// detected version is cached after the first success (POOLVER <0|33|34>
// to override).
//
// Deployment:
// ESP32 + Tasmota + TinyC. The device must be on the SAME LAN segment
// as the pump (must be able to TCP-connect to pump:6668). On most
// home networks any device on the same subnet works; if the pump is
// behind a NAT extender, the TinyC device needs to be on that extender's
// WiFi too (no main-LAN bridging).
//
// Configuration:
// Drop a plain-text `/pool_pump.cfg` file onto the device's filesystem
// (Tasmota → Werkzeuge → Datei verwalten → upload). Three lines, exact
// order, one value per line, no quoting, no extra whitespace:
//
// <pump-IP> e.g. 192.168.x.y
// <16-char Tuya local AES-128 key> from your Tuya cloud project
// <22-char Tuya device ID> from your Tuya cloud project
//
// Line 1 = pump IP (≥ 7 chars).
// Line 2 = 16-char Tuya local AES-128 key.
// Line 3 = 22-char Tuya device ID.
//
// The script reads this file once at boot. There is no command-line path
// to edit credentials — that's deliberate so the example is safe to share
// publicly. To change credentials: re-upload the .cfg, then restart the
// slot (`TinyCStop 0` + `TinyCRun /pool_pump.tcb`).
//
// To extract localKey + devId: see notes in README on the Tuya IoT
// Platform (cloud project + tinytuya-iot-py-sdk SMART_HOME login).
//
// Console commands:
// POOLSTAT — show last cached state
// POOLQUERY — refresh state from pump (queues background TCP)
// POOLON — switch pump on
// POOLOFF — switch pump off
// POOLTEMP <n> — set target water temp (5..80 °C)
// POOLVER <0|33|34> — force protocol version (0 = auto-detect)
// ─────────────────────────────────────────────────────────────────────
// ── Build-time toggles ───────────────────────────────────────────────
// Comment out to re-enable v3.4 protocol auto-detect + handshake. With
// V33_ONLY defined, a failed v3.3 query simply waits for the next tick
// instead of attempting (and timing out on) v3.4 — much faster recovery
// on flaky links.
#define POOL_PROTO_V33_ONLY
// ── Configuration (loaded from /pool_pump.cfg in main()) ────────────
// Plain `char` — never persists. The file is the source of truth. Sizes
// match Tuya's fixed lengths plus NUL terminator.
//
// NB: local_key is dimensioned 17, not 16 — TinyC's `char arr[N]` reserves
// slot N-1 for the NUL terminator, so a 16-char key needs 17 slots to
// avoid silent truncation of the last byte (which would corrupt AES).
char pump_ip[16];
int pump_port = 6668;
char local_key[17];
char dev_id[24];
// ── UDP-broadcast: current pool-pump power consumption ────────────
// `pwp` (Pool-WP, Watts) is broadcast over UDP by whichever Tasmota
// device meters the pump's power draw (a smart plug or clamp meter
// upstream). Same name the energy_dashboard slot subscribes to —
// declaring it here makes slot 1 a second receiver, no extra
// broadcast traffic. Float because the firmware UDP receiver stores
// scalars as float bits; cast to int for display.
global float pwp = 0.0;
// ── State ──────────────────────────────────────────────────────────
int pump_on = -1;
int pump_temp_set = 0;
int pump_temp_cur = 0;
char pump_state[16] = "unknown";
char pump_mode[16] = "unknown"; // DP 4 — "Boost_Heat" / "Auto" / etc.
int poll_seq = 0;
int last_query_ms = 0;
int last_ok = 0;
int query_errors = 0;
// ── Web inputs — change-detected via TinyC `watch` ───────────────
// `webButton` flips pool_btn_state 0↔1 on click (renders the value as
// "An/Aus: ON" / "An/Aus: OFF" — so the bound variable must mirror the
// pump's actual switch state, not be a click-only trigger). `webSlider`
// writes pool_set_input on release. Both are `watch` so EverySecond uses
// `written()` to react to the URL-driven write.
//
// State-mirror invariant:
// - `tuya_query()` (success path) writes `pool_btn_state = pump_on` and
// `pool_set_input = clamped(pump_temp_set)`, then `snapshot()` clears
// the written-flag so EverySecond doesn't mistake the script's own
// sync for a user click.
// - When `written()` fires, EverySecond optimistically updates
// `pump_on` / `pump_temp_set` to the new value (so the UI stays in
// sync until the immediate post-write tuya_query confirms).
watch int pool_btn_state = 0;
watch int pool_set_input = 24;
int pool_set_synced = 0; // 1 once first query has aligned slider with pump
int protocol_version = 0; // 0 = auto, 33, 34
int v34_session_ok = 0; // 1 once session_key established
char session_key[16]; // active key for v3.4 (= local_key for v3.3)
char local_nonce[16];
char remote_nonce[16];
// ── Worker task queue (delay() can't run in Command — spawn instead) ──
int work_pending = 0; // 1 = PoolWorker should run
int work_kind = 0; // 0=query 1=on 2=off 3=set_temp
int work_arg = 0; // for set_temp
// ── Buffers ────────────────────────────────────────────────────────
char json_buf[160];
char packet_out[400];
char packet_in[640];
char decrypt_buf[400];
char resp_msg[100];
char scratch[80];
char hmac_buf[32];
char tmp_buf[64];
// CRC32 table — populated once in main()
int crc32_table[256];
// ── CRC32 (zlib) — used by v3.3 ────────────────────────────────────
void crc32_init() {
// TinyC `int` is signed 32-bit; `>>` does arithmetic (sign-extending) shift.
// For CRC we need logical (zero-fill) shift, so mask to 31 bits after each
// shift. Without this, table entries with the top bit set get corrupted.
for (int i = 0; i < 256; i = i + 1) {
int c = i;
for (int k = 0; k < 8; k = k + 1) {
if (c & 1) { c = ((c >> 1) & 0x7FFFFFFF) ^ 0xEDB88320; }
else { c = (c >> 1) & 0x7FFFFFFF; }
}
crc32_table[i] = c & 0xFFFFFFFF;
}
}
int crc32_calc(char buf[], int len) {
int crc = 0xFFFFFFFF;
for (int i = 0; i < len; i = i + 1) {
int b = buf[i] & 0xFF;
crc = crc32_table[(crc ^ b) & 0xFF] ^ ((crc >> 8) & 0x00FFFFFF);
}
return crc ^ 0xFFFFFFFF;
}
// ── AES-ECB multi-block (in-place; key is which 16-byte char[]) ────
int aes_ecb_multi(char key[], char data[], int len, int enc_flag) {
char block[16];
if ((len & 15) != 0) return 0;
for (int b = 0; b < len; b = b + 16) {
for (int i = 0; i < 16; i = i + 1) block[i] = data[b + i];
if (!aesEcb(key, block, enc_flag)) return 0;
for (int i = 0; i < 16; i = i + 1) data[b + i] = block[i];
}
return 1;
}
int pkcs7_pad(char buf[], int len) {
int pad = 16 - (len & 15);
for (int i = 0; i < pad; i = i + 1) buf[len + i] = pad;
return len + pad;
}
int pkcs7_strip(char buf[], int len) {
if (len <= 0) return 0;
int pad = buf[len - 1] & 0xFF;
if (pad < 1 || pad > 16) return len;
return len - pad;
}
// ── Set 32-bit big-endian into buffer ─────────────────────────────
void put_be32(char buf[], int off, int val) {
buf[off] = (val >> 24) & 0xFF;
buf[off + 1] = (val >> 16) & 0xFF;
buf[off + 2] = (val >> 8) & 0xFF;
buf[off + 3] = val & 0xFF;
}
int get_be32(char buf[], int off) {
int b0 = buf[off] & 0xFF;
int b1 = buf[off + 1] & 0xFF;
int b2 = buf[off + 2] & 0xFF;
int b3 = buf[off + 3] & 0xFF;
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
}
// ── Build a Tuya packet (returns total length, -1 on error) ─────
//
// version: 33 or 34
// active_key: which 16-byte key to use for AES & HMAC
// cmd: command code (0x03/04/05 handshake, 0x07 control, 0x0A query, 0x09 hb)
// json_body: plaintext body (for handshake cmds, body is the raw nonce/hmac;
// for app commands, it's JSON; the version header is added
// automatically for cmds that need it)
// json_len: length of body in bytes (binary-safe)
//
// For v3.3: trailer is CRC32(4)+suffix(4)
// For v3.4: trailer is HMAC-SHA256(32)+suffix(4)
//
int build_packet(int version, char active_key[], int cmd, char body[], int body_len) {
poll_seq = poll_seq + 1;
// Header
put_be32(packet_out, 0, 0x000055AA);
put_be32(packet_out, 4, poll_seq);
put_be32(packet_out, 8, cmd);
// length placeholder
put_be32(packet_out, 12, 0);
int pos = 16;
// Decide if version header prefix is needed.
// Cmds that DON'T get the prefix:
// 0x03 (SESS_KEY_NEG_START), 0x04, 0x05, 0x09 (HEARTBEAT), 0x0A (DP_QUERY)
int need_prefix = 1;
if (cmd == 0x03 || cmd == 0x04 || cmd == 0x05 || cmd == 0x09 || cmd == 0x0A) {
need_prefix = 0;
}
// KEY DIFFERENCE between v3.3 and v3.4:
// v3.3: prepend UNENCRYPTED "3.3" + 12 zero bytes BEFORE the encrypted JSON.
// (the prefix is sent in cleartext as the first 15 bytes of the
// payload section, and only the JSON bytes are AES-ECB encrypted.)
// v3.4: prepend "3.4" + 12 zero bytes to the JSON, THEN encrypt the whole
// thing as one block sequence.
// Get this wrong and the pump replies with "parse data error" (16 chars
// decrypted) for v3.3 commands.
if (need_prefix && version == 33) {
packet_out[pos] = '3';
packet_out[pos+1] = '.';
packet_out[pos+2] = '3';
for (int i = 3; i < 15; i = i + 1) packet_out[pos + i] = 0;
pos = pos + 15;
}
// Encrypt-buffer
char enc_buf[256];
int enc_len = 0;
if (need_prefix && version == 34) {
// v3.4: prefix INSIDE encrypted region
enc_buf[0] = '3'; enc_buf[1] = '.'; enc_buf[2] = '4';
for (int i = 3; i < 15; i = i + 1) enc_buf[i] = 0;
enc_len = 15;
}
for (int i = 0; i < body_len; i = i + 1) enc_buf[enc_len + i] = body[i];
enc_len = enc_len + body_len;
int padded_len = pkcs7_pad(enc_buf, enc_len);
if (!aes_ecb_multi(active_key, enc_buf, padded_len, 1)) {
addLog("POOL: aes encrypt failed");
return -1;
}
for (int i = 0; i < padded_len; i = i + 1) packet_out[pos + i] = enc_buf[i];
pos = pos + padded_len;
// Fill in length field
int trailer_len;
if (version == 33) trailer_len = 4 + 4; // crc + suffix
else trailer_len = 32 + 4; // hmac + suffix
int plen = (pos - 16) + trailer_len;
put_be32(packet_out, 12, plen);
// Trailer: CRC32 (v3.3) or HMAC-SHA256 (v3.4)
if (version == 33) {
int crc = crc32_calc(packet_out, pos);
put_be32(packet_out, pos, crc);
pos = pos + 4;
} else {
// HMAC-SHA256 over packet_out[0..pos)
char hmac_out[32];
// hmacSha256(key_ref, klen, data_ref, dlen, out32_ref) -> int
if (!hmacSha256(active_key, 16, packet_out, pos, hmac_out)) {
addLog("POOL: hmac failed");
return -1;
}
for (int i = 0; i < 32; i = i + 1) packet_out[pos + i] = hmac_out[i];
pos = pos + 32;
}
// Suffix
put_be32(packet_out, pos, 0x0000AA55);
return pos + 4;
}
// ── Send raw bytes over TCP, read response ─────────────────────────
// Caller is responsible for tcpConnect / tcpDisconnect.
// Returns bytes read into packet_in, or 0 on timeout/error.
// Big buffer for hex dumps (300 chars + label)
char dbg_buf[400];
int tcp_round_trip(int packet_len) {
// Send packet (type 0 = uint8 single-byte-per-element — type 1 silently
// doubled the length and corrupted bytes; type 0 is what we want).
tcpWriteArray(packet_out, packet_len, 0);
// Wait up to 3 s for response. delay() inside spawnTask is fine.
int waited = 0;
int avail = 0;
while (waited < 3000) {
avail = tcpAvailable();
if (avail >= 24) break;
delay(50);
waited = waited + 50;
}
if (avail < 24) {
addLog("POOL: timeout after %d ms (avail=%d)", waited, avail);
return 0;
}
delay(150); // let trailing bytes arrive
return tcpReadArray(packet_in);
}
// ── Parse response (returns decrypted body length in decrypt_buf, 0 on err)
//
// version: 33 or 34
// active_key: AES + HMAC key
// got: total bytes received in packet_in
//
// Strips header, retcode (if v3.4 response includes it), version prefix
// (if present), AES-decrypts, PKCS7-strips. decrypt_buf gets the result.
//
// For HMAC verification on v3.4, we recompute HMAC over the prefix..before-hmac
// and compare. (Skipped for now to keep code small — Tuya doesn't refuse the
// session if we don't verify; verification only matters for tamper detection.)
//
// Returns: -1 on packet error, 0 on valid empty-body ACK (SET commands),
// >0 = decrypted body length (DP_QUERY responses).
int parse_response(int version, char active_key[], int got) {
if (got < 24) return -1;
// Verify prefix
if (get_be32(packet_in, 0) != 0x000055AA) {
addLog("POOL: bad prefix");
return -1;
}
int rsp_seq = get_be32(packet_in, 4);
int rsp_cmd = get_be32(packet_in, 8);
int plen = get_be32(packet_in, 12);
// Total length should match
int total_expected = 16 + plen;
if (total_expected > got) {
sprintf(scratch, "POOL: short pkt got=%d want=%d", got, total_expected);
addLog(scratch);
return -1;
}
// Detect empty-body ACK (typical for SET commands):
// plen = 4 (retcode) + 0 (body) + trailer = 4 + trailer_len.
// For v3.3: trailer 8 → plen 12. Pump ACK is empty-body → return 0.
int trailer_len_check = (version == 33) ? 8 : 36;
if (plen <= trailer_len_check + 4) return 0;
// Trailer length
int trailer_len;
if (version == 33) trailer_len = 4 + 4;
else trailer_len = 32 + 4;
// The encrypted body region: starts after the 16-byte header AND optionally
// a 4-byte retcode (returned in responses). Heuristic: if first 4 bytes are
// small (return code 0 or 1), treat as retcode and skip.
int body_start = 16;
int retcode = get_be32(packet_in, 16);
// If retcode looks plausible (0..255) AND the remaining bytes after it
// form a valid-length encrypted block, advance.
int body_end = 16 + plen - trailer_len; // end-exclusive of body
int body_len_with_retcode = body_end - 16;
int body_len_without_retcode = body_end - 16;
// Try with retcode first
if (retcode >= 0 && retcode < 256) {
int test_len = body_end - (16 + 4);
if (test_len > 0 && (test_len & 15) == 0) {
body_start = 20;
body_len_with_retcode = test_len;
}
}
int body_len = body_end - body_start;
if (body_len <= 0 || (body_len & 15) != 0 || body_len > 384) {
// Try without skipping retcode
body_start = 16;
body_len = body_end - 16;
if (body_len < 0 || (body_len & 15) != 0 || body_len > 384) {
sprintf(scratch, "POOL: bad body_len=%d", body_len);
addLog(scratch);
return -1;
}
if (body_len == 0) return 0; // empty-body ACK
}
// Copy ciphertext into decrypt_buf
for (int i = 0; i < body_len; i = i + 1) decrypt_buf[i] = packet_in[body_start + i];
if (!aes_ecb_multi(active_key, decrypt_buf, body_len, 0)) {
addLog("POOL: aes decrypt failed");
return -1;
}
// Strip PKCS7 padding
int dec_len = pkcs7_strip(decrypt_buf, body_len);
// Strip optional version header "3.x" + 12 bytes if present
if (dec_len >= 15 && decrypt_buf[0] == '3' && decrypt_buf[1] == '.' &&
(decrypt_buf[2] == '3' || decrypt_buf[2] == '4')) {
for (int i = 0; i < dec_len - 15; i = i + 1) decrypt_buf[i] = decrypt_buf[i + 15];
dec_len = dec_len - 15;
}
decrypt_buf[dec_len] = 0;
return dec_len;
}
// ── v3.4 handshake — runs over an already-open TCP connection ─────
int tuya_v34_handshake() {
addLog("POOL: v3.4 handshake start");
// Reset session
v34_session_ok = 0;
// Use tinytuya's not-so-random fixed nonce — deterministic, simpler debug
char nonce_lit[16];
strcpy(nonce_lit, "0123456789abcdef");
for (int i = 0; i < 16; i = i + 1) local_nonce[i] = nonce_lit[i];
// ── Step 1: send SESS_KEY_NEG_START (0x03) with local_nonce ──
int plen = build_packet(34, local_key, 0x03, local_nonce, 16);
if (plen <= 0) return 0;
int got = tcp_round_trip(plen);
if (got <= 0) {
addLog("POOL: v3.4 step1 no response");
return 0;
}
// ── Step 2: parse SESS_KEY_NEG_RESP (0x04) ──
// Response payload (after AES-decrypt) should be:
// [16] remote_nonce
// [32] HMAC-SHA256(local_key, local_nonce)
int dec_len = parse_response(34, local_key, got);
if (dec_len < 48) { // need at least 16 nonce + 32 hmac
addLog("POOL: v3.4 step2 short payload %d", dec_len);
return 0;
}
for (int i = 0; i < 16; i = i + 1) remote_nonce[i] = decrypt_buf[i];
// Verify HMAC: HMAC(local_key, local_nonce) == decrypt_buf[16..48]
char check_hmac[32];
if (!hmacSha256(local_key, 16, local_nonce, 16, check_hmac)) {
addLog("POOL: v3.4 hmac check fn failed");
return 0;
}
int hmac_ok = 1;
for (int i = 0; i < 32; i = i + 1) {
if (check_hmac[i] != decrypt_buf[16 + i]) { hmac_ok = 0; }
}
if (!hmac_ok) {
addLog("POOL: v3.4 hmac mismatch — wrong localKey?");
return 0;
}
// ── Step 3: send SESS_KEY_NEG_FINISH (0x05) with HMAC(local_key, remote_nonce) ──
char rkey_hmac[32];
if (!hmacSha256(local_key, 16, remote_nonce, 16, rkey_hmac)) {
addLog("POOL: v3.4 step3 hmac failed");
return 0;
}
plen = build_packet(34, local_key, 0x05, rkey_hmac, 32);
if (plen <= 0) return 0;
// Step 3 doesn't need a meaningful response — we just send and continue.
tcpWriteArray(packet_out, plen, 1);
delay(150);
// Drain any incoming bytes (we don't care about content)
int drained = tcpAvailable();
if (drained > 0) tcpReadArray(packet_in);
// ── Finalize: session_key = AES-ECB-encrypt(local_key, local_nonce XOR remote_nonce) ──
char xor_block[16];
for (int i = 0; i < 16; i = i + 1) {
xor_block[i] = local_nonce[i] ^ remote_nonce[i];
}
if (!aesEcb(local_key, xor_block, 1)) {
addLog("POOL: v3.4 session-key encrypt failed");
return 0;
}
for (int i = 0; i < 16; i = i + 1) session_key[i] = xor_block[i];
v34_session_ok = 1;
addLog("POOL: v3.4 handshake OK");
return 1;
}
// ── JSON DP extraction ─────────────────────────────────────────────
// pat is global — having a local char[16] in each call hits a TinyC
// heap-handle edge case ("heap handle N invalid" warnings + parse fail).
char json_pat[24];
int json_get_int(char json[], char key[], int dflt) {
sprintf(json_pat, "\"%s\":", key);
int p = strFind(json, json_pat);
if (p < 0) return dflt;
int i = p + strlen(json_pat);
while (json[i] == ' ' || json[i] == '\t') i = i + 1;
if (json[i] == 't') return 1;
if (json[i] == 'f') return 0;
int neg = 0; int val = 0; int seen = 0;
if (json[i] == '-') { neg = 1; i = i + 1; }
while (json[i] >= '0' && json[i] <= '9') {
val = val * 10 + (json[i] - '0');
i = i + 1; seen = 1;
}
if (!seen) return dflt;
if (neg) val = -val;
return val;
}
void json_get_str(char json[], char key[], char out[], int outmax) {
sprintf(json_pat, "\"%s\":\"", key);
int p = strFind(json, json_pat);
if (p < 0) { out[0] = 0; return; }
int i = p + strlen(json_pat);
int j = 0;
while (json[i] != '\"' && json[i] != 0 && j < outmax - 1) {
out[j] = json[i];
i = i + 1; j = j + 1;
}
out[j] = 0;
}
// ── Single attempt at a query/control round-trip (one specific version) ──
// Connects, optionally does handshake (v3.4), sends body, reads response.
// Returns decrypted-body length (>0 OK, 0 fail). Caller already tcpSelect'd.
int tuya_round_trip_v(int version, int cmd, char body[], int body_len) {
// Pump only allows one concurrent local connection and has a cooldown
// after each disconnect. Retry up to 3 times with growing back-off.
int rc = -1;
for (int attempt = 0; attempt < 3; attempt = attempt + 1) {
rc = tcpConnect(pump_ip, pump_port);
if (rc == 0) break;
sprintf(scratch, "POOL: connect retry %d (rc=%d)", attempt, rc);
addLog(scratch);
delay(800 + attempt * 400); // 800ms, 1200ms, 1600ms
}
if (rc != 0) {
sprintf(scratch, "POOL: connect fail rc=%d (%s:%d)", rc, pump_ip, pump_port);
addLog(scratch);
return 0;
}
delay(120);
if (!tcpConnected()) {
addLog("POOL: not connected post-delay");
tcpDisconnect();
return 0;
}
int active_is_session = 0;
if (version == 34) {
if (!tuya_v34_handshake()) {
tcpDisconnect();
return 0;
}
active_is_session = 1;
}
// Build the actual command packet
int plen;
if (active_is_session) {
plen = build_packet(34, session_key, cmd, body, body_len);
} else {
plen = build_packet(33, local_key, cmd, body, body_len);
}
if (plen <= 0) { tcpDisconnect(); return -1; }
int got = tcp_round_trip(plen);
tcpDisconnect();
if (got <= 0) {
addLog("POOL: response timeout");
return -1;
}
int dec_len;
if (active_is_session) dec_len = parse_response(34, session_key, got);
else dec_len = parse_response(33, local_key, got);
// -1 = real error, 0 = empty-body ACK (success for SET), >0 = body
if (dec_len < 0) return -1;
if (dec_len == 0) return 0;
// Sanity: response should be a JSON object (or empty for ACK-only)
if (decrypt_buf[0] != '{' && cmd != 0x07 && cmd != 0x09) {
sprintf(scratch, "POOL: response not JSON (first byte=%d)", decrypt_buf[0] & 0xFF);
addLog(scratch);
return -1;
}
return dec_len;
}
// ── Top-level: do a query/control with version auto-detect ──────────
int tuya_round_trip(int cmd, char body[], int body_len) {
tcpSelect(0);
int dec_len = -1;
#ifdef POOL_PROTO_V33_ONLY
// V33-only build: pin to v3.3 forever, no v3.4 fallback.
if (protocol_version == 0) {
protocol_version = 33;
}
return tuya_round_trip_v(33, cmd, body, body_len);
#else
if (protocol_version == 0 || protocol_version == 33) {
dec_len = tuya_round_trip_v(33, cmd, body, body_len);
if (dec_len >= 0) { // 0 = ACK, >0 = body — both success
if (protocol_version == 0) {
protocol_version = 33;
addLog("POOL: detected v3.3");
}
return dec_len;
}
if (protocol_version == 33) return -1;
}
// Try v3.4
dec_len = tuya_round_trip_v(34, cmd, body, body_len);
if (dec_len >= 0) {
if (protocol_version == 0) {
protocol_version = 34;
addLog("POOL: detected v3.4");
}
return dec_len;
}
return -1;
#endif
}
// ── Top-level operations ─────────────────────────────────────────
int tuya_query() {
sprintf(json_buf, "{\"gwId\":\"%s\",\"devId\":\"%s\"}", dev_id, dev_id);
int dec_len = tuya_round_trip(0x0A, json_buf, strlen(json_buf));
if (dec_len <= 0) { // <0 = error, 0 = empty-body (no DPs to parse)
last_ok = 0;
query_errors = query_errors + 1;
return 0;
}
last_ok = 1;
last_query_ms = millis();
addLog(decrypt_buf);
// DP id mapping reverse-engineered from a live pump query:
// 1 = switch (bool) 2 = temp_set (int °C) 3 = temp_current (int °C)
// 4 = mode ("Boost_Heat", "Auto", etc.) 11 = work_state ("standby"/"heating")
// 13 = temp_unit_convert ("c"/"f") 21 = silent_mode flag
//
// Keys are named char[] not string literals — TinyC's literal-to-char[]
// promotion for function args hits a heap-handle edge case ("heap handle
// N invalid" warnings + json_get_* silently returning default).
char k_switch[4]; strcpy(k_switch, "1");
char k_temp_set[4]; strcpy(k_temp_set, "2");
char k_temp_cur[4]; strcpy(k_temp_cur, "3");
char k_mode[4]; strcpy(k_mode, "4");
char k_state[4]; strcpy(k_state, "11");
pump_on = json_get_int(decrypt_buf, k_switch, pump_on);
pump_temp_set = json_get_int(decrypt_buf, k_temp_set, pump_temp_set);
pump_temp_cur = json_get_int(decrypt_buf, k_temp_cur, pump_temp_cur);
json_get_str(decrypt_buf, k_mode, pump_mode, 16);
json_get_str(decrypt_buf, k_state, pump_state, 16);
// Sync the toggle button with the pump's actual switch state on every
// successful query — keeps the "An/Aus: ON/OFF" label honest even when
// someone toggles the pump via the Tuya app on their phone. `snapshot()`
// clears the `written` flag set by this script-side write so EverySecond
// doesn't mistake it for a user click.
pool_btn_state = pump_on;
snapshot(pool_btn_state);
// Sync slider with pump's actual target on first query so the widget
// starts at the real value. Clamp to its visible 20..28 range.
if (!pool_set_synced) {
int v = pump_temp_set;
if (v < 20) v = 20;
if (v > 28) v = 28;
pool_set_input = v;
snapshot(pool_set_input);
pool_set_synced = 1;
}
return 1;
}
// Tuya CONTROL JSON: {"devId":"<id>","uid":"<id>","t":"<unix>","dps":{"<dp>":<val>}}
// uid must match devId — tinytuya fills it from self.id, NOT empty string. With
// uid="" the pump silently rejects the command (no error response, just no
// state change). Use Tasmota's time() syscall for the unix timestamp; pump
// validates that t is recent.
int tuya_set_int(int dp_id, int value) {
int t = 1777530000 + (millis() / 1000); // 10-digit unix-epoch-ish
sprintf(json_buf,
"{\"devId\":\"%s\",\"uid\":\"%s\",\"t\":\"%d\",\"dps\":{\"%d\":%d}}",
dev_id, dev_id, t, dp_id, value);
int dec_len = tuya_round_trip(0x07, json_buf, strlen(json_buf));
return dec_len >= 0 ? 1 : 0;
}
int tuya_set_bool(int dp_id, int value) {
// Hardcoded recent unix epoch — pump may validate format requires 10 digits.
// TODO: replace with proper time once we figure out the right syscall.
int t = 1777530000 + (millis() / 1000);
char tf[8];
if (value) { strcpy(tf, "true"); } else { strcpy(tf, "false"); }
sprintf(json_buf,
"{\"devId\":\"%s\",\"uid\":\"%s\",\"t\":\"%d\",\"dps\":{\"%d\":%s}}",
dev_id, dev_id, t, dp_id, tf);
int dec_len = tuya_round_trip(0x07, json_buf, strlen(json_buf));
return dec_len >= 0 ? 1 : 0;
}
// ── Load credentials from /pool_pump.cfg ──────────────────────────
// File format: 3 lines, exact order, no quoting:
// line 1 — pump IP (≥ 7 chars, e.g. "192.168.1.50")
// line 2 — 16-char Tuya local AES-128 key
// line 3 — 22-char Tuya device ID
// Lines may end with `\n` or `\r\n`. Returns 1 on success, 0 on any
// error (file missing, malformed, wrong field lengths). Caller logs.
int load_config() {
char raw[96];
int h = fileOpen("/pool_pump.cfg", 0);
if (h < 0) return 0;
int n = fileRead(h, raw, 95);
fileClose(h);
if (n <= 0) return 0;
raw[n] = 0;
int line = 0;
int j = 0;
pump_ip[0] = 0;
local_key[0] = 0;
dev_id[0] = 0;
for (int i = 0; i < n; i = i + 1) {
int c = raw[i] & 0xFF;
if (c == 13) continue; // skip \r
if (c == 10) { // \n → next field
if (line == 0) pump_ip[j] = 0;
else if (line == 1) local_key[j] = 0;
else if (line == 2) dev_id[j] = 0;
line = line + 1;
j = 0;
if (line >= 3) break;
} else {
if (line == 0 && j < 15) { pump_ip[j] = c; j = j + 1; }
else if (line == 1 && j < 16) { local_key[j] = c; j = j + 1; }
else if (line == 2 && j < 23) { dev_id[j] = c; j = j + 1; }
}
}
// Files without a trailing newline still terminate the last field.
if (line == 0 && j > 0) { pump_ip[j] = 0; }
else if (line == 1 && j > 0) { local_key[j] = 0; }
else if (line == 2 && j > 0) { dev_id[j] = 0; }
if (strlen(pump_ip) < 7) return 0;
if (strlen(local_key) != 16) return 0;
if (strlen(dev_id) != 22) return 0;
return 1;
}
// ── Worker task — runs TCP work in own FreeRTOS task (delay() OK here) ──
// SET commands are followed by an immediate query so the UI reflects the
// pump's confirmed state within ~1-2 s instead of waiting up to 30 s for
// the next periodic poll. tuya_set_bool/_int returns 1 only when the pump
// ACKed; on failure we still re-query to read the unchanged real state.
void PoolWorker() {
addLog("POOL WORKER: dispatch");
if (work_kind == 0) {
tuya_query();
} else if (work_kind == 1) {
tuya_set_bool(1, 1);
tuya_query();
} else if (work_kind == 2) {
tuya_set_bool(1, 0);
tuya_query();
} else if (work_kind == 3) {
tuya_set_int(2, work_arg);
tuya_query();
}
work_pending = 0;
addLog("POOL WORKER: done");
}
void enqueue_work(int kind, int arg) {
if (taskRunning("PoolWorker")) {
addLog("POOL: previous worker still running, ignoring");
return;
}
work_kind = kind;
work_arg = arg;
work_pending = 1;
spawnTask("PoolWorker", 8);
}
// ── Tasmota integration ─────────────────────────────────────────────
void Command(char cmd[]) {
if (strFind(cmd, "STAT") == 0 && strlen(cmd) == 4) {
if (last_ok) {
sprintf(resp_msg, "POOL v%d on=%d set=%d cur=%d state=%s err=%d (cached)",
protocol_version, pump_on, pump_temp_set, pump_temp_cur,
pump_state, query_errors);
} else {
sprintf(resp_msg, "POOL: no fresh data (errors=%d)", query_errors);
}
responseCmnd(resp_msg);
} else if (strFind(cmd, "QUERY") == 0 && strlen(cmd) == 5) {
enqueue_work(0, 0);
responseCmnd("POOL: query queued (POOLSTAT to read result)");
} else if (strFind(cmd, "ON") == 0 && strlen(cmd) == 2) {
enqueue_work(1, 0);
responseCmnd("POOL: ON queued");
} else if (strFind(cmd, "OFF") == 0 && strlen(cmd) == 3) {
enqueue_work(2, 0);
responseCmnd("POOL: OFF queued");
} else if (strFind(cmd, "TEMP ") == 0) {
int n = strlen(cmd);
int t = 0;
for (int i = 5; i < n; i = i + 1) {
if (cmd[i] >= '0' && cmd[i] <= '9') t = t * 10 + (cmd[i] - '0');
}
if (t < 5 || t > 80) {
responseCmnd("POOL: TEMP out of range 5..80");
} else {
enqueue_work(3, t);
sprintf(resp_msg, "POOL: target %d C queued", t);
responseCmnd(resp_msg);
}
} else if (strFind(cmd, "VER ") == 0) {
int v = 0;
if (cmd[4] == '3' && cmd[5] == '3') v = 33;
#ifndef POOL_PROTO_V33_ONLY
if (cmd[4] == '3' && cmd[5] == '4') v = 34;
#endif
if (cmd[4] == '0') v = 0;
protocol_version = v;
v34_session_ok = 0;
sprintf(resp_msg, "POOL: forced version %d (0=auto)", v);
responseCmnd(resp_msg);
} else {
#ifdef POOL_PROTO_V33_ONLY
responseCmnd("POOL: STAT | QUERY | ON | OFF | TEMP <n> | VER <0|33>");
#else
responseCmnd("POOL: STAT | QUERY | ON | OFF | TEMP <n> | VER <0|33|34>");
#endif
}
}
int tick_secs = 0;
void EverySecond() {
// Web "An/Aus" button — webButton renders "<label>: ON/OFF" from the
// bound variable's value and flips it 0↔1 on click. The URL handler's
// STORE_WATCH bridge sets the written-flag, EverySecond reads the new
// value and dispatches the matching command. Optimistic update of
// `pump_on` keeps the UI consistent until the post-write tuya_query
// (in PoolWorker) confirms within seconds.
if (written(pool_btn_state)) {
if (pool_btn_state == 1) {
addLog("POOL: web button → ON");
enqueue_work(1, 0);
} else {
addLog("POOL: web button → OFF");
enqueue_work(2, 0);
}
pump_on = pool_btn_state;
snapshot(pool_btn_state);
}
// Target slider — webSlider writes pool_set_input on release. `written()`
// detects the external write; `pool_set_input != pump_temp_set` filters
// out our own optimistic write below; `pool_set_synced` blocks the very
// first tick before tuya_query has aligned the slider with the pump.
if (written(pool_set_input)) {
if (pool_set_synced &&
pool_set_input != pump_temp_set &&
pool_set_input >= 5 && pool_set_input <= 80) {
addLog("POOL: web slider → TEMP %d", pool_set_input);
enqueue_work(3, pool_set_input);
pump_temp_set = pool_set_input; // optimistic, prevents re-fire
}
snapshot(pool_set_input);
}
tick_secs = tick_secs + 1;
if (tick_secs >= 30) {
tick_secs = 0;
if (work_pending) return;
if (taskRunning("PoolWorker")) return;
enqueue_work(0, 0);
}
}
void JsonCall() {
if (last_ok) {
sprintf(resp_msg, ",\"POOL\":{\"v\":%d,\"on\":%d,\"set\":%d,\"cur\":%d,\"state\":\"%s\"}",
protocol_version, pump_on, pump_temp_set, pump_temp_cur, pump_state);
responseAppend(resp_msg);
}
}
void WebCall() {
char stale[12];
if (last_ok) { stale[0] = 0; } else { strcpy(stale, " (stale)"); }
// Section header — visually frames this slot's output as a
// distinct block on the shared / page when running alongside
// other TinyC slots. Section-banner style (dark slate +
// orange left accent) — distinct from the Tasmota blue
// (#1fa3ec) button color so users don't try to tap it. Same
// style as energy_dashboard.tc.
webSend("{s}<hr>{m}<hr>{e}<tr><td colspan=2 style='text-align:left;background:#34495e;color:#fff;padding:8px 12px;border-left:6px solid #e67e22;border-radius:2px;font-size:1.4em;font-weight:bold;letter-spacing:0.5px;'>Pool-Wärmepumpe</td></tr>");
// Four sensor rows in standard Tasmota {s}label{m}value{e} format
sprintf(resp_msg, "{s}Pool Mode{m}%s%s{e}", pump_mode, stale);
webSend(resp_msg);
sprintf(resp_msg, "{s}Pool State{m}%s%s{e}", pump_state, stale);
webSend(resp_msg);
sprintf(resp_msg, "{s}Pool Target{m}%d °C{e}", pump_temp_set);
webSend(resp_msg);
sprintf(resp_msg, "{s}Pool Water{m}%d °C{e}", pump_temp_cur);
webSend(resp_msg);
// Current power draw — sourced from a UDP-broadcast meter, not
// from the Tuya DP set (this pump model doesn't expose power).
sprintf(resp_msg, "{s}Pool Power{m}%d W{e}", (int)pwp);
webSend(resp_msg);
// Seconds since the last successful query. Stays in seconds at all
// magnitudes (no minute/hour rollover) so the display visibly
// increments on every browser poll — no "stuck on 1m ago" effect
// during a failure streak. `last_query_ms` is set in tuya_query()
// on success only, so this keeps growing while the pump misses
// polls (Tuya local TCP can flake when the cloud client is also
// connected) and lets the user judge how stale the values are.
char age_str[16];
if (last_query_ms == 0) {
strcpy(age_str, "—");
} else {
int sec = (millis() - last_query_ms) / 1000;
if (sec < 0) sec = 0; // millis() rollover guard
sprintf(age_str, "%ds ago", sec);
}
sprintf(resp_msg, "{s}Pool Update{m}%s{e}", age_str);
webSend(resp_msg);
// Toggle button — webButton renders "An/Aus: ON" or "An/Aus: OFF"
// from `pool_btn_state`, which mirrors `pump_on` after each query.
// Click flips it 0↔1 and EverySecond dispatches the matching command.
//
// The <tr><td colspan=2> wrapper is REQUIRED — webButton emits a raw
// <div><button>...</button></div>, which is invalid inside a <table>
// and gets foster-parented out by the HTML5 parser, ending up
// visually at the TOP of the live-poll section instead of the bottom
// of the pool block. Wrapping in a colspan=2 cell keeps it in
// table flow.
webSend("<tr><td colspan=2>");
webButton(pool_btn_state, "An/Aus");
webSend("</td></tr>");
// Target-temperature slider — same foster-parenting story; same wrap.
webSend("<tr><td colspan=2>");
webSlider(pool_set_input, 20, 28, "Target °C");
webSend("</td></tr>");
}
int main() {
crc32_init();
addCommand("POOL");
if (!load_config()) {
addLog("POOL: cannot load /pool_pump.cfg — upload a 3-line config (ip / 16-char key / 22-char devId) and restart");
} else {
addLog("POOL: ready — target %s:%d", pump_ip, pump_port);
}
return 0;
}