Zum Inhalt

tuya_pool_heater.tc

tuya_pool_heater.tc — Tuya v3.3 local-protocol client in TinyC

Source on GitHub

// =================================================================
// 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;
}