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:" e "serial:" 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 }; }