Tomadas Tuya pela API da nuvem, com Node.js
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.
🔑 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_IDdo 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 Devices → Link Tuya App Account → Add 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
SHA256do 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_ide o timestamp. Hex maiúsculo, e a linha em branco do\n\nnão é decorativa. - Para listar, use
/v2.0/cloud/thing/device. A rota/v1.3/iot-03/devicesrespondesuccess: truecom lista vazia — o pior tipo de resposta que existe. E aí opage_sizevai só até 20. - O nome amigável é
customName; onameé o do produto e é igual para todas as tomadas iguais. - Leia as escalas da
specification, uma vez só — mas lembre quescaledescreve a casa decimal, não a unidade.cur_currentvem em mA comscale: 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 categoriacz; mandar isso para uma câmera devolvecode 2008. - Comando aceito ≠ comando executado. Uma tomada
desplugada há catorze dias aceitou meu
oncomsuccess: 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:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.