acsService.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { config } from "../config/index.js";
  2. import { Integracao, INTEGRACAO_TIPO_ACS } from "../models/Integracao.model.js";
  3. import { baseUrlComBarra } from "../functions/atualizaTokenIxcIntegradorERetorna.js";
  4. import acsHttpClient from "./acsHttpClient.js";
  5. // Cliente da API pública do ACS Space (/api/v1). Só leitura + disparo de diagnóstico:
  6. // o Oráculo nunca reinicia nem altera configuração de CPE — por isso não existe aqui
  7. // nenhuma função genérica de POST/PUT, só os diagnósticos da allowlist abaixo.
  8. export const TIPOS_DIAGNOSTICO = ["ping", "traceroute", "wifi-scan"];
  9. const ESTADOS_CONCLUIDO = ["Complete", "Completed"];
  10. const ESTADOS_ERRO_RE = /^Error/i;
  11. // resultado de diagnóstico vem do relógio do ACS/CPE: tolera uma pequena diferença de relógio
  12. const TOLERANCIA_RELOGIO_MS = 5_000;
  13. // /acs ping repetido logo depois só relê o resultado, sem disparar outro teste na CPE
  14. const PENDENCIA_VALIDADE_MS = 5 * 60_000;
  15. const MINUTOS_ONLINE = 10;
  16. const MINUTOS_OFFLINE = 60;
  17. export class AcsError extends Error {
  18. constructor(message, statusCode = 502, status = null) {
  19. super(message);
  20. this.statusCode = statusCode;
  21. this.status = status;
  22. }
  23. }
  24. async function carregarIntegracao() {
  25. const integracao = await Integracao.query().findOne({ Tipo: INTEGRACAO_TIPO_ACS });
  26. if (!integracao?.Url || !integracao?.Token) throw new AcsError("integracao_acs_nao_configurada");
  27. return integracao;
  28. }
  29. async function fetchJson(path, { method = "GET", body } = {}) {
  30. const integracao = await carregarIntegracao();
  31. let res;
  32. try {
  33. res = await acsHttpClient.request({
  34. url: `${baseUrlComBarra(integracao.Url)}${path}`,
  35. method,
  36. headers: { Authorization: `Bearer ${integracao.Token}` },
  37. data: body
  38. });
  39. } catch (error) {
  40. const status = error.response?.status ?? null;
  41. const detail = error.response?.data?.detail;
  42. // 404 = CPE/alias não existe; 409 = alias ambíguo — o chamador decide a mensagem
  43. if (status === 404 || status === 409) throw new AcsError(detail ?? `acs_${status}`, status, status);
  44. throw new AcsError(`acs_fetch_error:${status ?? "sem_resposta"}${detail ? ` (${detail})` : ""}`, 502, status);
  45. }
  46. if (typeof res.data === "string") throw new AcsError("acs_invalid_json");
  47. return res.data;
  48. }
  49. // O ACS aceita "pppoe:<usuario>" e "serial:<numero>" no lugar do id interno.
  50. export function refPorPppoe(pppoe) {
  51. return encodeURIComponent(`pppoe:${String(pppoe).trim()}`);
  52. }
  53. export function refPorSerial(serial) {
  54. return encodeURIComponent(`serial:${String(serial).trim()}`);
  55. }
  56. export function minutosDesde(data) {
  57. const t = data ? new Date(data).getTime() : NaN;
  58. return Number.isFinite(t) ? (Date.now() - t) / 60_000 : null;
  59. }
  60. // mesma regra do ACS (dispositivoResumo/alertas): online até 10 min sem Inform, offline após 60 min
  61. export function statusConexao(ultimoInform) {
  62. const min = minutosDesde(ultimoInform);
  63. if (min === null) return "nunca_informou";
  64. if (min <= MINUTOS_ONLINE) return "online";
  65. if (min <= MINUTOS_OFFLINE) return "atrasado";
  66. return "offline";
  67. }
  68. // GPON classe B+: faixa de recepção usual da ONU
  69. export function classificarSinalRx(rx) {
  70. if (typeof rx !== "number" || !Number.isFinite(rx)) return null;
  71. if (rx > -8) return "saturado";
  72. if (rx >= -25) return "ok";
  73. if (rx >= -27) return "atencao";
  74. return "critico";
  75. }
  76. // SSIDs extras de fábrica que a CPE reporta desligados (DM985: "SSID2".."SSID8"; DM986:
  77. // "DTC_1_5G", "DTC__2_2.4G"...) — não são do cliente e o LLM os descrevia como "redes desconhecidas".
  78. // Ligado com nome genérico continua aparecendo: aí é uma rede em uso.
  79. const SSID_GENERICO_RE = /^(SSID\d*|DTC_+\d+_(2\.4|5)G)$/i;
  80. export function radiosVisiveis(radios) {
  81. if (!Array.isArray(radios)) return [];
  82. return radios.filter((r) => !(r?.habilitado === false && SSID_GENERICO_RE.test(String(r?.ssid ?? "").trim())));
  83. }
  84. export async function buscarDispositivo(ref) {
  85. try {
  86. return await fetchJson(`devices/${ref}`);
  87. } catch (err) {
  88. if (err.status === 404) return null;
  89. throw err;
  90. }
  91. }
  92. export async function listarDispositivos({ filtro, pppoe, serial, status, limite = 20 } = {}) {
  93. const params = new URLSearchParams();
  94. if (filtro) params.set("filtro", filtro);
  95. if (pppoe) params.set("pppoe", pppoe);
  96. if (serial) params.set("serial", serial);
  97. if (status) params.set("status", status);
  98. params.set("limite", String(limite));
  99. const json = await fetchJson(`devices?${params}`);
  100. return Array.isArray(json) ? json : [];
  101. }
  102. function validarTipo(tipo) {
  103. if (!TIPOS_DIAGNOSTICO.includes(tipo)) throw new AcsError(`diagnostico_nao_permitido:${tipo}`, 400);
  104. }
  105. export async function dispararDiagnostico(ref, tipo, host) {
  106. validarTipo(tipo);
  107. const body = tipo === "wifi-scan" ? {} : { host };
  108. return fetchJson(`devices/${ref}/${tipo}`, { method: "POST", body });
  109. }
  110. export async function lerDiagnostico(ref, tipo) {
  111. validarTipo(tipo);
  112. try {
  113. return await fetchJson(`devices/${ref}/${tipo}`);
  114. } catch (err) {
  115. // o ACS responde 404 também quando a CPE ainda não tem o objeto de diagnóstico
  116. if (err.status === 404) return { estado: null };
  117. throw err;
  118. }
  119. }
  120. // ping/traceroute: o objeto de diagnóstico guarda o último teste até o próximo Inform —
  121. // só vale o resultado do host pedido e gravado depois do disparo.
  122. // wifi-scan não devolve host nem horário: vale quando já foi visto em andamento antes de concluir.
  123. function resultadoValido(tipo, resultado, { host, disparadoEm, viuAndamento }) {
  124. if (!resultado || !ESTADOS_CONCLUIDO.includes(resultado.estado)) return false;
  125. if (tipo === "wifi-scan") return viuAndamento;
  126. if (String(resultado.host ?? "").toLowerCase() !== String(host).toLowerCase()) return false;
  127. if (resultado.medido_em) {
  128. const medido = new Date(resultado.medido_em).getTime();
  129. if (Number.isFinite(medido) && medido < disparadoEm - TOLERANCIA_RELOGIO_MS) return false;
  130. }
  131. return true;
  132. }
  133. const dormir = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  134. // chave -> { host, disparadoEm, viuAndamento } — evita disparar o mesmo teste de novo quando
  135. // o atendente repete o comando para ver se o resultado já chegou
  136. const pendentes = new Map();
  137. function limparPendentesVencidos(agora) {
  138. for (const [chave, p] of pendentes) {
  139. if (agora - p.disparadoEm > PENDENCIA_VALIDADE_MS) pendentes.delete(chave);
  140. }
  141. }
  142. // Dispara (se ainda não houver um igual pendente) e acompanha até o prazo.
  143. // Retorna { status: "ok" | "pendente" | "erro", resultado, reaproveitado }.
  144. export async function executarDiagnostico(ref, tipo, host, {
  145. prazoMs = config.acs.diagnosticoPrazoMs,
  146. intervaloMs = config.acs.diagnosticoIntervaloMs
  147. } = {}) {
  148. validarTipo(tipo);
  149. const agora = Date.now();
  150. limparPendentesVencidos(agora);
  151. const chave = `${ref}|${tipo}|${tipo === "wifi-scan" ? "" : String(host).toLowerCase()}`;
  152. let pendente = pendentes.get(chave);
  153. const reaproveitado = Boolean(pendente);
  154. if (!pendente) {
  155. await dispararDiagnostico(ref, tipo, host);
  156. pendente = { host, disparadoEm: agora, viuAndamento: false };
  157. pendentes.set(chave, pendente);
  158. }
  159. const fim = Date.now() + prazoMs;
  160. let resultado = null;
  161. for (;;) {
  162. resultado = await lerDiagnostico(ref, tipo);
  163. if (resultado?.estado && !ESTADOS_CONCLUIDO.includes(resultado.estado)) pendente.viuAndamento = true;
  164. if (resultadoValido(tipo, resultado, pendente)) {
  165. pendentes.delete(chave);
  166. return { status: "ok", resultado, reaproveitado };
  167. }
  168. if (resultado?.estado && ESTADOS_ERRO_RE.test(resultado.estado) && (tipo === "wifi-scan" || resultado.host === host)) {
  169. pendentes.delete(chave);
  170. return { status: "erro", resultado, reaproveitado };
  171. }
  172. if (Date.now() + intervaloMs > fim) break;
  173. await dormir(intervaloMs);
  174. }
  175. // varredura rápida pode concluir entre duas leituras sem nunca aparecer "em andamento":
  176. // devolve o que houver, marcado como possivelmente de uma varredura anterior
  177. if (tipo === "wifi-scan" && ESTADOS_CONCLUIDO.includes(resultado?.estado)) {
  178. pendentes.delete(chave);
  179. return { status: "ok", resultado, reaproveitado, possivelmenteAntigo: true };
  180. }
  181. return { status: "pendente", resultado: null, reaproveitado };
  182. }