文件预览

network.js

查看 FTTR Copilot 技能包中的文件内容。

文件内容

src/tools/network.js

import { getDeviceDetail, resolveDevice } from "./devices.js";

const LIST_NETWORK_TOPOLOGY = "/bison.networkstats.v1.NetworkStatsService/ListNetworkTopology";
const GET_STATION_STATS = "/bison.networkstats.v1.NetworkStatsService/GetStationStats";
const GET_NETWORK_EXPERIENCE = "/bison.networkstats.v1.NetworkStatsService/GetNetworkExperience";
const GET_STATION_EXPERIENCE = "/bison.networkstats.v1.NetworkStatsService/GetStationExperience";

export async function getNetworkTopology(client, args = {}) {
  const resolved = await resolveDeviceForMac(client, args.device_identifier);
  const response = await client.unary(LIST_NETWORK_TOPOLOGY, { mac: resolved.mac });
  const topology = formatTopology(response.topology || {});

  return {
    title: "网络拓扑",
    summary: `拓扑包含 ${topology.gateways.length} 个网关和 ${topology.stations.length} 个下挂设备。`,
    data: {
      device_identifier: args.device_identifier,
      resolved_device: resolved,
      topology,
      raw: response,
    },
    suggestions: topology.stations.length === 0
      ? ["当前拓扑未返回下挂设备,可继续确认终端是否已接入或查询设备在线状态。"]
      : ["可继续查询下挂设备指标,重点关注 RSSI、协商速率和收发包失败数。"],
  };
}

export async function getStationStats(client, args = {}) {
  const resolved = await resolveDeviceForMac(client, args.device_identifier);
  const response = await client.unary(GET_STATION_STATS, { mac: resolved.mac });
  const allStats = Array.isArray(response.stats) ? response.stats.map(formatStationStat) : [];
  const staMac = String(args.sta_mac || args.station_mac || "").trim();
  const stats = staMac
    ? allStats.filter((item) => sameMac(item.station.mac, staMac))
    : allStats;

  return {
    title: staMac ? "下挂设备指标" : "下挂设备指标列表",
    summary: stats.length === 0
      ? "未查询到下挂设备指标。"
      : `查询到 ${stats.length} 个下挂设备的指标。`,
    data: {
      device_identifier: args.device_identifier,
      resolved_device: resolved,
      sta_mac: staMac,
      stats,
      raw: response,
    },
    suggestions: stats.length === 0
      ? ["确认下挂设备 MAC 是否正确,或先查询网络拓扑获取下挂设备列表。"]
      : ["优先关注 RSSI 低、协商速率低、上下行速率异常或收发包失败数高的终端。"],
  };
}

export async function getNetworkExperience(client, args = {}) {
  const resolved = await resolveDeviceForMac(client, args.device_identifier);
  const response = await client.unary(GET_NETWORK_EXPERIENCE, { mac: resolved.mac });
  const experience = formatExperienceStats(response.experienceStats || response.experience_stats || {});

  return {
    title: "网络体验",
    summary: `网络体验评分: ${experience.network_score}`,
    data: {
      device_identifier: args.device_identifier,
      resolved_device: resolved,
      experience,
      raw: response,
    },
    suggestions: experience.network_score <= 0
      ? ["体验评分为空或为 0 时,建议结合拓扑和下挂设备指标判断是否为采集缺失。"]
      : ["可结合带宽趋势、RSSI 趋势和下挂设备指标定位网慢原因。"],
  };
}

export async function getStationExperience(client, args = {}) {
  const resolved = await resolveDeviceForMac(client, args.device_identifier);
  const staMac = requiredString(args.sta_mac || args.station_mac, "sta_mac");
  const response = await client.unary(GET_STATION_EXPERIENCE, {
    mac: resolved.mac,
    staMac,
  });
  const rssi = Array.isArray(response.rssiSerialize)
    ? response.rssiSerialize.map(formatRssiPoint)
    : Array.isArray(response.rssi_serialize)
      ? response.rssi_serialize.map(formatRssiPoint)
      : [];

  return {
    title: "下挂设备体验",
    summary: `下挂设备 ${response.staMac || response.sta_mac || staMac} 返回 ${rssi.length} 个 RSSI 数据点。`,
    data: {
      device_identifier: args.device_identifier,
      resolved_device: resolved,
      sta_mac: response.staMac || response.sta_mac || staMac,
      rssi,
      raw: response,
    },
    suggestions: rssi.length === 0
      ? ["没有 RSSI 历史数据时,可查询实时下挂设备指标或确认该终端是否在线。"]
      : ["RSSI 长期偏低或波动大时,优先检查终端位置、从网关拓扑和无线覆盖。"],
  };
}

async function resolveDeviceForMac(client, identifier) {
  const resolved = await resolveDevice(client, identifier);
  if (resolved.mac) {
    return resolved;
  }

  const detail = await getDeviceDetail(client, { device_identifier: resolved.id });
  const mac = detail.data?.detail?.mac || "";
  if (!mac) {
    const err = new Error(`设备 ${identifier} 未返回 MAC,无法查询网络统计`);
    err.code = "invalid_argument";
    throw err;
  }
  return {
    ...resolved,
    mac,
  };
}

function formatTopology(topology) {
  return {
    gateways: Array.isArray(topology.gateways) ? topology.gateways.map(formatNode) : [],
    stations: Array.isArray(topology.stations) ? topology.stations.map(formatNode) : [],
    stats: Array.isArray(topology.stats) ? topology.stats.map(formatStationStats) : [],
  };
}

function formatNode(node) {
  return {
    type: node.type || "",
    mac: node.mac || "",
    name: node.name || "",
    ip: node.ip || "",
    interface: node.interface || "",
    ratio: node.ratio || "",
    standard: node.standard || "",
    parent_mac: node.parentMac || node.parent_mac || "",
    online: Boolean(node.online),
    station_type: node.stationType || node.station_type || "",
    first_see_time: node.firstSeeTime || node.first_see_time || null,
    up_link_negotiation_rate: numberField(node.upLinkNegotiationRate ?? node.up_link_negotiation_rate),
    down_link_negotiation_rate: numberField(node.downLinkNegotiationRate ?? node.down_link_negotiation_rate),
    brand: node.brand || "",
  };
}

function formatStationStat(item) {
  return {
    station: formatStation(item.station || {}),
    stats: formatStationStats(item.stats || {}),
  };
}

function formatStation(station) {
  return {
    type: station.type || "",
    mac: station.mac || "",
    name: station.name || "",
    ip: station.ip || "",
    interface: station.interface || "",
    ratio: station.ratio || "",
    standard: station.standard || "",
    parent_mac: station.parentMac || station.parent_mac || "",
    online: Boolean(station.online),
    first_see_time: station.firstSeeTime || station.first_see_time || null,
    up_link_negotiation_rate: numberField(station.upLinkNegotiationRate ?? station.up_link_negotiation_rate),
    down_link_negotiation_rate: numberField(station.downLinkNegotiationRate ?? station.down_link_negotiation_rate),
    brand: station.brand || "",
  };
}

function formatStationStats(stats) {
  return {
    mac: stats.mac || "",
    ping_delay: numberField(stats.pingDelay ?? stats.ping_delay),
    rssi: numberField(stats.rssi),
    up_speed: numberField(stats.upSpeed ?? stats.up_speed),
    down_speed: numberField(stats.downSpeed ?? stats.down_speed),
    up_link_negotiation_rate: numberField(stats.upLinkNegotiationRate ?? stats.up_link_negotiation_rate),
    down_link_negotiation_rate: numberField(stats.downLinkNegotiationRate ?? stats.down_link_negotiation_rate),
    rx_flow: numberField(stats.rxFlow ?? stats.rx_flow),
    tx_flow: numberField(stats.txFlow ?? stats.tx_flow),
    rx_pkt_total: numberField(stats.rxPktTotal ?? stats.rx_pkt_total),
    tx_pkt_total: numberField(stats.txPktTotal ?? stats.tx_pkt_total),
    rx_pkt_retry: numberField(stats.rxPktRetry ?? stats.rx_pkt_retry),
    tx_pkt_retry: numberField(stats.txPktRetry ?? stats.tx_pkt_retry),
    rx_pkt_fail: numberField(stats.rxPktFail ?? stats.rx_pkt_fail),
    tx_pkt_fail: numberField(stats.txPktFail ?? stats.tx_pkt_fail),
    pkt_total: numberField(stats.pktTotal ?? stats.pkt_total),
    pkt_loss: numberField(stats.pktLoss ?? stats.pkt_loss),
    delay_avg: numberField(stats.delayAvg ?? stats.delay_avg),
  };
}

function formatExperienceStats(experience) {
  return {
    network_score: numberField(experience.networkScore ?? experience.network_score),
    experience_serialize: arrayField(experience.experienceSerialize ?? experience.experience_serialize).map((item) => ({
      time_window: item.timeWindow || item.time_window || null,
      num_good: numberField(item.numGood ?? item.num_good),
      num_bad: numberField(item.numBad ?? item.num_bad),
      num_normal: numberField(item.numNormal ?? item.num_normal),
    })),
    bandwidth_serialize: arrayField(experience.bandwidthSerialize ?? experience.bandwidth_serialize).map((item) => ({
      time_window: item.timeWindow || item.time_window || null,
      up: numberField(item.up),
      down: numberField(item.down),
    })),
    rssi_serialize: arrayField(experience.rssiSerialize ?? experience.rssi_serialize).map(formatRssiPoint),
  };
}

function formatRssiPoint(item) {
  return {
    time_window: item.timeWindow || item.time_window || null,
    avg: numberField(item.avg),
    min: numberField(item.min),
    max: numberField(item.max),
  };
}

function requiredString(value, fieldName) {
  const trimmed = String(value || "").trim();
  if (!trimmed) {
    const err = new Error(`${fieldName} 不能为空`);
    err.code = "invalid_argument";
    throw err;
  }
  return trimmed;
}

function sameMac(left, right) {
  return normalizeMac(left) === normalizeMac(right);
}

function normalizeMac(value) {
  return String(value || "").trim().toLowerCase().replace(/[:.\-\s]/g, "");
}

function numberField(value) {
  const number = Number(value || 0);
  return Number.isFinite(number) ? number : 0;
}

function arrayField(value) {
  return Array.isArray(value) ? value : [];
}