Pular para o conteúdo
Home Assistant

Tomadas Tuya pelo Home Assistant, por código

Paloma Macetko
Ilustração de um castelo em corte com tomadas coloridas brilhando nas paredes, fios de luz subindo até um painel de controle com uma coruja, um feixe saindo em direção a uma nuvem e um unicórnio observando um mostrador de watts e volts

Olá meus Unicórnios! 🦄✨

Eu tenho um projeto em casa que vigia o nobreak do escritório, mede o consumo das tomadas e, numa queda de energia, vai desligando as coisas em ordem para a bateria durar até o computador crítico poder ser salvo. Duas tomadas Tuya inteligentes fazem a medição — uma na carga crítica, outra no que pode morrer sem dó.

Até semana passada, o backend falava direto com a nuvem da Tuya: assinatura HMAC-SHA256 em cada requisição, token com validade, um devices.json para traduzir nome em deviceId e as escalas de cada medida. Funciona, mas é bastante protocolo morando do meu lado.

Funcionava bem. Até o dia em que parou de funcionar — e é por isso que este artigo existe.

Exemplos_HomeAssistant_TomasTuya no GitHubO script completo, com os seis caminhos de erro tipados. Node puro, zero dependência.github.com

💥 O dia em que a nuvem da Tuya me abandonou

Três e pouco da manhã. Meu sistema simplesmente parou de enxergar as tomadas. Nada de leitura, nada de watts, nada. O painel inteiro cego. 😱

Minha primeira suspeita foi a de sempre: credencial. Chave errada, token expirado, Local Key trocada depois de reprovisionar. Fui no log procurar a mensagem — e o que estava lá não era nada disso:

{"code":28841004,
 "erro":"Tuya API /v1.0/iot-03/devices/eb05...ann/status falhou:
         IoT Core trial quota is exhausted. (code 28841004)",
 "level":"error","message":"falha","tomada":"PcCritico"}

"IoT Core trial quota is exhausted." A cota do projeto na plataforma IoT da Tuya tinha acabado. 🫠

E aqui vai o detalhe cruel: a minha assinatura HMAC estava perfeita. A nuvem aceitava meu client_id, validava a assinatura, conferia o token — e só então respondia "sua cota acabou". Não era bug meu, não era credencial, não era Local Key. Trocar as chaves no .env não resolveria absolutamente nada, e eu perdi um tempo bom tentando exatamente isso antes de ler o código de erro com atenção.

O tamanho do estrago, contado pelo log daquele dia:

$ grep -c "28841004" backend/logs/tuya.log
9750
$ grep -c '"leitura"' backend/logs/tuya.log
511

9.750 falhas contra 511 leituras boas em um único dia — mais de dez horas de sistema cego, da madrugada até o meio da tarde. E, o que me tirou o sono: se tivesse faltado energia naquela madrugada, a Auto Proteção não teria a menor ideia do que estava acontecendo com as tomadas. O nobreak seguiria descarregando enquanto meu painel mostrava... nada.

Descobri depois que a cota do IoT Core é um trial de mais ou menos um mês, que você renova no iot.tuya.com → Cloud → projeto → Service API. Renovei, voltou a funcionar. Mas o recado ficou: meu sistema de proteção contra queda de energia dependia de uma cota gratuita expirando do outro lado do mundo, sem me avisar. 😤

Foi esse susto que me fez subir uma VM com Home Assistant. Ela já enxergava as duas tomadas, e a ideia foi imediata: e se eu falasse com elas pelo HA? Some a assinatura, some o token da Tuya, some o devices.json — e, principalmente, some aquela dependência que tinha acabado de me deixar na mão.

Funcionou — e é sobre isso este artigo. Mas eu descobri, medindo, que duas das coisas que eu achava que ganharia simplesmente não eram verdade. 😅 A primeira delas, aliás, é justamente essa daí de cima.

🛒 Qual tomada eu uso (e o detalhe que muda tudo)

Antes do código, o hardware — porque tem um detalhe aqui que, se você errar na compra, derruba metade deste artigo. 🙈

As minhas são estas:

Tomada Inteligente Wi-Fi Novadigital 10A com Medidor de Consumo (modelo GH 10A)mercadolivre.com.br

Plugue padrão brasileiro, 10 A, Wi-Fi 2,4 GHz, app Smart Life ou Tuya, e funciona com Alexa e Google.

Agora o detalhe: tem que ser o modelo "com medidor de consumo". Existe um monte de tomada inteligente barata que só liga e desliga — e uma dessas te dá uma entidade no Home Assistant, o switch, e mais nada. Todo este artigo lê sensor.*_energia, sensor.*_voltagem e sensor.*_corrente: se a sua tomada não mede, esses três sensores simplesmente não existem e o meu código morre com ENTIDADE_AUSENTE.

🔒 Por que eu não usei o localtuya

Essa é a pergunta que todo mundo faz — e com razão, porque a resposta óbvia seria: "se você quer parar de depender da nuvem, instala o localtuya e pronto". O localtuya é uma integração alternativa que fala com o dispositivo direto na sua rede, sem passar pela internet. Seria perfeito.

Eu não usei por um motivo bem prosaico: estas tomadas não deixam. 😤

O localtuya depende de o dispositivo aceitar conexão TCP na porta 6668 da rede local. As minhas se anunciam por broadcast UDP — elas gritam "estou aqui!" na rede, o que dá uma falsa esperança danada — mas quando você tenta conectar, não atendem a 6668 e nem respondem ARP. O firmware delas (product_id nlqc2zwgpjypop2p) simplesmente não expõe o serviço local.

E não existe flag escondida, opção no app ou firmware alternativo oficial para ligar isso. Eu procurei. Não procure. 🙃

Ou seja: no meu caso a escolha nunca foi "nuvem versus local" — foi "nuvem direto versus nuvem passando pelo HA". Se controle local é requisito de verdade, a saída não é software: é trocar o hardware. Volto nisso no fim do artigo, porque é para lá que eu vou. 🔮

Vale dizer: se a sua tomada for um modelo que aceita localtuya, vá de localtuya. Você ganha o que eu não ganhei, e a segunda armadilha deste artigo — a do carimbo — provavelmente nem te atinge, porque ela é um comportamento da integração Tuya oficial. As outras lições continuam valendo. 💜

🔑 O token e a primeira chamada

A API REST do Home Assistant se autentica com um token de longa duração. Você gera na sua conta: clique no seu usuário (canto inferior esquerdo) → aba SegurançaTokens de acesso de longa duração → criar. Ele aparece uma única vez — copie na hora, porque não dá para ver de novo.

Com o token na mão, a chamada que importa é uma só:

curl -s http://SEU-HA:8123/api/states \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json"

/api/states devolve todas as entidades da sua instalação de uma vez. Na minha são 115. E aqui já tem uma decisão de projeto que vale a pena: existe também o /api/states/<entity_id>, que traz uma entidade só. Eu não uso.

Por quê? Porque uma tomada, para mim, são quatro entidades (já já explico). Se eu buscasse uma por vez, seriam quatro requisições — e, pior, quatro instantes diferentes. Quando eu gravo o snapshot de uma queda de energia segundo a segundo, eu quero tensão, corrente e potência do mesmo momento. Uma chamada só resolve as duas tomadas e me dá coerência temporal de graça. 🎯

🔌 Uma tomada são quatro entidades

Esse foi o primeiro ajuste de cabeça. No mundo Tuya você pensa em "dispositivo". No Home Assistant não existe dispositivo na API de estados — existem entidades, e cada medida é uma entidade separada:

switch.pccritico_socket_1     → on          (liga/desliga)
sensor.pccritico_energia      → 273.6       (watts)
sensor.pccritico_voltagem     → 205.5       (volts)
sensor.pccritico_corrente     → 1.441       (ampères — repare)

Cada uma delas vem do /api/states assim — este é o JSON real de uma das minhas:

{
  "entity_id": "sensor.pccritico_corrente",
  "state": "1.441",
  "attributes": {
    "state_class": "measurement",
    "unit_of_measurement": "A",
    "device_class": "current",
    "friendly_name": "PcCritico Corrente"
  },
  "last_reported": "2026-08-05T22:25:49.544529+00:00"
}

Duas coisas para guardar dessa resposta. O state vem como texto ("1.441", entre aspas), mesmo sendo um número — por isso o script mais adiante converte e valida. E o unit_of_measurement diz "A": ampères. Volto nisso já já. ⚡

Repare no padrão dos nomes: todos derivam do mesmo prefixo, pccritico. Então eu guardo no banco só o entity_id do switch e derivo o resto:

// 'switch.pccritico_socket_1' -> 'pccritico'
function prefixoDe(entityId) {
  const semDominio = String(entityId).split('.')[1] || '';
  return semDominio.replace(/_socket_\d+$/, '');
}

// As 4 entidades que, juntas, formam UMA tomada.
function entidadesDe(entityId) {
  const p = prefixoDe(entityId);
  return {
    ligado:  entityId,
    watts:   `sensor.${p}_energia`,
    volts:   `sensor.${p}_voltagem`,
    amperes: `sensor.${p}_corrente`,
  };
}

O switch é a identidade canônica da tomada: é ele que eu comando, e é ele que a proteção valida. Os sensores são derivados. Se um dia algum sensor fugir da convenção, resolvo com um mapa explícito — mas enquanto os seis seguem o padrão, derivar é menos coisa para manter em sincronia.

⚡ O ampère que vira miliampère

Lembra do "unit_of_measurement": "A" naquele JSON? O Home Assistant entrega corrente em ampères. O resto do meu sistema — banco, gráficos, histórico de dois anos — usa miliampères, porque é assim que a Tuya sempre entregou.

A conversão acontece dentro do adapter, num lugar só:

amperes: Math.round(amperes * 1000), // o HA entrega A; converta uma vez, aqui.

Parece detalhe bobo de uma linha, mas é o tipo de coisa que, espalhada, vira bug de madrugada. Se eu deixasse cada consumidor converter, bastaria um esquecer e eu teria um gráfico com um pico de mil vezes — ou, pior, um histórico com duas unidades misturadas na mesma coluna e nenhum jeito de saber qual linha está em quê. Uma ruptura de unidade no banco não tem rollback. 😰

🧰 O script completo

Node puro, sem nenhuma dependência — o fetch e o AbortSignal.timeout já vêm no runtime. É só copiar:

/**
 * tomada-ha.js — le uma tomada Tuya pelo Home Assistant, em Node puro.
 * Uso: node tomada-ha.js switch.pccritico_socket_1
 */
'use strict';

const URL     = process.env.HA_URL   || 'http://192.168.0.10:8123';
const TOKEN   = process.env.HA_TOKEN || '';
const TIMEOUT = Number(process.env.HA_TIMEOUT_MS || 8000);

// O HA emite estes estados quando PERDE o dispositivo. E o sinal de falha.
const INDISPONIVEIS = ['unavailable', 'unknown', 'none', ''];

class ErroHa extends Error {
  constructor(motivo, mensagem) {
    super(mensagem);
    this.motivo = motivo; // INDISPONIVEL | AUSENTE | VALOR | AUTH | HTTP | REDE
  }
}

/** As 4 entidades de uma tomada, derivadas do entity_id do switch. */
function entidadesDe(entityId) {
  const p = entityId.split('.')[1].replace(/_socket_\d+$/, '');
  return {
    ligado: entityId,
    watts: `sensor.${p}_energia`,
    volts: `sensor.${p}_voltagem`,
    amperes: `sensor.${p}_corrente`,
  };
}

/** Uma chamada traz TODAS as entidades, do mesmo instante. */
async function buscarEstados() {
  let res;
  try {
    res = await fetch(`${URL}/api/states`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
      signal: AbortSignal.timeout(TIMEOUT),
    });
  } catch (e) {
    throw new ErroHa('REDE', `nao consegui falar com o HA: ${e.message}`);
  }
  if (res.status === 401 || res.status === 403) throw new ErroHa('AUTH', 'token recusado pelo HA');
  if (!res.ok) throw new ErroHa('HTTP', `HA respondeu HTTP ${res.status}`);
  return res.json();
}

/** Le um estado exigindo que exista e nao esteja indisponivel. */
function estadoDe(porId, id) {
  const e = porId.get(id);
  if (!e) throw new ErroHa('AUSENTE', `entidade ausente no HA: ${id}`);
  if (INDISPONIVEIS.includes(String(e.state).toLowerCase())) {
    throw new ErroHa('INDISPONIVEL', `${id} esta "${e.state}" — dispositivo perdido`);
  }
  return e;
}

/** Converte para numero. Number(" ")===0 e Number("N/A")===NaN passariam batido. */
function numero(e) {
  const n = Number(e.state);
  if (!Number.isFinite(n) || String(e.state).trim() === '') {
    throw new ErroHa('VALOR', `${e.entity_id} nao e numerico: "${e.state}"`);
  }
  return n;
}

async function lerTomada(entityId) {
  const estados = await buscarEstados();
  const porId = new Map(estados.map((e) => [e.entity_id, e]));
  const alvo = entidadesDe(entityId);

  const ligado = estadoDe(porId, alvo.ligado);
  const watts = numero(estadoDe(porId, alvo.watts));
  const volts = numero(estadoDe(porId, alvo.volts));
  const amperes = numero(estadoDe(porId, alvo.amperes));

  const carimbo = Date.parse(ligado.last_reported || ligado.last_updated);

  return {
    ligado: ligado.state === 'on',
    watts,
    volts,
    amperes: Math.round(amperes * 1000), // o HA entrega A; converta uma vez, aqui.
    idadeS: Math.round((Date.now() - carimbo) / 1000),
  };
}

/** Liga/desliga. Aqui e onde a protecao do PcCritico precisa entrar. */
async function comandar(entityId, ligar) {
  const res = await fetch(`${URL}/api/services/switch/turn_${ligar ? 'on' : 'off'}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ entity_id: entityId }),
    signal: AbortSignal.timeout(TIMEOUT),
  });
  if (!res.ok) throw new ErroHa('HTTP', `HA respondeu HTTP ${res.status} ao comandar`);
  return true;
}

async function principal() {
  const alvo = process.argv[2] || 'switch.pccritico_socket_1';
  try {
    const t = await lerTomada(alvo);
    console.log(alvo);
    console.log(`  ligada   : ${t.ligado ? 'sim' : 'nao'}`);
    console.log(`  potencia : ${t.watts} W`);
    console.log(`  tensao   : ${t.volts} V`);
    console.log(`  corrente : ${t.amperes} mA`);
    console.log(`  carimbo  : ${t.idadeS} s atras`);
  } catch (e) {
    if (!(e instanceof ErroHa)) throw e;
    console.error(`FALHA [${e.motivo}] ${e.message}`);
    process.exitCode = 1;
  }
}

if (require.main === module) principal();

module.exports = { lerTomada, comandar, entidadesDe, ErroHa };

Rodando contra o meu HA, agora há pouco:

$ node tomada-ha.js switch.pccritico_socket_1
switch.pccritico_socket_1
  ligada   : sim
  potencia : 273.6 W
  tensao   : 205.5 V
  corrente : 1441 mA
  carimbo  : 41966 s atras

$ node tomada-ha.js switch.pcnormal_socket_1
switch.pcnormal_socket_1
  ligada   : sim
  potencia : 42.6 W
  tensao   : 205.4 V
  corrente : 390 mA
  carimbo  : 41966 s atras

Os 1441 mA ali são o 1.441 ampère daquele JSON, já convertido. E olha o campo carimbo: 41.966 segundos. Onze horas e meia! Numa tomada que está ligada, medindo, e alimentando o computador em que eu escrevo isto. Guarde essa estranheza — ela é a segunda armadilha, e eu volto nela. 👀

☁️ Mentira nº 1: o HA não me tirou da nuvem Tuya

Essa doeu no orgulho. 😳

Eu entrei nesse trabalho com uma expectativa que nunca cheguei a escrever, de tão óbvia que parecia: saindo da nuvem Tuya, eu fico independente da nuvem Tuya. Faz sentido, né? Tirei as credenciais, tirei a assinatura, tirei o devices.json. Logo, tirei a dependência.

Não tirei nada. Fui verificar qual integração o HA usava para falar com essas tomadas e é a integração Tuya oficial — a de nuvem. O localtuya não está nem instalado. Ou seja, o caminho não encurtou:

antes:  meu sistema → nuvem Tuya → tomada
depois: meu sistema → HA (LAN) → nuvem Tuya → tomada

Mesma nuvem, mesma cota do IoT Core, um salto a mais. Ou seja: aquela cota estourada que me deixou dez horas no escuro (code 28841004)? Se acontecer de novo, o HA para junto — ele é cliente da mesma API que eu era. Eu tinha trocado de porta, não de dependência. E se a internet cair, os dois caminhos morrem igual.

E, como contei lá em cima, trocar pelo localtuya não era opção: essas tomadas recusam conexão na rede local, então não havia caminho local para seguir mesmo que eu quisesse.

Então o HA valeu a pena? Valeu — só que pelos motivos certos, não pelo que eu imaginava: menos segredo no meu .env, menos código de protocolo meu para manter, todas as medidas num request só, e um ponto único de integração para o dia em que entrar hardware local. Nenhum desses é "independência da nuvem". Escrevi isso no documento de projeto antes de implementar, justamente para o eu-do-futuro não recomeçar a fantasia.

⏰ Mentira nº 2: frescor não se mede por carimbo

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

Todo dado de sensor precisa de uma pergunta respondida: isso aqui ainda é verdade, ou é lixo velho? No meu caso a pergunta é séria — se as leituras congelarem numa queda de energia, o sistema acha que está tudo bem enquanto a bateria morre.

A resposta parecia trivial. Todo estado do HA vem com last_updated, last_changed e last_reported. Era só medir a idade: passou de dois minutos, é falha. Foi assim que eu desenhei. 📐

Antes de codar, resolvi medir os valores reais. Ainda bem:

switch.pccritico_socket_1     on            42057s
switch.pcnormal_socket_1      on            42057s
sensor.pccritico_energia      299.8             15s
sensor.pccritico_voltagem     200.9             15s
sensor.pcnormal_energia       42.6            1256s
sensor.natal_energia          unavailable   42057s
switch.natal_socket_1         unavailable   42057s

Olha o switch.pccritico_socket_1: ligado, perfeitamente saudável, alimentando o computador em que eu estou trabalhando agora — e com carimbo de 42.057 segundos. Quase doze horas! 🤯

Meu limiar de dois minutos teria declarado tudo como falha permanente. O sistema entraria em modo degradado e ficaria lá para sempre.

A causa: a integração Tuya do HA é push-based. O carimbo só se renova quando o valor muda. Aquele switch está on desde ontem — então o carimbo é de ontem. Ele não é velho, ele é estável. E eu testei os três campos: last_changed, last_updated e last_reported se comportam igual.

Repare também na assimetria entre as duas tomadas, que é o que mata qualquer tentativa de calibrar um limiar: a PcCritico puxa ~300 W de um computador que oscila o tempo todo, então renovou há 15 s. A PcNormal puxa 42 W estáveis e está parada há 1.256 s — vinte minutos, completamente sadia. Qualquer número que eu escolhesse olhando para uma daria falso positivo na outra.

E o sinal certo estava ali o tempo todo. Vê a natal, no fim da lista? É uma tomada de Natal que eu tinha desconectado da parede. O HA não deixou o último valor dela envelhecendo em silêncio — ele marcou unavailable, explicitamente. É isso que o HA faz quando perde um dispositivo.

Então a regra de falha não olha idade nenhuma:

state = unavailable | unknown    → falha de comunicação
HA não responde / timeout / 401  → falha de comunicação
entidade ausente na resposta     → falha de comunicação
caso contrário                   → dado bom (mesmo com carimbo de 10 horas)

Cada caso classificado por tipo explícito, nunca por regex na mensagem de erro. Eu já tinha me queimado com isso: o código antigo da Tuya detectava problema de credencial com um /key|sign|token/ na string, e por isso não reconhecia a cota estourada — ela chegava como falha genérica e eu perdia tempo procurando no lugar errado.

Todos os caminhos de erro do script acima eu rodei de verdade:

$ node tomada-ha.js switch.natal_socket_1
FALHA [INDISPONIVEL] switch.natal_socket_1 esta "unavailable" — dispositivo perdido

$ node tomada-ha.js switch.fantasma_socket_1
FALHA [AUSENTE] entidade ausente no HA: switch.fantasma_socket_1

$ HA_TOKEN=errado node tomada-ha.js switch.pccritico_socket_1
FALHA [AUTH] token recusado pelo HA

$ HA_URL=http://192.168.0.99:8123 node tomada-ha.js switch.pccritico_socket_1
FALHA [REDE] nao consegui falar com o HA: The operation was aborted due to timeout

🧟 A exceção: quando o carimbo volta a valer

Duas exceções, na verdade — e as duas são instrutivas.

A primeira: ao confirmar que um comando funcionou, o carimbo volta a ser critério legítimo. É o caso oposto ao da leitura passiva: um turn_off bem-sucedido muda o estado, logo o carimbo tem que renovar. Sem exigir isso, eu confirmaria o sucesso lendo o estado anterior em cache e reportaria "desligou!" para um comando que talvez nem tenha chegado na tomada.

A segunda me incomodou mais. Se descartei a idade como sinal de falha, o que acontece se a VM do HA travar servindo cache? HTTP 200, valores numéricos plausíveis, nada unavailable — e dados de duas horas atrás. Meu sistema não perceberia nada. Numa queda real, ele ficaria olhando para uma foto. 😱

A solução foi um aviso que não desarma nada:

// Carimbo mais RECENTE entre as tomadas: se nem a mais ativa renovou dentro do
// limiar, TODAS estão paradas — aí sim é sinal de VM travada, não de carga estável.
const carimboMaisRecente = Math.max(...carimbos);
const idadeMaxS = (Date.now() - carimboMaisRecente) / 1000;

Esse Math.max é a parte importante, e eu errei antes de acertar. Com Math.min, a tomada mais quieta ditaria o alarme — e a PcNormal, que legitimamente fica 1.256 s parada, dispararia falso positivo o dia inteiro. Uma tomada quieta é normal. As duas quietas ao mesmo tempo é que é suspeito: é o sinal de que a VM inteira congelou.

E ele só avisa: registra um log e um evento, uma vez, na transição — não em loop. Não marca nada como sem comunicação, não deixa de gravar as leituras, não desarma proteção nenhuma. Aqui um falso positivo custaria mais caro que o risco que estou cobrindo.

🛡️ A proteção que esvaziou em silêncio

Agora a parte em que eu quase estraguei tudo. 😬

O meu sistema tem uma regra inegociável: a tomada do computador crítico nunca desliga. Por nenhum caminho — nem pela tela, nem pela API, nem pela automação de queda de energia. Isso é garantido em três camadas independentes, cada uma capaz de barrar sozinha: uma flag no banco, uma validação no serviço, e uma última checagem dentro do adapter, imediatamente antes do POST sair para a rede.

Essa terceira camada compara o identificador do dispositivo contra uma lista de protegidos:

async setSwitch(entityId, ligar) {
  validarProtecaoAdapter(entityId, !!ligar, this._idsProtegidos);
  const servico = ligar ? 'turn_on' : 'turn_off';
  return this._post(`/api/services/switch/${servico}`, { entity_id: entityId });
}

Eu portei essa camada para o adapter novo, escrevi os testes, tudo verde. Menos mal que fui conferir de onde vinha a tal lista de protegidos:

const list = await prisma.dispositivo.findMany({
  where: { protegido: true, removido: false },
  select: { chave: true, deviceIdExterno: true },   // ← e o entityIdHa?
});

Achou? A lista era montada com o deviceIdExterno — o identificador da Tuya, tipo bf1a2b3c.... Mas o adapter do HA valida entity_id, tipo switch.pccritico_socket_1.

A comparação é string contra lista. Elas nunca coincidiriam. A camada 3 rodaria a cada comando, compararia diligentemente contra uma lista que nunca dá match, e deixaria passar tudo. Uma proteção que executa, não reclama, e vale exatamente zero.

E o pior: silenciosamente. Sem erro, sem log, sem teste vermelho. Um select ao qual faltava uma palavra. A correção foi literalmente acrescentar entityIdHa: true ali — e fazer a coleta perguntar à fonte ativa qual identidade usar, em vez de assumir a da Tuya.

🚨 Falhar alto no boot, não baixinho na queda

Esse susto mudou como o sistema arranca. Havia uma tentação de projeto muito razoável: se a configuração do HA estiver incompleta, cai de volta para a Tuya, que sempre funcionou. Um fallback gentil.

É uma péssima ideia — e o motivo é exatamente o bug de cima. Um fallback silencioso significaria operar na nuvem direta acreditando estar no HA. A lista de protegidos sairia montada com a identidade errada e a camada 3 ficaria vazia. Eu descobriria isso durante uma queda de energia.

Então o backend simplesmente não sobe:

TOMADAS_FONTE=ha, mas a configuracao esta incompleta:
  - HA_TOKEN vazio no .env
  - medidores sem entity_id_ha no banco: PC_NORMAL
Corrija ou volte para TOMADAS_FONTE=tuya. O backend nao sobe pela metade.

Ele checa o que precisa e aborta com exit 1 se faltar: URL, token, e entity_id em todo medidor ativo. Inclusive o caso em que a lista de medidores vem vazia — porque subir com zero protegidos é a mesma coisa que subir sem proteção, só que mais difícil de perceber.

Repare que essa dureza é só para erro de configuração. Falha de hardware — a porta serial do nobreak ocupada, o NAS fora do ar — o sistema tolera de propósito e sobe mesmo assim, porque aí é a realidade sendo imperfeita, não eu tendo digitado errado. São categorias diferentes de problema e merecem tratamentos diferentes.

Falha ruidosa no boot acontece uma vez e você conserta. Falha discreta é descoberta durante a queda de energia. 🔊

🔀 Manter os dois caminhos

Um detalhe de arquitetura que valeu muito: eu não apaguei o código da Tuya. Poller e executor não importam mais um adapter concreto — eles pedem a uma fábrica, que lê a variável TOMADAS_FONTE no boot e devolve o adapter certo. Os dois implementam o mesmo contrato (getStatus, interpretar, setSwitch, definirProtegidos), então nada mais no sistema sabe qual fonte está ativa.

Trocar de fonte é editar uma linha do .env e reiniciar. Se o HA me der dor de cabeça às duas da manhã, eu volto para a nuvem direta sem deploy, sem rollback, sem drama. O caminho de volta é uma funcionalidade, não código morto.

E a fonte ativa aparece no painel, na tela. Porque se alguém subir o sistema com um .env antigo, a tela é onde isso fica visível — sem ela, só o log responde, e log ninguém lê antes de precisar.

🧭 O que eu levaria para o seu caso

Se você vai gerenciar Tuya (ou qualquer coisa) pelo Home Assistant por código:

  • Compre tomada "com medidor de consumo" — sem isso você tem um liga/desliga, não uma fonte de telemetria.
  • Se controle local é requisito, confira o firmware antes — nem toda Tuya aceita localtuya, e isso não se resolve por software. Para sair da nuvem de verdade, Tasmota já instalado de fábrica.
  • Uma chamada a /api/states, não uma por entidade — mais rápido e com todos os valores do mesmo instante.
  • Descubra qual integração está por baixo antes de assumir que você ficou local. Oficial = nuvem.
  • Não meça frescor por carimbo em integração push-based. Use o unavailable, que o HA emite de graça quando perde o dispositivo.
  • Converta unidade num lugar só, dentro do adapter.
  • Valide os números de verdadeNumber(" ") === 0 e esse zero falso é convincente demais.
  • Classifique erro por tipo, nunca por regex na mensagem.
  • Se houver proteção, teste que ela bloqueia — e confira que está comparando a identidade certa.

O trabalho todo levou bem mais que a tarde que eu tinha estimado. 😅 Mas metade do valor não veio do código: veio de duas medições que desmentiram o que eu tinha certeza. Se eu tivesse implementado o limiar de dois minutos direto do desenho, teria um sistema que se declara quebrado o tempo todo — e eu iria procurar o defeito no Home Assistant, não na minha suposição.

Meça antes. O hardware mente, e a documentação não conta onde. 💜

🔮 Plano futuro: sair da nuvem de vez, com Tasmota

Termino com o que vem por aí — porque este artigo tem um final em aberto. 😄

Repare que, das duas mentiras, a primeira continua de pé: eu troquei de porta, não de dependência. Aquela cota que me deixou dez horas cega pode estourar de novo amanhã, e o Home Assistant vai parar junto comigo. Resolver isso de verdade exige o que eu disse lá atrás — trocar o hardware.

Tasmota é um firmware alternativo de código aberto para os chips ESP8266/ESP8285, que é o que mora dentro de boa parte dessas tomadas inteligentes. Trocando o firmware de fábrica por ele, a tomada para de falar com a nuvem do fabricante e passa a falar MQTT direto na sua rede. Sem conta, sem token de nuvem, sem cota, sem intermediário. 🎉

Só que regravar firmware dá trabalho: dependendo do modelo, envolve abrir a tomada e soldar fios num adaptador serial. Por isso eu estou de olho nestas, que já vêm com o Tasmota instalado de fábrica:

IoTorero Tasmota & MQTT Brazil Plug — 16 A, com medição de consumo, ESP8285pt.aliexpress.com

Plugue brasileiro, 16 A (mais folga que os meus 10 A), medição de consumo em tempo real e integração direta com o Home Assistant. Vêm da China, então conte com algumas semanas de espera.

O caminho que eu quero é este:

no começo: meu sistema → nuvem Tuya → tomada
hoje:      meu sistema → HA (LAN) → nuvem Tuya → tomada
plano:     meu sistema → HA (LAN) → MQTT (LAN) → tomada

Aí sim a nuvem sai da equação, e o ganho que eu achei que teria lá na Mentira nº 1 finalmente acontece de verdade. Numa queda de energia com o roteador fora, a tomada continua alcançável — nada precisa sair de casa. 🏠

E olha só onde a arquitetura deste artigo se paga: como o poller e o executor pedem o adapter a uma fábrica, entrar com uma terceira fonte é escrever um adapter novo que cumpra o mesmo contrato. Não é reformar o sistema — é acrescentar um arquivo. Foi sem querer que eu preparei o terreno para a próxima mudança enquanto fazia esta. 😊

Quando elas chegarem, eu conto aqui como foi. 💜

💾 O repositório

O script está lá inteiro, e o README traz os seis caminhos de erro com a saída real de cada um — inclusive o unavailable, que chega com HTTP 200 e cara de resposta boa:

Exemplos_HomeAssistant_TomasTuya no GitHubnode tomada-ha.js switch.sua_tomada_socket_1. Sem dependência nenhuma.github.com

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

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

Leia também