Pular para o conteúdo
Node.js

Node.js: lendo e controlando o nobreak NHS

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris ligado por um cabo serial a um nobreak, com uma coruja segurando um multímetro, bytes coloridos voando até um pergaminho e um banco de baterias ao lado

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ó.

Exemplos_NobreakNHS no GitHubO monitor completo, que acha a porta sozinho. Uma dependência só, o serialport.github.com

🔌 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 é Buffer do 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, exija FE, 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:

Exemplos_NobreakNHS no GitHubnpm install e node nobreak.js. Ele descobre a porta do nobreak sozinho.github.com

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

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

Leia também