Pular para o conteúdo
Node.js

Tomadas Tuya pela API da nuvem, com Node.js

Paloma Macetko
Ilustração de uma tomada inteligente na parede de um castelo, com um unicórnio ao lado e fios de luz subindo até uma nuvem brilhante, passando por um pergaminho lacrado e um mostrador de energia

Olá meus Unicórnios! 🦄✨

Tudo começou com uma pergunta boba: quantos watts o meu PC puxa de verdade? Eu tinha uma tomada inteligente Tuya de 16A medindo energia, comprada para outra coisa, e o app Smart Life mostrava o número na tela. Só que número na tela do celular não vira gráfico, não vira histórico e não me diz quanto tempo o nobreak aguenta. Eu queria aquele valor no meu código.

Achei que seria meia hora. 😅 Pois é.

Foram algumas horas — e, no fim, o que me deu mais trabalho não foi a criptografia da assinatura (essa é chata, mas é receita de bolo). Foi descobrir que a API me respondia com um sorriso no rosto enquanto mentia: sucesso em consulta que não achou nada, sucesso em comando para uma tomada que estava desplugada da parede há duas semanas, e um valor de potência tão convincente que eu levei um tempo até perceber que ele tinha 15 minutos de idade.

Este artigo é o cliente completo em Node puro — só crypto e fetch, sem instalar nada — e as armadilhas que só aparecem quando você liga o troço na tomada de verdade.

Exemplos_TomasTuya no GitHubO cliente completo, sem dependência nenhuma. Credenciais por variável de ambiente.github.com

🔑 Antes do código: criando a sua API key na Tuya

Esta é a parte que mais trava gente — não por ser difícil, mas porque o painel da Tuya é um labirinto e um passo esquecido no meio se manifesta lá na frente como "sua conta não tem dispositivo nenhum". Então vamos com calma, um passo de cada vez. 🙂

1. Crie a conta de desenvolvedor

É uma conta separada da do app Smart Life. Cadastre-se em:

iot.tuya.com — Tuya IoT Development Platformiot.tuya.com

2. Crie um Cloud Project

No menu lateral, vá em Cloud → Development e clique em Create Cloud Project. No formulário:

  • Project Name: o que você quiser (ex.: Casa).
  • Industry e Development Method: escolha Smart Home nos dois. É essa escolha que faz o projeto enxergar as tomadas que já estão no seu app.
  • Data Center: ⚠️ o campo mais importante da tela. Escolha Western America — é o data center que atende o Brasil e o que este tutorial usa (openapi.tuyaus.com).

3. Anote o Access ID e o Access Secret

Criado o projeto, você cai na aba Overview. Ali estão as suas duas credenciais:

  • Access ID / Client ID → é o TUYA_CLIENT_ID do script.
  • Access Secret / Client Secret → é o TUYA_SECRET. Clique no olhinho para revelar.

O secret é senha: não comita no Git, não põe em print. 🔒 No script ele entra por variável de ambiente justamente por isso.

4. Vincule a sua conta do Smart Life — o passo esquecido

Este é o passo que todo mundo pula, e o que mais custa tempo depois. Sem ele, o projeto existe, as credenciais funcionam, a autenticação passa direitinho… e a sua lista de dispositivos vem vazia. 😤

Dentro do projeto, vá na aba DevicesLink Tuya App AccountAdd App Account. Vai aparecer um QR code.

Agora no celular: abra o Smart Life, toque em Eu (canto inferior direito) e no ícone de leitura de QR code, no topo da tela. Leia o código e confirme a autorização.

Feito isso, volte na aba Devices → All Devices: as suas tomadas têm que aparecer listadas ali, com o Device ID de cada uma. Se a lista estiver vazia, o vínculo não pegou — refaça a leitura do QR code antes de escrever qualquer linha de código, porque nenhuma API vai inventar dispositivo que ela não enxerga.

5. Confira as autorizações do projeto

Ainda no projeto, a aba Service API lista as APIs liberadas. Confirme que estão lá as duas que este tutorial usa: IoT Core e Authorization. Elas costumam vir habilitadas por padrão em projeto novo; se faltarem, use Go to Authorize para adicionar.

Pronto — com o Access ID, o Access Secret e as tomadas aparecendo na aba Devices, você tem tudo. Agora sim, o código. 🚀

🔐 A assinatura: o pedaço que parece difícil (mas não é)

A Tuya assina cada requisição com HMAC-SHA256. A regra tem um detalhe que derruba quase toda primeira tentativa, então vou direto ao ponto:

// stringToSign = METODO \n SHA256(corpo) \n headers \n caminho
const stringToSign = `${method}\n${sha256(body)}\n\n${urlPath}`;

// Para pegar o TOKEN:
sign = HMAC(clientId + t + nonce + stringToSign)

// Para QUALQUER outra chamada — repare onde o token entra:
sign = HMAC(clientId + access_token + t + nonce + stringToSign)

Isso mesmo, são duas fórmulas diferentes. 🤯 O access_token entra entre o client_id e o timestamp — não no fim, que é onde a intuição manda colocar. Errar aqui dá sign invalid, e a mensagem não faz a menor ideia de te contar qual das duas você deveria estar usando.

Mais três detalhes que custam tempo:

  • O resultado do HMAC vai em hexadecimal MAIÚSCULO.
  • Aquela linha vazia no meio (\n\n) é o campo de headers assinados. Ele é opcional, mas a linha em branco não é — some com ela e a assinatura quebra.
  • O SHA256 do corpo é calculado mesmo em GET, sobre string vazia. Não pule.

Em Node moderno isso não precisa de nenhuma biblioteca:

const crypto = require('crypto');

_sha256(s) {
  return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}

_hmac(s) {
  return crypto.createHmac('sha256', this.secret)
    .update(s, 'utf8').digest('hex').toUpperCase();
}

// A string assinada muda conforme a chamada seja de token ou de negócio:
// no segundo caso o access_token entra ENTRE o client_id e o timestamp.
_headers(method, urlPath, body, comToken) {
  const t = String(Date.now());
  const nonce = crypto.randomUUID();
  const stringToSign = `${method}\n${this._sha256(body)}\n\n${urlPath}`;
  const base = comToken
    ? this.clientId + this.token + t + nonce + stringToSign
    : this.clientId + t + nonce + stringToSign;

  const h = {
    client_id: this.clientId,
    sign: this._hmac(base),
    t,
    sign_method: 'HMAC-SHA256',
    nonce,
    'Content-Type': 'application/json',
  };
  if (comToken) h.access_token = this.token;
  return h;
}

O token vale duas horas. Eu renovo com 60 segundos de folga, e essa folga não é frescura: sem ela, uma requisição que sai faltando três segundos para expirar chega do outro lado já vencida, e você perde a leitura por um motivo que não vai reproduzir depois. 😤

async _garanteToken() {
  // 60s de folga: um token que expira no meio do voo custa uma requisição perdida.
  if (this.token && Date.now() < this.tokenExpira - 60000) return;
  const r = await this._req('GET', '/v1.0/token?grant_type=1', { comToken: false });
  this.token = r.access_token;
  this.tokenExpira = Date.now() + r.expire_time * 1000;
}

🕵️ Mentira nº 1: "sucesso" com a lista vazia

Com a assinatura funcionando, fui listar minhas tomadas. Todo tutorial manda usar o endpoint de dispositivos do IoT Core:

GET /v1.3/iot-03/devices?page_size=100

E a resposta foi esta:

{
    "result": {
        "has_more": false,
        "last_row_key": "",
        "list": [],
        "total": 0
    },
    "success": true
}

Repare com carinho: "success": true. E "total": 0.

Passei um tempo bom refazendo o vínculo do app, relendo o QR code, conferindo o data center, desconfiando da assinatura — porque a única coisa que a API me dizia é que tinha dado certo. Um erro teria sido um presente: "você não tem permissão", "projeto sem dispositivos vinculados", qualquer coisa. Um sucesso vazio parece resposta correta e te faz procurar o bug em você.

Não era permissão. Era endpoint errado. Para projetos do tipo Smart Home — que é o que a gente cria ao vincular o Smart Life — quem enxerga os aparelhos é a rota de things:

GET /v2.0/cloud/thing/device?page_size=20

E aí vieram os meus 16 dispositivos. Mas ela cobra o troco em dois detalhes:

// Descoberta de dispositivos. NÃO use /v1.3/iot-03/devices: ela responde
// success:true com total:0 quando o projeto é do tipo "Smart Home" — um
// sucesso vazio, que se lê como "você não tem tomada nenhuma".
async listarDispositivos() {
  await this._garanteToken();
  // page_size aqui vai ate 20 — pedir 100 devolve "param size too much".
  const r = await this._req('GET', '/v2.0/cloud/thing/device?page_size=20');
  return Array.isArray(r) ? r : (r.list || []);
}

Um: o page_size desta rota vai até 20. Os mesmos 100 que a v1.3 aceitava numa boa aqui viram param size too much.

Dois: o campo do nome muda. O nome que você deu no app é customName; o name é o nome do produto — todas as minhas tomadas se chamam "Tomada Inteligente 16A Wi-Fi", o que é maravilhosamente inútil para escolher qual ligar. E o online é isOnline, não online. 🙄

// `customName` e o nome que voce deu no app; `name` e o do produto.
const nome = d.customName || d.name || '(sem nome)';
const cat = d.category === 'cz' ? ' <- tomada com medicao' : '';
console.log(`  ${d.isOnline ? 'on ' : 'OFF'}  ${String(d.id).padEnd(24)}  ${nome}${cat}`);

Resultado — a saída real aqui de casa, com os identificadores e os nomes trocados por fictícios (não é dado que se publica 😉):

$ node tuya.js listar
16 dispositivo(s):

  on   ebaaaaaaaaaaaaaaaaaaaa    Tomada Sala <- tomada com medicao
  OFF  ebbbbbbbbbbbbbbbbbbbbb    Tomada Varanda <- tomada com medicao
  on   ebcccccccccccccccccccc    Tomada Escritorio <- tomada com medicao
  on   ebdddddddddddddddddddd    Ar Condicionado
  on   ebeeeeeeeeeeeeeeeeeeee    Controle IR
  on   ebffffffffffffffffffff    Interruptor Corredor
  on   eb11111111111111111111    Camera Quintal
  OFF  eb22222222222222222222    Bomba Jardim

Aquele category === 'cz' é o que separa tomada com medição de energia do resto. Guarde a sigla — ela salva a próxima seção.

📏 As escalas: 2736 não são 2.736 watts

Lendo o status de uma tomada, a resposta vem como uma lista de pares code / value. Este é um recorte real da minha:

[
    {
        "code": "switch_1",
        "value": true
    },
    {
        "code": "cur_current",
        "value": 1441
    },
    {
        "code": "cur_power",
        "value": 2736
    },
    {
        "code": "cur_voltage",
        "value": 2055
    }
]

Meu PC não puxa 2.736 W e a tomada da parede não tem 2.055 volts. 😂 Os inteiros vêm multiplicados, e o multiplicador está na specification do aparelho, num campo scale: scale: 1 quer dizer "divida por 10¹".

Aí está a pegadinha que me pegou de verdade. Olhe a especificação real das minhas tomadas:

dp 18  cur_current  unit: mA   scale: 0
dp 19  cur_power    unit: W    scale: 1
dp 20  cur_voltage  unit: V    scale: 1

Potência e tensão têm scale: 1 — divide por 10 e pronto: 273,6 W e 205,5 V. Mas a corrente tem scale: 0. Se você aplicar a escala mecanicamente, como eu apliquei, divide por 10⁰ = 1 e conclui que o PC puxa 1441 amperes. 🤯

A escala está certa, e é justamente esse o problema: ela descreve a casa decimal, não a unidade. A unidade é mA. Quem divide por mil é você, e a API não vai te avisar — porque, do ponto de vista dela, ela nunca prometeu ampere.

Por isso a conversão de unidade mora num lugar só, dentro do adapter:

// Converte os codes crus em grandezas com unidade de gente.
function medidas(codes, escalas) {
  const f = (code) => escalas[code] || 1;
  // Number(" ") é 0 e Number(null) é 0: um zero falso que passa por medição
  // legítima. Só aceita o que já veio numérico da API.
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
  const p = num(codes.cur_power);
  const v = num(codes.cur_voltage);
  const i = num(codes.cur_current);
  return {
    ligada: codes.switch_1 === true,
    watts: p === null ? null : p / f('cur_power'),
    volts: v === null ? null : v / f('cur_voltage'),
    amperes: i === null ? null : i / 1000, // mA -> A (a scale do DP é 0)
  };
}

Repare no num(). Ele não é paranoia: em JavaScript, Number(" ") e Number(null) valem zero. Num monitor de energia, um zero falso é o pior resultado possível — ele não parece bug, parece tomada desligada, e você vai olhar para o gráfico achando que economizou energia. Por isso só aceito o que já chegou numérico da API. 🙏

E não fixe as escalas no código: leia da specification e guarde. Ela é estável, então basta uma chamada, reaproveitada no resto do programa:

// A specification é estável: busque UMA vez e reaproveite no loop.
const spec = await tc.getSpec(deviceId);
const escalas = escalasDe(spec);
const aceita = ((spec && spec.functions) || []).map((f) => f.code);

⏰ Mentira nº 2: o número certo, na hora errada

Se você só for ler um pedaço deste artigo, leia este. 🙏

Com tudo funcionando, montei o monitor e mandei ler a cada 5 segundos. Saída:

18:03:21  LIGADA  |   319.0 W |   217.7 V |   1.553 A
18:03:26  LIGADA  |   319.0 W |   217.7 V |   1.553 A
18:03:32  LIGADA  |   319.0 W |   217.7 V |   1.553 A
18:03:38  LIGADA  |   319.0 W |   217.7 V |   1.553 A
18:03:43  LIGADA  |   319.0 W |   217.7 V |   1.553 A
18:03:49  LIGADA  |   319.0 W |   217.7 V |   1.553 A

Bonito, né? Seis leituras, valores estáveis, tudo idêntico até a terceira casa decimal. Foi essa terceira casa que me incomodou: 1,553 A seis vezes seguidas é estabilidade demais para um PC que estava ali compilando coisa.

O /status devolve valor e mais nada. Mas existe uma rota irmã que devolve, junto de cada dado, o instante em que o aparelho reportou aquilo:

GET /v2.0/cloud/thing/{device_id}/shadow/properties

Rodei e calculei a idade de cada campo. Prepare-se:

switch_1        true  time= 1784755475388  idade= 1208370s
cur_current     1553  time= 1785963103545  idade= 741s
cur_power       3190  time= 1785963103545  idade= 741s
cur_voltage     2177  time= 1785963103545  idade= 741s

741 segundos. Meus "dados em tempo real" tinham doze minutos. 😳

E não era um azar momentâneo. Deixei rodando por dois minutos e meio, lendo a cada 15 segundos:

18:04:18  cur_power= 3190  idade=  755s
18:04:34  cur_power= 3190  idade=  771s
18:04:49  cur_power= 3190  idade=  786s
18:05:05  cur_power= 3190  idade=  802s
18:05:21  cur_power= 3190  idade=  818s
18:05:36  cur_power= 3190  idade=  833s
18:05:52  cur_power= 3190  idade=  849s
18:06:08  cur_power= 3190  idade=  865s
18:06:23  cur_power= 3190  idade=  880s
18:06:39  cur_power= 3190  idade=  896s

O valor nunca mudou e a idade só cresceu, de 755s para 896s, batendo certinho com o relógio. Ou seja: em dez requisições eu não recebi nenhuma medição nova. Recebi dez vezes a mesma medição velha, cada vez mais velha.

A explicação é simples e muda tudo: a nuvem não pergunta nada à tomada quando você chama a API. Quem fala é a tomada, quando ela quer — e ela é econômica, reportando só de tempos em tempos ou quando o valor muda o bastante. A nuvem guarda o último recado e repete para quem perguntar. Consultar mais rápido não acelera nada; só gasta a sua cota de chamadas para receber o mesmo eco. 🔁

A correção não é ler mais rápido, é parar de fingir que você não sabe a idade. Passei o monitor para a rota com carimbo e fiz o próprio programa dizer a verdade na cara:

// Achata a lista de propriedades em {code: valor} e guarda o carimbo mais
// recente entre os DPs de medição — é a "idade" da leitura.
function codesDe(props) {
  const c = {};
  let medidoEm = 0;
  for (const p of props) {
    c[p.code] = p.value;
    if (p.time && ['cur_power', 'cur_voltage', 'cur_current'].includes(p.code)) {
      if (p.time > medidoEm) medidoEm = p.time;
    }
  }
  c.__medidoEm = medidoEm;
  return c;
}

E na hora de imprimir, um aviso que não dá para ignorar:

// Acima de 60s a leitura provavelmente não descreve mais o presente.
const idade = m.idadeS === null ? '' :
  `  (${m.idadeS}s atras${m.idadeS > 60 ? ' - VELHA' : ''})`;

O mesmo monitor, agora honesto:

18:07:40  LIGADA  |   319.0 W |   217.7 V |   1.553 A  (957s atras - VELHA)
18:07:46  LIGADA  |   319.0 W |   217.7 V |   1.553 A  (963s atras - VELHA)
18:07:51  LIGADA  |   319.0 W |   217.7 V |   1.553 A  (968s atras - VELHA)
18:07:57  LIGADA  |   319.0 W |   217.7 V |   1.553 A  (974s atras - VELHA)
18:08:03  LIGADA  |   319.0 W |   217.7 V |   1.553 A  (980s atras - VELHA)

Os números são exatamente os mesmos. O que mudou é que agora eu sei. E saber muda a decisão: se você vai gravar isso num banco para calcular consumo, gravar o carimbo do aparelho junto — e não só a hora em que você perguntou — é a diferença entre um histórico real e um gráfico de mentirinha com dez pontos idênticos enfileirados.

💡 Ligando e desligando (e o erro 2008)

Comandar é bem mais simples que ler: um POST com uma lista de comandos.

async enviarComandos(deviceId, commands) {
  await this._garanteToken();
  return this._req('POST', `/v1.0/iot-03/devices/${deviceId}/commands`, {
    body: { commands },
  });
}

async setSwitch(deviceId, ligar, code = 'switch_1') {
  return this.enviarComandos(deviceId, [{ code, value: !!ligar }]);
}

Fui testar num aparelho chamado "Comedor Noturno" — pelo nome, o comedouro automático do meu gato. Levei isto:

Erro: Tuya /v1.0/iot-03/devices/.../commands: command or value not support (code 2008)

Fui olhar a specification dele. E aí a ficha caiu:

Comedor Noturno -> category: sp | functions: basic_indicator, basic_flip, basic_osd, basic_nightvision
PcCritico       -> category: cz | functions: switch_1, countdown_1, relay_status, overcharge_switch

Categoria sp. basic_nightvision. Gente, o "Comedor Noturno" é uma câmera — a que fica de olho no comedouro. 😅 O nome que eu dei no app anos atrás descrevia o *lugar*, não o aparelho, e eu estava mandando switch_1 para um bicho que só entende comandos de vídeo.

A lição vale além da piada: o nome no app não diz o que a coisa é, e switch_1 não é universal — é o code da categoria cz. Em vez de mandar o comando e torcer, pergunte antes:

// Pergunte ANTES de mandar. O nome no app não diz a categoria: uma câmera
// batizada de "Comedor Noturno" aceita `basic_flip`, não `switch_1`, e a
// nuvem só responde "command or value not support" (code 2008).
if (!aceita.includes('switch_1')) {
  throw new Error(
    `Este aparelho (categoria ${spec.category}) não tem switch_1. ` +
    `Aceita: ${aceita.join(', ') || '(nenhum comando)'}`
  );
}

Agora o erro é meu, é antes de sair da minha máquina, e ele ensina:

$ node tuya.js toggle ebcccccccccccccccccccc
Erro: Este aparelho (categoria sp) não tem switch_1.
      Aceita: basic_indicator, basic_flip, basic_osd, basic_nightvision

👻 Mentira nº 3: comandar uma tomada que não existe

Esta foi a que mais me assustou, e ela fecha o assunto das duas anteriores.

Testei o on numa tomada antiga, a "Natal". A nuvem aceitou o comando — sem erro nenhum, success: true, código de saída zero:

$ node tuya.js on ebdddddddddddddddddddd
antes:  DESLIG. |     0.0 W |   216.2 V |   0.000 A  (1238391s atras - VELHA)
depois: DESLIG. |     0.0 W |   216.2 V |   0.000 A  (1238393s atras - VELHA)

Olhe a idade: 1.238.393 segundos. São catorze dias. Essa tomada está literalmente desplugada da parede desde o mês passado — e mesmo assim a API me disse que o comando foi um sucesso, e me devolveu com toda a confiança do mundo uma tensão de 216,2 V que foi medida há duas semanas.

Se eu tivesse confiado só no retorno do POST, teria um script alegremente "ligando" uma tomada fantasma para sempre, sem nunca perceber. E se eu tivesse confiado só nos volts, teria concluído que havia energia chegando num aparelho que não está conectado a nada. 😳

Por isso o meu on/off relê o estado depois de mandar — e por isso a idade da leitura aparece em toda linha. Uma pausa antes de reler também é necessária, porque a nuvem responde no instante em que aceita o comando, não quando o relé mexe:

await tc.setSwitch(deviceId, alvo);

// O comando volta true assim que a nuvem aceita — o relé ainda não mexeu.
await new Promise((r) => setTimeout(r, 1500));
console.log('depois: ' + linha(medidas(codesDe(await tc.getPropriedades(deviceId)), escalas)));

A regra que eu levo daqui: em IoT, "o comando foi aceito" e "a coisa aconteceu" são dois fatos diferentes. A API só te conta o primeiro. O segundo você confere.

🧾 O script completo

Este é o arquivo inteiro, do jeito que rodou nas saídas acima. Salve como tuya.js — não precisa de package.json, não precisa de npm install. Só Node 18 ou mais novo, pelo fetch nativo.

export TUYA_CLIENT_ID=seu_access_id
export TUYA_SECRET=seu_access_secret

node tuya.js listar
node tuya.js status <deviceId>
node tuya.js monitor <deviceId> 15
node tuya.js on|off|toggle <deviceId>
/**
 * tuya.js — Monitor e controle de tomadas inteligentes Tuya pela nuvem.
 *
 * Node 18+ puro: só `crypto` e `fetch` nativos. Sem dependências.
 *
 * Uso:
 *   node tuya.js status                 # lê a tomada padrão
 *   node tuya.js status <deviceId>      # lê outra tomada
 *   node tuya.js listar                 # lista os dispositivos do projeto
 *   node tuya.js on|off|toggle [id]     # liga / desliga / inverte
 *   node tuya.js monitor [id] [seg]     # fica lendo a cada N segundos
 *
 * Credenciais por variável de ambiente:
 *   TUYA_CLIENT_ID, TUYA_SECRET, TUYA_DEVICE_ID
 */
'use strict';

const crypto = require('crypto');

// Data center Western America. Se o seu projeto estiver em outro, troque aqui
// — o host tem de ser o MESMO onde as credenciais foram criadas.
const HOST = 'https://openapi.tuyaus.com';

class TuyaCloud {
  constructor({ clientId, secret }) {
    if (!clientId || !secret) throw new Error('Faltam TUYA_CLIENT_ID / TUYA_SECRET.');
    this.clientId = clientId;
    this.secret = secret;
    this.token = null;
    this.tokenExpira = 0;
  }

  _sha256(s) {
    return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
  }

  _hmac(s) {
    return crypto.createHmac('sha256', this.secret)
      .update(s, 'utf8').digest('hex').toUpperCase();
  }

  // A string assinada muda conforme a chamada seja de token ou de negócio:
  // no segundo caso o access_token entra ENTRE o client_id e o timestamp.
  _headers(method, urlPath, body, comToken) {
    const t = String(Date.now());
    const nonce = crypto.randomUUID();
    const stringToSign = `${method}\n${this._sha256(body)}\n\n${urlPath}`;
    const base = comToken
      ? this.clientId + this.token + t + nonce + stringToSign
      : this.clientId + t + nonce + stringToSign;

    const h = {
      client_id: this.clientId,
      sign: this._hmac(base),
      t,
      sign_method: 'HMAC-SHA256',
      nonce,
      'Content-Type': 'application/json',
    };
    if (comToken) h.access_token = this.token;
    return h;
  }

  async _req(method, urlPath, { body, comToken = true } = {}) {
    const corpo = body ? JSON.stringify(body) : '';
    const res = await fetch(HOST + urlPath, {
      method,
      headers: this._headers(method, urlPath, corpo, comToken),
      body: corpo || undefined,
    });
    const json = await res.json();
    if (!json.success) {
      const e = new Error(`Tuya ${urlPath}: ${json.msg || 'erro'} (code ${json.code})`);
      e.tuya = json;
      throw e;
    }
    return json.result;
  }

  async _garanteToken() {
    // 60s de folga: um token que expira no meio do voo custa uma requisição perdida.
    if (this.token && Date.now() < this.tokenExpira - 60000) return;
    const r = await this._req('GET', '/v1.0/token?grant_type=1', { comToken: false });
    this.token = r.access_token;
    this.tokenExpira = Date.now() + r.expire_time * 1000;
  }

  // Leitura com `time` por DP — o instante em que a tomada reportou aquele
  // valor. Existe também /v1.0/iot-03/devices/{id}/status, mais conhecida,
  // mas ela devolve só o valor: sem o carimbo você não tem como saber se
  // está lendo uma medição de agora ou a mesma de 15 minutos atrás.
  async getPropriedades(deviceId) {
    await this._garanteToken();
    const r = await this._req('GET', `/v2.0/cloud/thing/${deviceId}/shadow/properties`);
    return r.properties || [];
  }

  // Traz o mapping dos DPs — é de onde saem a escala de cada grandeza E a
  // lista de comandos que o aparelho realmente aceita.
  async getSpec(deviceId) {
    await this._garanteToken();
    return this._req('GET', `/v1.0/iot-03/devices/${deviceId}/specification`);
  }

  // Descoberta de dispositivos. NÃO use /v1.3/iot-03/devices: ela responde
  // success:true com total:0 quando o projeto é do tipo "Smart Home" — um
  // sucesso vazio, que se lê como "você não tem tomada nenhuma".
  async listarDispositivos() {
    await this._garanteToken();
    // page_size aqui vai ate 20 — pedir 100 devolve "param size too much".
    const r = await this._req('GET', '/v2.0/cloud/thing/device?page_size=20');
    return Array.isArray(r) ? r : (r.list || []);
  }

  async enviarComandos(deviceId, commands) {
    await this._garanteToken();
    return this._req('POST', `/v1.0/iot-03/devices/${deviceId}/commands`, {
      body: { commands },
    });
  }

  async setSwitch(deviceId, ligar, code = 'switch_1') {
    return this.enviarComandos(deviceId, [{ code, value: !!ligar }]);
  }
}

// --- Escalas ----------------------------------------------------------------
// A specification devolve, por DP, um `values` em JSON-string com `scale`.
// scale N significa: o inteiro recebido está multiplicado por 10^N.
// ATENÇÃO: `cur_current` tem unit "mA" e scale 0 — a escala NÃO converte para
// ampere. Quem divide por 1000 é você, e é por isso que a conversão de unidade
// mora aqui, num lugar só.
function escalasDe(spec) {
  const out = {};
  for (const s of (spec && spec.status) || []) {
    let v = {};
    try { v = typeof s.values === 'string' ? JSON.parse(s.values) : (s.values || {}); }
    catch (_) { v = {}; }
    out[s.code] = 10 ** (Number(v.scale) || 0);
  }
  return out;
}

// Achata a lista de propriedades em {code: valor} e guarda o carimbo mais
// recente entre os DPs de medição — é a "idade" da leitura.
function codesDe(props) {
  const c = {};
  let medidoEm = 0;
  for (const p of props) {
    c[p.code] = p.value;
    if (p.time && ['cur_power', 'cur_voltage', 'cur_current'].includes(p.code)) {
      if (p.time > medidoEm) medidoEm = p.time;
    }
  }
  c.__medidoEm = medidoEm;
  return c;
}

// Converte os codes crus em grandezas com unidade de gente.
function medidas(codes, escalas) {
  const f = (code) => escalas[code] || 1;
  // Number(" ") é 0 e Number(null) é 0: um zero falso que passa por medição
  // legítima. Só aceita o que já veio numérico da API.
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
  const p = num(codes.cur_power);
  const v = num(codes.cur_voltage);
  const i = num(codes.cur_current);
  return {
    ligada: codes.switch_1 === true,
    watts: p === null ? null : p / f('cur_power'),
    volts: v === null ? null : v / f('cur_voltage'),
    amperes: i === null ? null : i / 1000, // mA -> A (a scale do DP é 0)
    idadeS: codes.__medidoEm ? Math.round((Date.now() - codes.__medidoEm) / 1000) : null,
  };
}

function linha(m) {
  const n = (x, d, u) => (x === null ? '  --' : x.toFixed(d) + ' ' + u);
  // Acima de 60s a leitura provavelmente não descreve mais o presente.
  const idade = m.idadeS === null ? '' :
    `  (${m.idadeS}s atras${m.idadeS > 60 ? ' - VELHA' : ''})`;
  return [
    m.ligada ? 'LIGADA ' : 'DESLIG.',
    n(m.watts, 1, 'W').padStart(9),
    n(m.volts, 1, 'V').padStart(9),
    n(m.amperes, 3, 'A').padStart(9),
  ].join(' | ') + idade;
}

function hora(d) {
  const p = (n) => String(n).padStart(2, '0');
  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}

// --- CLI --------------------------------------------------------------------
async function main() {
  const [acao = 'status', arg1, arg2] = process.argv.slice(2);

  const tc = new TuyaCloud({
    clientId: process.env.TUYA_CLIENT_ID,
    secret: process.env.TUYA_SECRET,
  });

  if (acao === 'listar') {
    const devs = await tc.listarDispositivos();
    console.log(`${devs.length} dispositivo(s):\n`);
    for (const d of devs) {
      // `customName` e o nome que voce deu no app; `name` e o do produto.
      const nome = d.customName || d.name || '(sem nome)';
      const cat = d.category === 'cz' ? ' <- tomada com medicao' : '';
      console.log(`  ${d.isOnline ? 'on ' : 'OFF'}  ${String(d.id).padEnd(24)}  ${nome}${cat}`);
    }
    return;
  }

  const deviceId = arg1 || process.env.TUYA_DEVICE_ID;
  if (!deviceId) throw new Error('Informe o deviceId (argumento ou TUYA_DEVICE_ID).');

  // A specification é estável: busque UMA vez e reaproveite no loop.
  const spec = await tc.getSpec(deviceId);
  const escalas = escalasDe(spec);
  const aceita = ((spec && spec.functions) || []).map((f) => f.code);

  // Uma leitura completa: busca, achata e converte.
  const ler = async () => medidas(codesDe(await tc.getPropriedades(deviceId)), escalas);

  if (acao === 'status') {
    console.log(linha(await ler()));
    return;
  }

  if (acao === 'monitor') {
    const seg = Math.max(2, Number(arg2) || 5); // a nuvem limita chamadas
    console.log(`Lendo a cada ${seg}s — Ctrl+C para sair.\n`);
    for (;;) {
      try {
        console.log(`${hora(new Date())}  ${linha(await ler())}`);
      } catch (e) {
        // Uma leitura que falha não derruba o monitor: só perde este tick.
        console.error(`${hora(new Date())}  erro: ${e.message}`);
      }
      await new Promise((r) => setTimeout(r, seg * 1000));
    }
  }

  if (['on', 'off', 'toggle'].includes(acao)) {
    // Pergunte ANTES de mandar. O nome no app não diz a categoria: uma câmera
    // batizada de "Comedor Noturno" aceita `basic_flip`, não `switch_1`, e a
    // nuvem só responde "command or value not support" (code 2008).
    if (!aceita.includes('switch_1')) {
      throw new Error(
        `Este aparelho (categoria ${spec.category}) não tem switch_1. ` +
        `Aceita: ${aceita.join(', ') || '(nenhum comando)'}`
      );
    }

    const antes = await ler();
    console.log('antes:  ' + linha(antes));

    const alvo = acao === 'on' ? true : acao === 'off' ? false : !antes.ligada;
    if (alvo === antes.ligada) {
      console.log(`ja esta ${alvo ? 'LIGADA' : 'DESLIGADA'}; nada a fazer.`);
      return;
    }

    await tc.setSwitch(deviceId, alvo);

    // O comando volta true assim que a nuvem aceita — o relé ainda não mexeu.
    await new Promise((r) => setTimeout(r, 1500));
    console.log('depois: ' + linha(await ler()));
    return;
  }

  throw new Error(`acao desconhecida: ${acao}`);
}

main().catch((e) => {
  console.error('Erro:', e.message);
  if (e.tuya) console.error(e.tuya);
  process.exit(1);
});

📋 O resumo que eu queria ter lido antes

  • Duas fórmulas de assinatura, e no caso com token ele entra entre o client_id e o timestamp. Hex maiúsculo, e a linha em branco do \n\n não é decorativa.
  • Para listar, use /v2.0/cloud/thing/device. A rota /v1.3/iot-03/devices responde success: true com lista vazia — o pior tipo de resposta que existe. E aí o page_size vai só até 20.
  • O nome amigável é customName; o name é o do produto e é igual para todas as tomadas iguais.
  • Leia as escalas da specification, uma vez só — mas lembre que scale descreve a casa decimal, não a unidade. cur_current vem em mA com scale: 0: os mil você divide.
  • Valide número como número. Number(" ") === 0, e um zero falso num medidor de energia passa despercebido porque parece "desligado".
  • Meça a idade do dado com /shadow/properties. Consultar de 5 em 5 segundos não te dá dados de 5 em 5 segundos — te dá o mesmo eco, cada vez mais velho.
  • Cheque o code antes de comandar. switch_1 é da categoria cz; mandar isso para uma câmera devolve code 2008.
  • Comando aceito ≠ comando executado. Uma tomada desplugada há catorze dias aceitou meu on com success: true. Releia o estado e olhe a idade.

No fim, o que essa brincadeira toda me deu não foi o script — foi o histórico. Com o watt real gravando num banco, dá para responder coisas que o app nunca responderia: quanto o PC puxa em média ao longo do dia, quanto custa por mês, e quanto tempo o nobreak aguenta com essa carga. Mas isso já é assunto para outro artigo. 😊

💾 O repositório

O cliente está lá inteiro — e o README resume as três armadilhas, com a conversão de escala conferida naqueles mesmos 2736 e 1441:

Exemplos_TomasTuya no GitHubnode tuya.js listar. Só precisa do Node 18+ e das suas credenciais no .env.github.com

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também