tuya_pool_heater.tc¶
tuya_pool_heater.tc — Tuya v3.3 local-protocol client in TinyC
// =================================================================
// tuya_pool_heater.tc — Tuya v3.3 local-protocol client in TinyC
// =================================================================
// Drives a Smart-Life-controlled pool heat pump (or any Tuya device
// running protocol v3.3) directly over the LAN. No cloud, no bridge.
//
// Requires: TinyC ≥ 1.3.20 (for aesEcb, hex2bin, bin2hex syscalls)
//
// One-time per-device setup (do this OUTSIDE Tasmota first):
// 1. Install tinytuya on a PC: pip install tinytuya
// 2. Run: tinytuya wizard
// Sign up for a free Tuya IoT developer account, link your
// Smart Life account, and the wizard will dump every device's
// `id` (devId, 22 hex chars) and `key` (local_key, 16 ASCII chars).
// The Tuya account is only needed for THIS extraction step —
// after that everything is fully local.
// 3. Note your pump's LAN IP (router DHCP table or `nmap` for
// open TCP port 6668).
// 4. Plug those three values into the constants below.
//
// Discovering DP (data point) IDs for your specific pump model:
// - Some published lists exist on github.com/jasonacox/tinytuya
// - Or run: tinytuya scan then tinytuya monitor <id>
// - Common pool-heater DPs (Aquark/Madimack/Pioneer family):
// DP 1 = power on/off (bool)
// DP 2 = target water temp (int, °C × 1)
// DP 102 = current water temp (int, °C × 1)
// DP 4 = mode 1=heat 2=cool 3=auto (int)
// DP 17 = silent / boost / normal (int)
// YOUR device's DPs may differ — use `monitor` to find out.
//
// Console commands once running:
// POOLON — turn heater on
// POOLOFF — turn heater off
// POOLTEMP <n> — set target temp to n °C
// POOLSTATUS — query and log current state
//
// =================================================================
// --- Per-device constants — REPLACE THESE ---
char DEV_IP[] = "192.168.1.42"; // pool heater LAN IP
char DEV_ID[] = "bf12345678abcdef0123456789abcd"; // 22-char devId from tinytuya wizard
char KEY_HEX[] = "313233343536373839616263646566"; // 16-char local_key encoded as 32 hex chars
// (run hex2bin once at boot to get raw key)
// shortcut: if local_key is ASCII like "abc...", convert each byte:
// "a"=0x61,"b"=0x62 → "6162..."
// --- Globals ---
char dev_key[16]; // raw 16-byte AES key (filled at boot)
int seq_num = 1; // outgoing packet sequence counter
int tuya_sock = -1; // current TCP socket fd, -1 = not connected
// --- CRC32 (Ethernet polynomial, reflected, used by Tuya headers) ---
// Slow but small (~700 B of code). Tuya packets are short so speed is fine.
// NOTE: TinyC ints are 32-bit signed. The `>> 1` operation might be an
// arithmetic shift (sign-extending), so we mask with 0x7FFFFFFF to force
// logical shift behavior. Otherwise crc=0xFFFFFFFF (= -1 signed) would
// stay at -1 forever and the CRC would always be 0.
int crc32(char data[], int len) {
int crc = 0xFFFFFFFF;
for (int i = 0; i < len; i++) {
int b = data[i] & 0xFF;
crc = crc ^ b;
for (int j = 0; j < 8; j++) {
int mask = -(crc & 1);
crc = ((crc >> 1) & 0x7FFFFFFF) ^ (0xEDB88320 & mask);
}
}
return crc ^ 0xFFFFFFFF;
}
// --- PKCS7 pad: append (16-(len%16)) bytes, each containing that pad value ---
int pkcs7_pad(char buf[], int len) {
int pad = 16 - (len % 16);
for (int i = 0; i < pad; i++) {
buf[len + i] = pad;
}
return len + pad;
}
// --- AES-128-ECB encrypt N blocks in-place. N = len/16. ---
void aes_encrypt_blocks(char buf[], int len) {
char block[16];
for (int i = 0; i < len; i = i + 16) {
for (int j = 0; j < 16; j++) block[j] = buf[i + j];
aesEcb(dev_key, block, 1);
for (int j = 0; j < 16; j++) buf[i + j] = block[j];
}
}
// --- AES-128-ECB decrypt N blocks in-place ---
void aes_decrypt_blocks(char buf[], int len) {
char block[16];
for (int i = 0; i < len; i = i + 16) {
for (int j = 0; j < 16; j++) block[j] = buf[i + j];
aesEcb(dev_key, block, 0);
for (int j = 0; j < 16; j++) buf[i + j] = block[j];
}
}
// --- Build a Tuya v3.3 outgoing packet ---
// Layout:
// 55 AA 00 00 00 00 | prefix
// <seq_num> | 4-byte BE
// <cmd> | 4-byte BE (0x07=CONTROL, 0x0A=DP_QUERY, 0x09=HEARTBEAT)
// <payload_len> | 4-byte BE = len(version_hdr+enc_payload+crc+suffix)
// "3.3" + 12*0x00 | 15-byte version header (CONTROL & DP_QUERY only)
// <enc_payload> | AES-ECB encrypted JSON, PKCS7-padded
// <crc32> | 4-byte BE, computed over everything above EXCEPT prefix... actually OVER prefix to here
// 00 00 AA 55 | suffix
//
// Returns total packet length written into out[].
int tuya_build_packet(char out[], int cmd, char json[], int json_len) {
// 1) Encrypt JSON (with PKCS7 pad). Use a working copy because
// we'll need to keep the unencrypted version around for debugging.
char enc[256];
for (int i = 0; i < json_len; i++) enc[i] = json[i];
int enc_len = pkcs7_pad(enc, json_len);
aes_encrypt_blocks(enc, enc_len);
// 2) Optional version header (only for CONTROL=0x07 and DP_QUERY=0x0a)
int hdr_len = 0;
if (cmd == 7 || cmd == 10) {
hdr_len = 15; // "3.3" + 12 nulls
}
// 3) Compute payload_len: hdr + enc + crc(4) + suffix(4)
int payload_len = hdr_len + enc_len + 4 + 4;
// 4) Write fixed prefix
out[0] = 0x00; out[1] = 0x00; out[2] = 0x55; out[3] = 0xAA;
// 5) Write seq_num (BE)
out[4] = (seq_num >> 24) & 0xFF;
out[5] = (seq_num >> 16) & 0xFF;
out[6] = (seq_num >> 8) & 0xFF;
out[7] = seq_num & 0xFF;
seq_num = seq_num + 1;
// 6) Write cmd (BE)
out[8] = (cmd >> 24) & 0xFF;
out[9] = (cmd >> 16) & 0xFF;
out[10] = (cmd >> 8) & 0xFF;
out[11] = cmd & 0xFF;
// 7) Write payload_len (BE)
out[12] = (payload_len >> 24) & 0xFF;
out[13] = (payload_len >> 16) & 0xFF;
out[14] = (payload_len >> 8) & 0xFF;
out[15] = payload_len & 0xFF;
int pos = 16;
// 8) Optional "3.3" + 12 zero bytes
if (hdr_len > 0) {
out[pos] = '3'; out[pos+1] = '.'; out[pos+2] = '3';
for (int i = 0; i < 12; i++) out[pos + 3 + i] = 0;
pos = pos + 15;
}
// 9) Encrypted payload
for (int i = 0; i < enc_len; i++) out[pos + i] = enc[i];
pos = pos + enc_len;
// 10) CRC32 over bytes [0 .. pos]
int crc = crc32(out, pos);
out[pos] = (crc >> 24) & 0xFF;
out[pos+1] = (crc >> 16) & 0xFF;
out[pos+2] = (crc >> 8) & 0xFF;
out[pos+3] = crc & 0xFF;
pos = pos + 4;
// 11) Suffix
out[pos] = 0x00; out[pos+1] = 0x00;
out[pos+2] = 0xAA; out[pos+3] = 0x55;
pos = pos + 4;
return pos;
}
// --- Open TCP connection to device (port 6668). Returns sock fd or -1. ---
int tuya_connect() {
if (tuya_sock >= 0) {
if (tcpConnected(tuya_sock)) return tuya_sock;
tcpClose(tuya_sock);
tuya_sock = -1;
}
tuya_sock = tcpConnect(DEV_IP, 6668);
if (tuya_sock < 0) {
addLog("TUYA: tcpConnect failed");
return -1;
}
return tuya_sock;
}
// --- Send a CONTROL packet setting one DP to a value ---
// dp = data point id
// value_str = quoted string for the value, e.g. "true", "false", "28", "\"heat\""
// Returns 1 on success, 0 on failure.
int tuya_send_dp(int dp, char value_str[]) {
int s = tuya_connect();
if (s < 0) return 0;
// Build JSON payload. Note Tuya wants devId both as `devId` and `uid`.
// `t` is unix epoch seconds — Tasmota uptime is fine if your pump
// doesn't validate strictly (most don't).
char json[256];
sprintf(json, "{\"devId\":\"%s\",\"uid\":\"%s\",\"t\":\"%d\",\"dps\":{\"%d\":%s}}",
DEV_ID, DEV_ID, tasm_uptime, dp, value_str);
int json_len = strlen(json);
// Build full packet
char pkt[512];
int pkt_len = tuya_build_packet(pkt, 7, json, json_len); // 7 = CONTROL
addLogF("TUYA: send dp=%d val=%s pkt_len=%d", dp, value_str, pkt_len);
int n = tcpWrite(s, pkt, pkt_len);
if (n != pkt_len) {
addLogF("TUYA: tcpWrite short %d/%d", n, pkt_len);
tcpClose(s); tuya_sock = -1;
return 0;
}
// Wait briefly for ACK / status response. Don't strictly need to parse
// it for fire-and-forget commands, but reading drains the buffer.
delay(200);
char resp[512];
int rn = tcpRead(s, resp, sizeof(resp));
if (rn > 0) {
addLogF("TUYA: rx %d bytes (resp ignored)", rn);
// To decode: skip 16-byte header + optional 15-byte version,
// decrypt the rest with AES-ECB, strip PKCS7 padding, parse JSON.
}
return 1;
}
// --- High-level convenience wrappers (edit DPs to match your meter!) ---
int heat_on() { return tuya_send_dp(1, "true"); }
int heat_off() { return tuya_send_dp(1, "false"); }
int heat_set_temp(int t) {
char v[8];
sprintfInt(v, "%d", t);
return tuya_send_dp(2, v);
}
// --- Console command dispatch ---
void Command(char cmd[]) {
char resp[64];
if (strcmp(cmd, "ON") == 0) {
if (heat_on()) responseCmnd("on");
else responseCmnd("fail");
} else if (strcmp(cmd, "OFF") == 0) {
if (heat_off()) responseCmnd("off");
else responseCmnd("fail");
} else if (strncmp(cmd, "TEMP ", 5) == 0) {
int t = atoi(cmd + 5);
if (heat_set_temp(t)) {
sprintfInt(resp, "target=%d", t);
responseCmnd(resp);
} else {
responseCmnd("fail");
}
} else if (strcmp(cmd, "STATUS") == 0) {
// Send DP_QUERY (cmd=10) to ask for current state
int s = tuya_connect();
if (s < 0) { responseCmnd("offline"); return; }
char json[128];
sprintf(json, "{\"gwId\":\"%s\",\"devId\":\"%s\"}", DEV_ID, DEV_ID);
char pkt[256];
int pkt_len = tuya_build_packet(pkt, 10, json, strlen(json));
tcpWrite(s, pkt, pkt_len);
responseCmnd("query sent — check log for response");
} else {
responseCmnd("? ON | OFF | TEMP <c> | STATUS");
}
}
// --- Init: convert hex key string to raw bytes once at boot ---
int main() {
int n = hex2bin(KEY_HEX, strlen(KEY_HEX), dev_key);
if (n != 16) {
addLogF("TUYA: KEY_HEX must be 32 hex chars, got %d bytes", n);
return -1;
}
addLog("TUYA: pool heater client ready, command prefix POOL");
addCommand("POOL");
return 0;
}