| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- import { config } from "../config/index.js";
- import { Integracao, INTEGRACAO_TIPO_ACS } from "../models/Integracao.model.js";
- import { baseUrlComBarra } from "../functions/atualizaTokenIxcIntegradorERetorna.js";
- import acsHttpClient from "./acsHttpClient.js";
- // Cliente da API pública do ACS Space (/api/v1). Só leitura + disparo de diagnóstico:
- // o Oráculo nunca reinicia nem altera configuração de CPE — por isso não existe aqui
- // nenhuma função genérica de POST/PUT, só os diagnósticos da allowlist abaixo.
- export const TIPOS_DIAGNOSTICO = ["ping", "traceroute", "wifi-scan"];
- const ESTADOS_CONCLUIDO = ["Complete", "Completed"];
- const ESTADOS_ERRO_RE = /^Error/i;
- // resultado de diagnóstico vem do relógio do ACS/CPE: tolera uma pequena diferença de relógio
- const TOLERANCIA_RELOGIO_MS = 5_000;
- // /acs ping repetido logo depois só relê o resultado, sem disparar outro teste na CPE
- const PENDENCIA_VALIDADE_MS = 5 * 60_000;
- const MINUTOS_ONLINE = 10;
- const MINUTOS_OFFLINE = 60;
- export class AcsError extends Error {
- constructor(message, statusCode = 502, status = null) {
- super(message);
- this.statusCode = statusCode;
- this.status = status;
- }
- }
- async function carregarIntegracao() {
- const integracao = await Integracao.query().findOne({ Tipo: INTEGRACAO_TIPO_ACS });
- if (!integracao?.Url || !integracao?.Token) throw new AcsError("integracao_acs_nao_configurada");
- return integracao;
- }
- async function fetchJson(path, { method = "GET", body } = {}) {
- const integracao = await carregarIntegracao();
- let res;
- try {
- res = await acsHttpClient.request({
- url: `${baseUrlComBarra(integracao.Url)}${path}`,
- method,
- headers: { Authorization: `Bearer ${integracao.Token}` },
- data: body
- });
- } catch (error) {
- const status = error.response?.status ?? null;
- const detail = error.response?.data?.detail;
- // 404 = CPE/alias não existe; 409 = alias ambíguo — o chamador decide a mensagem
- if (status === 404 || status === 409) throw new AcsError(detail ?? `acs_${status}`, status, status);
- throw new AcsError(`acs_fetch_error:${status ?? "sem_resposta"}${detail ? ` (${detail})` : ""}`, 502, status);
- }
- if (typeof res.data === "string") throw new AcsError("acs_invalid_json");
- return res.data;
- }
- // O ACS aceita "pppoe:<usuario>" e "serial:<numero>" no lugar do id interno.
- export function refPorPppoe(pppoe) {
- return encodeURIComponent(`pppoe:${String(pppoe).trim()}`);
- }
- export function refPorSerial(serial) {
- return encodeURIComponent(`serial:${String(serial).trim()}`);
- }
- export function minutosDesde(data) {
- const t = data ? new Date(data).getTime() : NaN;
- return Number.isFinite(t) ? (Date.now() - t) / 60_000 : null;
- }
- // mesma regra do ACS (dispositivoResumo/alertas): online até 10 min sem Inform, offline após 60 min
- export function statusConexao(ultimoInform) {
- const min = minutosDesde(ultimoInform);
- if (min === null) return "nunca_informou";
- if (min <= MINUTOS_ONLINE) return "online";
- if (min <= MINUTOS_OFFLINE) return "atrasado";
- return "offline";
- }
- // GPON classe B+: faixa de recepção usual da ONU
- export function classificarSinalRx(rx) {
- if (typeof rx !== "number" || !Number.isFinite(rx)) return null;
- if (rx > -8) return "saturado";
- if (rx >= -25) return "ok";
- if (rx >= -27) return "atencao";
- return "critico";
- }
- // SSIDs extras de fábrica que a CPE reporta desligados (DM985: "SSID2".."SSID8"; DM986:
- // "DTC_1_5G", "DTC__2_2.4G"...) — não são do cliente e o LLM os descrevia como "redes desconhecidas".
- // Ligado com nome genérico continua aparecendo: aí é uma rede em uso.
- const SSID_GENERICO_RE = /^(SSID\d*|DTC_+\d+_(2\.4|5)G)$/i;
- export function radiosVisiveis(radios) {
- if (!Array.isArray(radios)) return [];
- return radios.filter((r) => !(r?.habilitado === false && SSID_GENERICO_RE.test(String(r?.ssid ?? "").trim())));
- }
- export async function buscarDispositivo(ref) {
- try {
- return await fetchJson(`devices/${ref}`);
- } catch (err) {
- if (err.status === 404) return null;
- throw err;
- }
- }
- export async function listarDispositivos({ filtro, pppoe, serial, status, limite = 20 } = {}) {
- const params = new URLSearchParams();
- if (filtro) params.set("filtro", filtro);
- if (pppoe) params.set("pppoe", pppoe);
- if (serial) params.set("serial", serial);
- if (status) params.set("status", status);
- params.set("limite", String(limite));
- const json = await fetchJson(`devices?${params}`);
- return Array.isArray(json) ? json : [];
- }
- function validarTipo(tipo) {
- if (!TIPOS_DIAGNOSTICO.includes(tipo)) throw new AcsError(`diagnostico_nao_permitido:${tipo}`, 400);
- }
- export async function dispararDiagnostico(ref, tipo, host) {
- validarTipo(tipo);
- const body = tipo === "wifi-scan" ? {} : { host };
- return fetchJson(`devices/${ref}/${tipo}`, { method: "POST", body });
- }
- export async function lerDiagnostico(ref, tipo) {
- validarTipo(tipo);
- try {
- return await fetchJson(`devices/${ref}/${tipo}`);
- } catch (err) {
- // o ACS responde 404 também quando a CPE ainda não tem o objeto de diagnóstico
- if (err.status === 404) return { estado: null };
- throw err;
- }
- }
- // ping/traceroute: o objeto de diagnóstico guarda o último teste até o próximo Inform —
- // só vale o resultado do host pedido e gravado depois do disparo.
- // wifi-scan não devolve host nem horário: vale quando já foi visto em andamento antes de concluir.
- function resultadoValido(tipo, resultado, { host, disparadoEm, viuAndamento }) {
- if (!resultado || !ESTADOS_CONCLUIDO.includes(resultado.estado)) return false;
- if (tipo === "wifi-scan") return viuAndamento;
- if (String(resultado.host ?? "").toLowerCase() !== String(host).toLowerCase()) return false;
- if (resultado.medido_em) {
- const medido = new Date(resultado.medido_em).getTime();
- if (Number.isFinite(medido) && medido < disparadoEm - TOLERANCIA_RELOGIO_MS) return false;
- }
- return true;
- }
- const dormir = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
- // chave -> { host, disparadoEm, viuAndamento } — evita disparar o mesmo teste de novo quando
- // o atendente repete o comando para ver se o resultado já chegou
- const pendentes = new Map();
- function limparPendentesVencidos(agora) {
- for (const [chave, p] of pendentes) {
- if (agora - p.disparadoEm > PENDENCIA_VALIDADE_MS) pendentes.delete(chave);
- }
- }
- // Dispara (se ainda não houver um igual pendente) e acompanha até o prazo.
- // Retorna { status: "ok" | "pendente" | "erro", resultado, reaproveitado }.
- export async function executarDiagnostico(ref, tipo, host, {
- prazoMs = config.acs.diagnosticoPrazoMs,
- intervaloMs = config.acs.diagnosticoIntervaloMs
- } = {}) {
- validarTipo(tipo);
- const agora = Date.now();
- limparPendentesVencidos(agora);
- const chave = `${ref}|${tipo}|${tipo === "wifi-scan" ? "" : String(host).toLowerCase()}`;
- let pendente = pendentes.get(chave);
- const reaproveitado = Boolean(pendente);
- if (!pendente) {
- await dispararDiagnostico(ref, tipo, host);
- pendente = { host, disparadoEm: agora, viuAndamento: false };
- pendentes.set(chave, pendente);
- }
- const fim = Date.now() + prazoMs;
- let resultado = null;
- for (;;) {
- resultado = await lerDiagnostico(ref, tipo);
- if (resultado?.estado && !ESTADOS_CONCLUIDO.includes(resultado.estado)) pendente.viuAndamento = true;
- if (resultadoValido(tipo, resultado, pendente)) {
- pendentes.delete(chave);
- return { status: "ok", resultado, reaproveitado };
- }
- if (resultado?.estado && ESTADOS_ERRO_RE.test(resultado.estado) && (tipo === "wifi-scan" || resultado.host === host)) {
- pendentes.delete(chave);
- return { status: "erro", resultado, reaproveitado };
- }
- if (Date.now() + intervaloMs > fim) break;
- await dormir(intervaloMs);
- }
- // varredura rápida pode concluir entre duas leituras sem nunca aparecer "em andamento":
- // devolve o que houver, marcado como possivelmente de uma varredura anterior
- if (tipo === "wifi-scan" && ESTADOS_CONCLUIDO.includes(resultado?.estado)) {
- pendentes.delete(chave);
- return { status: "ok", resultado, reaproveitado, possivelmenteAntigo: true };
- }
- return { status: "pendente", resultado: null, reaproveitado };
- }
|