Node.js: lendo e controlando o nobreak NHS
Olá meus Unicórnios! 🦄✨
Sabe quando você olha para um aparelho em cima da mesa e pensa "esse trem sabe alguma coisa que eu não sei"? 😅 Pois é. Era o meu nobreak NHS ali, piscando, medindo tensão, contando bateria — e guardando tudo para ele mesmo.
O software oficial que vem com ele até mostra os dados. Mas mostra na tela dele, do jeito dele, e quando ele quer. Eu queria os números no meu código: para gravar histórico, montar gráfico, e — principalmente — para que o computador tomasse decisões sozinho quando a energia caísse.
Então fui atrás do protocolo. Este artigo é o que eu aprendi lendo aquela porta serial: como o frame do NHS é montado, como validar, como decodificar cada campo e como enviar comandos de volta. Tudo com Node.js puro e uma dependência só.
🔌 O básico: a porta é exclusiva, e isso dói
Primeira pedra no caminho, e ela chega antes de qualquer linha de código: a porta serial aceita um processo por vez. Se o serviço do fabricante estiver rodando (no Windows ele se chama NHSUPSServer), o seu script vai bater a cara num Access denied e ponto final.
Repare no detalhe cruel: a mensagem de erro não diz "a porta está ocupada pelo serviço X". Ela diz só "acesso negado", que é exatamente o tipo de erro que faz a gente perder meia hora procurando permissão de usuário. 🙄
net stop NHSUPSServer
Feito isso, a porta é sua. E como ela é uma só, vale a regra de ouro do projeto: um único processo abre a porta e nunca mais solta. Quem precisar ler ou escrever, pede para ele. Abrir e fechar a porta a cada comando é receita de conflito.
🔎 Descobrindo em qual porta o nobreak está
"Tá, mas é COM7 ou COM3?" 😅 Essa foi minha primeira dúvida — e é uma dúvida legítima, porque o número muda. Trocou o nobreak de porta USB? Mudou. Reinstalou o driver? Pode ter mudado. Eu mesma tinha COM7 escrito no .env do meu projeto e, quando fui conferir para escrever este artigo, o aparelho estava em COM9. 🙃
O serialport tem um list() que mostra tudo que existe na máquina:
const { SerialPort } = require('serialport');
SerialPort.list().then((portas) => {
for (const p of portas) {
console.log(p.path, '|', p.friendlyName, '|', p.vendorId, p.productId);
}
});
Na minha máquina, agora:
COM1 | Porta de comunicação (COM1) | undefined undefined
COM9 | Dispositivo Serial USB (COM9) | 0925 1241
Repare que o friendlyName não ajuda muito: "Dispositivo Serial USB" é o nome genérico do driver, não do nobreak. Não espere achar um "NHS" escrito ali. 😞
O vendorId/productId ajudam mais — aquele 0925:1241 é o par do conversor USB-serial que o NHS usa. Mas eu não confiaria só nisso: outro aparelho pode usar o mesmo chip.
O jeito que realmente funciona: escutar
Como o nobreak fala sozinho, existe um teste muito mais honesto: abre a porta, escuta uns segundos, e vê se aparece um frame válido — com a assinatura 15 44 e o checksum batendo. Se apareceu, é ele. Não tem como ser coincidência.
/** Escuta uma porta por alguns segundos. Resolve true se vier frame NHS. */
function farejar(caminho, ms = 4000) {
return new Promise((resolve) => {
let porta;
try {
porta = new SerialPort({ path: caminho, baudRate: 2400, autoOpen: false });
} catch (_) { return resolve(false); }
let buf = Buffer.alloc(0);
let achou = false;
const encerrar = () => {
clearTimeout(t);
if (porta.isOpen) porta.close(() => resolve(achou));
else resolve(achou);
};
const t = setTimeout(encerrar, ms);
porta.on('data', (c) => {
buf = Buffer.concat([buf, c]);
let i = 0;
while (i + 20 <= buf.length) {
if (buf[i] === 0x15 && buf[i+1] === 0x44 &&
checksumOk(buf.subarray(i, i + 20))) { achou = true; return encerrar(); }
i++;
}
// guarda só a cauda: o começo do frame pode estar no próximo chunk
buf = buf.subarray(Math.max(0, buf.length - 20));
});
porta.on('error', () => encerrar()); // porta ocupada, sem permissão...
porta.open((e) => { if (e) encerrar(); });
});
}
Aí é só varrer as portas até uma responder:
testando COM1... nada
testando COM9... FALA NHS!
Uns 4 segundos por porta bastam, já que o frame vem a cada segundo. E note que o on('error') também encerra: se a porta estiver ocupada, o teste apenas responde "nada" e segue para a próxima — em vez de derrubar o script. ✅
📡 O nobreak fala sozinho
Essa foi a parte que me surpreendeu, e para melhor: você não precisa pedir nada. O NHS transmite um frame de status por conta própria, mais ou menos uma vez por segundo, para sempre. Não tem request/response, não tem polling. É só abrir a porta e escutar.
A configuração é bem antiga, e isso é uma dica de que o protocolo também é: 2400 baud, 8N1.
const { SerialPort } = require('serialport');
const port = new SerialPort({
path: 'COM7',
baudRate: 2400,
dataBits: 8,
parity: 'none',
stopBits: 1,
autoOpen: false,
});
port.on('data', (chunk) => {
// chunk é um Buffer com "um pedaço" do fluxo — não um frame inteiro
});
port.open();
E aqui vem a armadilha número dois. Aquele chunk não é um frame. É o que chegou pelo cabo naquele instante — pode ser meio frame, pode ser um frame e meio, pode ser dois. Serial é fluxo de bytes, não fluxo de mensagens.
🧩 Enquadramento: achando o começo e o fim
Para transformar aquele fluxo picado em mensagens, precisamos saber onde cada uma começa e termina. No NHS o formato é:
15 44 <payload de 16 bytes> <checksum> FE
└──┬──┘ └────┬───┘ └┬┘
início byte 18 fim
total: 20 bytes
Ou seja: começa com 0x15 0x44, tem 20 bytes no total, o penúltimo é o checksum e o último é sempre 0xFE.
E tem um detalhe que me custou um tempo até cair a ficha: entre um frame e outro o nobreak manda 0xFF como enchimento. Aquilo não é começo de nada, é ruído de linha ociosa. Se você tentar sincronizar por qualquer byte que apareça, vai se perder.
A estratégia que funciona é acumular tudo num buffer e ir "caminhando" nele à procura da assinatura:
const FRAME_LEN = 20;
let buf = Buffer.alloc(0);
port.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]); // acumula
extrair();
});
function extrair() {
let i = 0;
while (i + 1 < buf.length) {
// 1) procura a assinatura de início
if (buf[i] !== 0x15 || buf[i + 1] !== 0x44) { i++; continue; }
// 2) achou o início, mas o frame ainda não chegou todo: espera mais dados
if (i + FRAME_LEN > buf.length) break;
const f = buf.subarray(i, i + FRAME_LEN);
// 3) tem que terminar em FE, senão era coincidência
if (f[FRAME_LEN - 1] !== 0xfe) { i++; continue; }
// 4) checksum: se falhou, descarta o frame inteiro e segue
if (!checksumOk(f)) { i += FRAME_LEN; continue; }
processar(Buffer.from(f)); // cópia! (veja a nota abaixo)
i += FRAME_LEN;
}
buf = buf.subarray(i); // guarda o resto para o próximo chunk
}
Três coisas nesse código merecem atenção, porque cada uma delas é um bug que eu já tomei:
O break do passo 2 não é continue. Se achamos o início mas o frame ainda não chegou inteiro, não adianta procurar mais para frente — é para parar e esperar o próximo pedaço de dados. Trocar por continue ali faz o frame ser descartado toda vez que ele chega partido, que é justamente o caso mais comum.
O Buffer.from(f) do passo 4 é obrigatório. O subarray() do Node não copia — ele devolve uma "janela" que compartilha a mesma memória do buffer original. Como logo abaixo eu faço buf = buf.subarray(i), aquela janela pode ser sobrescrita enquanto seu código ainda está usando. Fazer a cópia resolve. 😳
E a última linha guarda a sobra. Aquele meio-frame que ficou no fim do buffer é o começo do próximo — jogar fora é perder uma leitura a cada chunk.
🔐 O checksum
O checksum é simples de um jeito quase carinhoso: soma tudo e fica com o byte de baixo.
function checksumOk(f) {
if (f.length < 20) return false;
let s = 0;
for (let k = 0; k < 18; k++) s += f[k]; // soma bytes 0..17
return (s & 0xff) === f[18] && f[19] === 0xfe; // confere com o byte 18
}
É uma checagem fraca em termos criptográficos — mas não é para isso que ela serve. Ela existe para pegar ruído elétrico no cabo, e nisso funciona muito bem. E vale a pena usar: um frame corrompido que passa direto vira um pico absurdo no seu gráfico ou, pior, um comando automático disparado à toa.
📊 Decodificando os campos
Com o frame validado na mão, é hora da parte divertida. Cada posição do payload carrega uma medida, e a maioria usa o mesmo truque: um byte para a parte inteira, o seguinte para os centésimos.
function decodeFrame(f) {
return {
entrada_V: f[2] + f[3] / 100, // tensão da rede
ent_min_V: f[7] + f[8] / 100, // mínima registrada
ent_max_V: f[9] + f[10] / 100, // máxima registrada
saida_V: f[11] + f[12] / 100, // tensão de saída
temp_C: f[13] + f[14] / 100, // temperatura
carga_pct: f[6], // carga da saída em % (direto)
carregador_mA: f[15] / 25 * 750, // corrente do carregador
bateria_raw: f[4] + f[5] * 256, // bateria (veja a seção da calibração!)
flags: decodeFlags(f[16]), // 8 bits de estado
};
}
Repare que carga_pct vem cru, sem conversão nenhuma — é o percentual de carga que o nobreak está entregando na saída. E carregador_mA tem aquela escala esquisita /25*750, que é herança da engenharia reversa original.
🚩 As flags, bit a bit
O byte f[16] é onde mora o estado do aparelho. São oito bits independentes, cada um uma condição:
const FLAG_BITS = [
['modo_bateria', 0x01], // está em bateria
['bateria_baixa', 0x02], // bateria acabando
['falha_de_rede', 0x04], // a rede caiu
['falha_rapida_rede', 0x08], // piscada
['entrada_220V', 0x10], // tensão de entrada
['saida_220V', 0x20], // tensão de saída
['bypass_ativo', 0x40], // ⚠️ a confirmar
['carregador_ativo', 0x80], // ⚠️ mente (veja abaixo)
];
function decodeFlags(b) {
const out = {
_hex: '0x' + b.toString(16).padStart(2, '0'),
_bin: b.toString(2).padStart(8, '0'), // ótimo para depurar
};
for (const [nome, mask] of FLAG_BITS) out[nome] = (b & mask) ? 1 : 0;
return out;
}
Guardar o _bin junto salvou minha vida mais de uma vez. Quando algo estranho acontece, olhar 00110101 no log e comparar com o frame anterior mostra exatamente qual bit mudou.
Para saber se caiu a energia, o sinal confiável é o falha_de_rede:
const emRede = flags.falha_de_rede ? 0 : 1;
const estado = emRede ? 'REDE' : 'BATERIA';
😳 Duas mentiras que o hardware me contou
Se você só for ler um pedaço deste artigo, leia este. 🙏 Porque documentação a gente acha em qualquer lugar — o que quase ninguém conta é onde os dados enganam.
A flag do carregador que nunca acende
O bit 0x80 se chama carregador_ativo. Parece óbvio: se está acesa, o carregador está carregando. Perfeito para mostrar um ícone na tela.
Só que ela ficou em zero em milhares de leituras seguidas — enquanto o campo carregador_mA, no mesmo frame, mostrava mais de 1 ampère entrando na bateria. Isso mesmo, o aparelho jurava que não estava carregando enquanto carregava. 🤯
A solução foi parar de acreditar na flag e olhar para a corrente, que é o dado físico de verdade:
const LIMIAR_CARGA_MA = 150; // abaixo disso é corrente residual, não carga
// NÃO usar flags.carregador_ativo aqui: neste equipamento ela não é confiável.
const carregando = !!emRede && carregador_mA > LIMIAR_CARGA_MA;
Aquele piso de 150 mA importa: com a bateria cheia, ainda passa uma correntinha de manutenção. Sem o limiar, o sistema diria "carregando" para sempre.
A bateria que valia três vezes mais
Essa é a minha preferida, porque o erro era meu — ou melhor, era um erro herdado que eu copiei sem conferir.
O projeto de referência de onde veio o mapeamento calculava a tensão da bateria como f[4] / 5. Estava lá, funcionando, testado. Eu portei. Com bateria_raw = 136, isso dava 27,2 V.
Aí eu fui olhar o painel do nobreak. E o painel dizia: "72 V nominal | 81,6 V bateria | 13,6 V bateria".
Prepare-se para a continha, porque ela é bonita: 81,6 ÷ 13,6 = 6 exato. Seis baterias de 12 V em série. E o raw era 136, que é 13,6 × 10. Ou seja: aquele byte não é a tensão do banco, é a tensão de uma bateria, em decivolts.
const nBaterias = 6; // quantas em SÉRIE no seu arranjo (72V = 6 × 12V)
bateria_V: f[4] / 10, // 136 -> 13,6 V (por bateria)
// arredonda: 10.8 * 6 daria 64.80000000000001 e essa cauda iria parar no banco
bateria_banco_V: Math.round(f[4] * nBaterias) / 10, // -> 81,6 V (o banco)
O ÷5 não estava errado no projeto original — ele foi calibrado num nobreak de 24 V. Só que ninguém tinha escrito isso em lugar nenhum, e num equipamento de 72 V ele erra por três vezes. 😳
A lição que eu levo daqui: fórmula de engenharia reversa herdada é hipótese, não fato. Enquanto ela não bate com o painel ou com um multímetro, o certo é guardar o valor cru e marcar a estimativa como "não calibrada".
✍️ Escrevendo: simulando uma queda de energia
Ler é metade. O NHS também aceita comandos, e o formato é bem mais simples que o de status: 9 bytes, com um byte dizendo a ação.
FF 09 53 <acao> 00 00 00 <checksum> FE
queda: FF 09 53 0C 00 00 00 68 FE → força a ir para bateria
retorno: FF 09 53 04 00 00 00 60 FE → volta para a rede
O checksum aqui é o mesmo espírito, mas sobre o miolo do frame:
function checksumComandoOk(arr) {
const f = Buffer.from(arr);
const miolo = f.subarray(1, f.length - 2); // ignora o FF e o "cks FE"
const cks = miolo.reduce((a, b) => (a + b) & 0xff, 0);
return cks === f[f.length - 2];
}
Mesmo com os frames já capturados e prontos, eu valido o checksum antes de cada envio. É barato e evita mandar lixo para dentro do aparelho por um typo.
E — importante — o envio usa a mesma porta que o leitor já tem aberta. Nada de abrir uma segunda conexão:
async function enviarComando(nome) {
if (!port || !port.isOpen) throw new Error(`porta fechada — não dá para enviar ${nome}`);
const frame = frameComando(nome); // valida o checksum, lança se inválido
await new Promise((resolve, reject) => {
port.write(frame, (err) => {
if (err) return reject(err);
port.drain((e) => (e ? reject(e) : resolve())); // espera sair de verdade
});
});
}
Aquele drain() importa: o write() só enfileira os bytes. A 2400 baud, "enfileirado" e "transmitido" são coisas bem diferentes no tempo. ⏳
🔁 Reconexão: porque o cabo sempre cai
Último ponto, e é o que separa um script de demonstração de algo que roda por meses: a porta vai cair. Alguém esbarra no cabo, o Windows resolve reenumerar o dispositivo, o serviço do fabricante volta sozinho depois de uma atualização.
Então trate close e error como eventos normais, não como fim de mundo:
port.on('close', () => {
aberta = false;
if (!parando) agendarReconexao();
});
port.on('error', (e) => {
aberta = false;
if (/access denied/i.test(e.message)) {
// caso clássico: o serviço do fabricante voltou e tomou a porta
console.warn('Porta ocupada. Pare o NHSUPSServer (net stop NHSUPSServer).');
}
if (!parando) agendarReconexao();
});
function agendarReconexao() {
if (timer || parando) return; // sem empilhar timers!
timer = setTimeout(() => { timer = null; abrir(); }, 5000);
}
A guarda if (timer) parece boba, mas sem ela um close seguido de error (que é o par que acontece sempre junto) agenda duas reconexões — e depois quatro, e depois oito. 🙃 E o parando é para o desligamento limpo não ficar ressuscitando a porta enquanto você tenta encerrar o processo.
🎁 Juntando tudo
No fim, a receita inteira cabe em poucos conceitos:
- Uma dependência só —
serialport. O resto éBufferdo Node. - Não confie no número da COM. Ele muda de porta USB para porta USB. Fareje: quem devolver um frame com checksum válido é o nobreak.
- Um dono da porta. Um processo abre, mantém aberta, e centraliza leitura e escrita.
- Enquadre com cuidado: procure
15 44, exijaFE, confira o checksum, copie o buffer e guarde a sobra. - Desconfie do hardware. A flag do carregador mente, a escala da bateria era de outro equipamento. Confira contra o painel.
- Reconecte sempre, sem empilhar timers.
Com esses frames chegando de segundo em segundo, o resto vira consequência: dá para gravar histórico num banco, montar gráfico de tensão, estimar autonomia — e, o que mais me interessava, deixar o computador reagir sozinho a uma queda de energia, mudando o plano de energia e desligando o que não é essencial antes que a bateria acabe.
Mas isso já é assunto para outro artigo. 😊
📦 O script completo
Chega de pedaço solto — aqui está tudo junto e funcionando. 🎉 Um arquivo só, uma dependência só, que descobre a porta, mostra a telemetria em tempo real e envia os comandos.
npm install serialport
Modos de uso:
node nhs.js # varre as portas e escuta a que falar NHS
node nhs.js COM7 # escuta a porta indicada
node nhs.js COM7 queda # simula uma queda de energia
node nhs.js COM7 retorno # volta para a rede
É assim que ele aparece rodando — este print é do meu nobreak, agora:
NOBREAK NHS — 17:39:11
ESTADO REDE
ENTRADA 210.0 V (min 210.0 / max 211.0)
SAIDA 210.0 V
CARGA 23 % [#####...............]
TEMPERATURA 35.0 C
CARREGADOR 120 mA
BATERIA 13.6 V/bat -> banco 81.6 V (raw 136)
FLAGS 0x30 00110000
entrada_220V saida_220V
Ctrl+C para sair.
Repare em duas coisas que este print prova, e que são justamente as histórias que contei lá em cima. O raw 136 virando 81,6 V — o valor que bate com o painel, e não os 27,2 V da fórmula antiga. E o carregador em 120 mA com o estado sem "(carregando)": está abaixo do piso de 150 mA, ou seja, é corrente de manutenção com a bateria cheia. O limiar fazendo o trabalho dele. 😌
/**
* nhs.js — monitor e controle do nobreak NHS pela porta serial.
*
* node nhs.js lista as portas e escuta a primeira que falar NHS
* node nhs.js COM7 escuta a porta indicada
* node nhs.js COM7 queda simula uma queda de energia
* node nhs.js COM7 retorno volta para a rede
*/
'use strict';
const { SerialPort } = require('serialport');
const FRAME_LEN = 20;
const BAUD = 2400;
const LIMIAR_CARGA_MA = 150; // abaixo disso e corrente de manutencao
const N_BATERIAS = 6; // baterias em SERIE no seu banco (72V = 6 x 12V)
// ---------------------------------------------------------------- decode
const FLAG_BITS = [
['modo_bateria', 0x01], ['bateria_baixa', 0x02],
['falha_de_rede', 0x04], ['falha_rapida_rede', 0x08],
['entrada_220V', 0x10], ['saida_220V', 0x20],
['bypass_ativo', 0x40], ['carregador_ativo', 0x80],
];
function decodeFlags(b) {
const out = { _raw: b, _hex: '0x' + b.toString(16).padStart(2, '0'),
_bin: b.toString(2).padStart(8, '0') };
for (const [nome, mask] of FLAG_BITS) out[nome] = (b & mask) ? 1 : 0;
return out;
}
function decodeFrame(f) {
const flags = decodeFlags(f[16]);
const emRede = flags.falha_de_rede ? 0 : 1;
const carregadorMa = f[15] / 25 * 750;
return {
entrada_V: f[2] + f[3] / 100,
saida_V: f[11] + f[12] / 100,
ent_min_V: f[7] + f[8] / 100,
ent_max_V: f[9] + f[10] / 100,
carga_pct: f[6],
temp_C: f[13] + f[14] / 100,
carregador_mA: carregadorMa,
bateria_raw: f[4] + f[5] * 256,
bateria_V: f[4] / 10, // por bateria
bateria_banco_V: Math.round(f[4] * N_BATERIAS) / 10, // banco inteiro
em_rede: emRede,
estado: emRede ? 'REDE' : 'BATERIA',
// NAO usar flags.carregador_ativo: nao e confiavel (veja o artigo)
carregando: !!emRede && carregadorMa > LIMIAR_CARGA_MA,
flags,
};
}
function checksumOk(f) {
if (f.length < FRAME_LEN) return false;
let s = 0;
for (let k = 0; k < 18; k++) s += f[k];
return (s & 0xff) === f[18] && f[19] === 0xfe;
}
const hex = (f) => Array.from(f, (x) => x.toString(16).padStart(2, '0')).join('');
// ---------------------------------------------------------------- comandos
const COMANDOS = {
queda: [0xff, 0x09, 0x53, 0x0c, 0x00, 0x00, 0x00, 0x68, 0xfe],
retorno: [0xff, 0x09, 0x53, 0x04, 0x00, 0x00, 0x00, 0x60, 0xfe],
};
function frameComando(nome) {
const arr = COMANDOS[nome];
if (!arr) throw new Error(`comando desconhecido: ${nome}`);
const f = Buffer.from(arr);
const miolo = f.subarray(1, f.length - 2);
const cks = miolo.reduce((a, b) => (a + b) & 0xff, 0);
if (cks !== f[f.length - 2]) throw new Error(`checksum invalido em ${nome}`);
return f;
}
// ---------------------------------------------------------------- monitor
class Nhs {
constructor(porta) {
this.porta = porta;
this.port = null;
this.buf = Buffer.alloc(0);
this.parando = false;
this.timer = null;
this.aoFrame = () => {};
}
abrir() {
this.parando = false;
this.port = new SerialPort({
path: this.porta, baudRate: BAUD, dataBits: 8,
parity: 'none', stopBits: 1, autoOpen: false,
});
this.port.on('data', (c) => {
this.buf = Buffer.concat([this.buf, c]);
this.extrair();
});
this.port.on('close', () => { if (!this.parando) this.reconectar(); });
this.port.on('error', (e) => {
if (/access denied/i.test(e.message)) {
console.error(`\n A ${this.porta} esta ocupada.`);
console.error(' Pare o servico do fabricante: net stop NHSUPSServer\n');
} else {
console.error(` erro serial: ${e.message}`);
}
if (!this.parando) this.reconectar();
});
this.port.open((err) => {
if (!err) console.log(` ${this.porta} aberta a ${BAUD} baud. Escutando...\n`);
});
}
reconectar() {
if (this.timer || this.parando) return; // sem empilhar timers
this.timer = setTimeout(() => { this.timer = null; this.abrir(); }, 5000);
}
extrair() {
let i = 0;
const buf = this.buf;
while (i + 1 < buf.length) {
if (buf[i] !== 0x15 || buf[i + 1] !== 0x44) { i++; continue; }
if (i + FRAME_LEN > buf.length) break; // frame incompleto: espera
const f = buf.subarray(i, i + FRAME_LEN);
if (f[FRAME_LEN - 1] !== 0xfe) { i++; continue; }
if (!checksumOk(f)) { i += FRAME_LEN; continue; }
const copia = Buffer.from(f); // copia! subarray compartilha
try { this.aoFrame(decodeFrame(copia), copia); }
catch (e) { console.error(`decode: ${e.message}`); }
i += FRAME_LEN;
}
this.buf = buf.subarray(i); // guarda a sobra
}
async enviar(nome) {
if (!this.port || !this.port.isOpen) throw new Error('porta fechada');
const frame = frameComando(nome);
await new Promise((ok, falha) => {
this.port.write(frame, (e) => {
if (e) return falha(e);
this.port.drain((e2) => (e2 ? falha(e2) : ok()));
});
});
return hex(frame);
}
async fechar() {
this.parando = true;
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (this.port && this.port.isOpen) {
await new Promise((r) => this.port.close(() => r()));
}
}
}
// ---------------------------------------------------------------- descoberta
async function listar() {
const portas = await SerialPort.list();
if (!portas.length) { console.log(' nenhuma porta serial encontrada.'); return portas; }
console.log(' Portas seriais encontradas:\n');
for (const p of portas) {
const id = (p.vendorId && p.productId) ? ` VID:${p.vendorId} PID:${p.productId}` : '';
console.log(` ${p.path.padEnd(6)} ${p.friendlyName || p.manufacturer || ''}${id}`);
}
console.log('');
return portas;
}
/** Escuta uma porta por N ms e resolve true se vier frame NHS valido. */
function farejar(caminho, ms = 4000) {
return new Promise((resolve) => {
let porta;
try {
porta = new SerialPort({ path: caminho, baudRate: BAUD, dataBits: 8,
parity: 'none', stopBits: 1, autoOpen: false });
} catch (_) { return resolve(false); }
let buf = Buffer.alloc(0);
let achou = false;
const encerrar = () => {
clearTimeout(t);
if (porta.isOpen) porta.close(() => resolve(achou));
else resolve(achou);
};
const t = setTimeout(encerrar, ms);
porta.on('data', (c) => {
buf = Buffer.concat([buf, c]);
let i = 0;
while (i + FRAME_LEN <= buf.length) {
if (buf[i] === 0x15 && buf[i + 1] === 0x44 &&
checksumOk(buf.subarray(i, i + FRAME_LEN))) { achou = true; return encerrar(); }
i++;
}
buf = buf.subarray(Math.max(0, buf.length - FRAME_LEN));
});
porta.on('error', () => encerrar());
porta.open((e) => { if (e) encerrar(); });
});
}
async function descobrir() {
const portas = await listar();
for (const p of portas) {
process.stdout.write(` testando ${p.path}... `);
const ok = await farejar(p.path);
console.log(ok ? 'FALA NHS!' : 'nada');
if (ok) return p.path;
}
return null;
}
// ---------------------------------------------------------------- tela
const BARRA = (pct, larg = 20) => {
const n = Math.max(0, Math.min(larg, Math.round(pct / 100 * larg)));
return '[' + '#'.repeat(n) + '.'.repeat(larg - n) + ']';
};
function mostrar(d) {
const fl = d.flags;
const acesas = FLAG_BITS.filter(([n]) => fl[n]).map(([n]) => n).join(' ') || '(nenhuma)';
const linhas = [
` ESTADO ${d.estado}${d.carregando ? ' (carregando)' : ''}`,
` ENTRADA ${d.entrada_V.toFixed(1).padStart(6)} V (min ${d.ent_min_V.toFixed(1)} / max ${d.ent_max_V.toFixed(1)})`,
` SAIDA ${d.saida_V.toFixed(1).padStart(6)} V`,
` CARGA ${String(d.carga_pct).padStart(6)} % ${BARRA(d.carga_pct)}`,
` TEMPERATURA ${d.temp_C.toFixed(1).padStart(6)} C`,
` CARREGADOR ${d.carregador_mA.toFixed(0).padStart(6)} mA`,
` BATERIA ${d.bateria_V.toFixed(1).padStart(6)} V/bat -> banco ${d.bateria_banco_V.toFixed(1)} V (raw ${d.bateria_raw})`,
` FLAGS ${fl._hex} ${fl._bin}`,
` ${acesas}`,
];
console.clear();
console.log(`\n NOBREAK NHS — ${new Date().toLocaleTimeString('pt-BR')}\n`);
console.log(linhas.join('\n'));
console.log('\n Ctrl+C para sair.');
}
// ---------------------------------------------------------------- main
(async function main() {
const [portaArg, acao] = process.argv.slice(2);
const porta = portaArg || await descobrir();
if (!porta) {
console.log('\n Nenhuma porta falou NHS. O servico NHSUPSServer esta parado?\n');
process.exit(1);
}
const nhs = new Nhs(porta);
if (acao === 'queda' || acao === 'retorno') {
nhs.aoFrame = (d) => {
console.log(` ${new Date().toLocaleTimeString('pt-BR')} ${d.estado.padEnd(8)} entrada ${d.entrada_V.toFixed(1)} V flags ${d.flags._hex}`);
};
nhs.abrir();
console.log(`\n Enviando "${acao}" em 3 s... (Ctrl+C para abortar)\n`);
await new Promise((r) => setTimeout(r, 3000));
try {
const enviado = await nhs.enviar(acao);
console.log(`\n >> enviado: ${enviado}\n`);
} catch (e) {
console.error(` falhou: ${e.message}`);
await nhs.fechar();
process.exit(1);
}
// segue mostrando os frames por 15 s para voce ver a transicao acontecer
setTimeout(async () => { await nhs.fechar(); process.exit(0); }, 15000);
return;
}
nhs.aoFrame = (d) => mostrar(d);
nhs.abrir();
process.on('SIGINT', async () => {
await nhs.fechar();
console.log('\n porta fechada.\n');
process.exit(0);
});
})();
O modo de comando existe para você ver a transição acontecer: ele manda o frame e continua imprimindo uma linha por segundo, então dá para acompanhar o REDE virar BATERIA e as flags mudarem ao vivo.
💾 O repositório
O script está lá inteiro, do jeito que você leu aqui — e o README traz
a saída real da leitura, com o raw 136 virando 81,6 V:
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.