/** * lan-picker.js — 局域网设备发现面板(一份实现,两个入口) * * 入口:tabs.html 顶栏的 🔍(外壳文档 = 扫描的宿主)与 pages/index.html IP 行 * 的「发现」。iframe 里的入口只请求外壳开面板;外壳是同源父文档,所以不需要 * 选举——同一时刻只有一份扫描状态。 * * 分工(重要): * - 判据只在服务侧一处:lan_probe.classify() 说是不是板子,页面不复算, * 免得出现第二个"什么算设备"的口径。 * - 编排只在这里:候选表、分批、进度、取消、缓存、渲染。 * - 换端点只走 deviceLink.selectDevice()(唯一的写手 + 唯一的门)。 * * 当前已选端点**不重复开连接**去探:Owner 那条 1Hz /system/status 轮询已经是 * 它的活体判决(板子 httpd 只有 6 个 socket 且开了 lru_purge,多开一条会踢掉 * 网页自己的 keep-alive)。所以候选表把当前端点剔掉,行内容由快照渲染。 */ "use strict"; import { deviceLink } from './device-link.js'; import { isValidIpv4, getLanDevices, saveLanDevices, getDeviceIp } from './base-url.js'; import { escapeHtml, flashBusyNow } from './utils.js'; var PROBE_PATH = '/system/status?lite=1'; var BATCH_IPS = 64; // 一次 /api/lan/probe 的 IP 数(与服务侧 BATCH_MAX_IPS 一致) var CLIENT_PARALLEL = 2; // 同时在途的批数(整段 /24 实测 ~3.5–6s) var BATCH_TIMEOUT_MS = 6000; // 一批的 fetch 上限(服务侧一批最坏 ~1.8s) var PER_IP_TIMEOUT_MS = 750; // 交给服务侧的单目标硬截止 var STALE_MS = 20000; // 结果"新鲜"窗口,过期行变灰、按钮改「尝试连接」 var SEED_IPS = ['192.168.31.127', '192.168.31.1']; var MAX_ROWS = 40; // 未知/拒绝列表各截断到这里,但计数照全报 var state = { open: false, scanning: false, cancel: false, tier: '', cidr: '', local: null, rows: [], // verdict=board unknowns: [], // 连不上/读超时/形状合但落不进判据 rejected: [], // 有反证的 scan: null, skipped: [], detail: Object.create(null), // ip -> 完整 status(人点了「详情」才要) direct: Object.create(null), // ip -> 本页直测结论(B 路证据) note: null, progress: { done: 0, total: 0 }, autoScanned: 0 }; /* ───────────────────────── 纯函数(不碰 DOM,便于推理与离线核对) ───────────────────────── */ /** @description 192.168.31.0/24 → 1..254 主机地址。只支持 /24(掩码更宽请直接填 CIDR)。 */ export function cidrToIps(cidr) { var s = String(cidr || '').trim(); var bits = s.split('/'); var q = bits[0].split('.'); if (q.length !== 4 || !isValidIpv4(q.join('.'))) return { err: 'CIDR 不合法:' + s }; var mask = bits.length > 1 ? parseInt(bits[1], 10) : 24; if (mask !== 24) return { err: '只支持 /24(当前 /' + mask + ');需要更小的段请逐个手填 IP' }; if (bits.length > 1 && q[3] !== '0') return { err: 'CIDR 网络地址段末应为 .0:' + s }; var base = q.slice(0, 3).join('.'); var ips = []; for (var i = 1; i <= 254; i++) ips.push(base + '.' + i); return { ips: ips, cidr: base + '.0/24' }; } /** @description 私有网段判定(10/8、172.16/12、192.168/16)。 */ export function isPrivateIp(ip) { var q = String(ip).split('.'); var a = parseInt(q[0], 10), b = parseInt(q[1], 10); if (a === 10) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; return false; } /** * @description 候选表 = 去重 + 剔掉"探了也没意义"的地址。 * 被剔掉的每一条都带原因并显示出来——静默丢弃会让本机 :80 服务冒充板子。 * @param {{ips:string[], localIps?:string[], endpointIp?:string, allowPublic?:boolean}} o */ export function buildCandidates(o) { var local = {}; (o.localIps || []).forEach(function (ip) { local[ip] = 1; }); var out = [], skipped = [], seen = {}; (o.ips || []).forEach(function (ip) { var v = String(ip || '').trim(); if (!isValidIpv4(v)) { if (v) skipped.push([v, '不是 IPv4']); return; } if (seen[v]) return; seen[v] = 1; if (/^127\./.test(v)) { skipped.push([v, '回环']); return; } if (/^169\.254\./.test(v)) { skipped.push([v, '链路本地']); return; } if (local[v]) { skipped.push([v, '本机地址']); return; } if (o.endpointIp && o.endpointIp === v) { skipped.push([v, '当前端点:由 1Hz 轮询判活,不重复开连接']); return; } if (!isPrivateIp(v) && !o.allowPublic) { skipped.push([v, '非私有网段(要探请勾"允许公网段")']); return; } out.push(v); }); return { ips: out, skipped: skipped }; } /** @description 把服务侧逐 IP 结果分成三堆。判决本身来自服务侧,这里不重算。 */ export function groupResults(results) { var g = { boards: [], unknowns: [], rejected: [] }; (results || []).forEach(function (r) { if (!r || !r.ip) return; /* 服务侧不返回时间戳,这里补上"本轮观测时刻"——否则行会被当成过期数据变灰。 */ if (!r.observedAt) r.observedAt = Date.now(); if (r.verdict === 'board') g.boards.push(r); else if (r.verdict === 'rejected') g.rejected.push(r); else g.unknowns.push(r); }); return g; } /** @description 快扫种子 = 当前端点 + 历史默认 + 上次发现到的(都过一遍候选过滤)。 */ export function seedIps(endpointIp, lastSeen) { var list = SEED_IPS.slice(); if (endpointIp) list.unshift(endpointIp); (lastSeen || []).forEach(function (ip) { if (list.indexOf(ip) < 0) list.push(ip); }); return list.slice(0, 8); } /* ───────────────────────── 服务侧调用 ───────────────────────── */ async function apiProbe(body, timeoutMs) { var resp = await fetch('/api/lan/probe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', signal: AbortSignal.timeout(timeoutMs || BATCH_TIMEOUT_MS) }); var text = await resp.text(); var j = null; try { j = text ? JSON.parse(text) : null; } catch (_) { throw new Error('探测响应非合法 JSON(HTTP ' + resp.status + ')'); } if (!resp.ok) { var err = new Error('HTTP ' + resp.status); err.json = j; throw err; } return j || {}; } /** @description 「本页直测」:让页面自己打一次 lite,证明*这个文档*能不能到它。 * 服务侧扫到(A)与页面能连(B)是两条独立证据,浏览器对 127.0.0.1→私有子网 * 的限制(Local Network Access)只会影响 B,所以这条要能单独出结论。 */ async function directProbe(ip, path, timeoutMs) { var t0 = (window.performance || Date).now(); try { var r = await fetch('http://' + ip + (path || PROBE_PATH), { mode: 'cors', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs || 2500) }); var j = null; try { j = await r.json(); } catch (_) { j = null; } return { ok: r.ok && j !== null, http: r.status, json: j, ms: Math.round((window.performance || Date).now() - t0) }; } catch (e) { return { ok: false, name: (e && e.name) || 'Error', msg: (e && e.message) || String(e), ms: Math.round((window.performance || Date).now() - t0) }; } } /* ───────────────────────── 扫描编排 ───────────────────────── */ function canStartScan() { if (state.scanning) return '上一批还在跑(先取消或等它结束)'; /* 门放在发起处一次判完:刷写中扫段会抢设备 httpd 的 6 个 socket 并触发 * lru_purge 踢连接,等于把正在灌写的会话掐死。 */ if (flashBusyNow()) return '刷写/OTA 进行中,不发起扫描(会占设备 httpd 的 socket 并可能踢掉正在灌写的连接)'; return ''; } async function runScan(tier) { var refuse = canStartScan(); if (refuse) { say('warn', refuse); return; } state.scanning = true; state.cancel = false; state.tier = tier; state.rows = []; state.unknowns = []; state.rejected = []; state.skipped = []; state.scan = null; state.note = null; lastListHtml = null; state.progress = { done: 0, total: 0 }; render(); var groups = { boards: [], unknowns: [], rejected: [] }; /* durationMs 是各批服务端耗时之和(并发下会大于真实等待),只用于对账; * 给人看的"多久"用页面墙钟,别把累加值当耗时报。 */ var stats = { candidates: 0, tcpOpen: 0, httpProbed: 0, board: 0, rejected: 0, unknown: 0, durationMs: 0, wallMs: 0 }; var t0 = Date.now(); var err = ''; try { var endpoint = deviceLink.getIp(); var lastSeen = getLanDevices().map(function (d) { return d.ip; }); // 一次空探测顺便取本机网段(不浪费往返;候选为 0 时服务侧立即返回) var hello = await apiProbe({ ips: [], ports: [80], local: true }, 4000); state.local = hello.local || null; var localIps = (hello.local && hello.local.local_ips) || []; function fullIps() { var cidr = state.cidr || (hello.local && hello.local.cidr) || ''; if (!cidr) throw new Error('取不到本机默认路由,无法派生 /24。请在 CIDR 框手填(如 192.168.31.0/24)后重试。'); var c = cidrToIps(cidr); if (c.err) throw new Error(c.err); state.cidr = c.cidr; return c.ips; } async function sweep(raw) { var built = buildCandidates({ ips: raw, localIps: localIps, endpointIp: endpoint, allowPublic: !!state.allowPublic }); state.skipped = state.skipped.concat(built.skipped); var ips = built.ips; /* 当前端点不进候选表(由 Owner 的 1Hz 快照判活),但作为一行结果单独摆着。 */ stats.candidates += ips.length; state.progress.total += ips.length; render(); var queue = []; for (var i = 0; i < ips.length; i += BATCH_IPS) queue.push(ips.slice(i, i + BATCH_IPS)); /* 批间并发 CLIENT_PARALLEL;服务侧一批内并发 48(跨不同地址, * 一块板子全程只被占 1 个 socket)。JS 单线程,await 恢复后的累加是原子的。 */ async function worker() { while (queue.length && !state.cancel) { var batch = queue.shift(); if (!batch) break; var out = await apiProbe({ ips: batch, ports: [80], path: PROBE_PATH, timeout_ms: PER_IP_TIMEOUT_MS }); var g = groupResults(out.results); groups.boards = groups.boards.concat(g.boards); groups.unknowns = groups.unknowns.concat(g.unknowns); groups.rejected = groups.rejected.concat(g.rejected); var s = out.scan || {}; ['tcpOpen', 'httpProbed', 'board', 'rejected', 'unknown'].forEach(function (k) { stats[k] = (stats[k] || 0) + (s[k] || 0); }); stats.durationMs += (s.durationMs || 0); if (s.truncated && s.pending && s.pending.length) { state.skipped.push(['—', '本批预算用尽,未探 ' + s.pending.length + ' 个']); } (out.errors || []).forEach(function (e2) { state.skipped.push(['—', '服务侧: ' + e2]); }); state.progress.done = Math.min(state.progress.done + batch.length, state.progress.total); state.rows = groups.boards; state.unknowns = groups.unknowns; state.rejected = groups.rejected; render(); } } var workers = []; for (var w = 0; w < Math.max(1, CLIENT_PARALLEL); w++) workers.push(worker()); await Promise.all(workers); } if (tier === 'fast') { await sweep(seedIps(endpoint, lastSeen)); /* 种子没命中就自动扩到整段:设备换 DHCP 地址正是最常见的失效原因, * 而整段实测 ~4s(并发 48),没理由让用户再点一次按钮。 */ if (!groups.boards.length && !state.cancel) { var seg = (state.local && state.local.cidr) || '/24'; state.skipped.push(['—', '种子未命中 → 自动扩到整段 ' + seg]); say('warn', '种子里没有设备(多半是地址变了)——自动扩到整段 ' + seg); await sweep(fullIps()); } } else { await sweep(fullIps()); } if (state.cancel) say('warn', '已取消:已探测 ' + state.progress.done + '/' + state.progress.total + ' 个地址,结果保留在下面(取消只表示不再发下一批,在途那批仍会跑完)'); } catch (e) { err = (e && e.message) || String(e); } state.scanning = false; stats.wallMs = Date.now() - t0; state.scan = stats; if (err) state.note = { kind: 'error', text: err, at: Date.now() }; // 只把"确认是板子"的写回候选种子缓存;未知/拒绝的不配当种子。 persistSeen(groups.boards); render(); if (!err) { say('info', '扫描完成:候选 ' + stats.candidates + ' → 端口开 ' + stats.tcpOpen + ' → 板子 ' + groups.boards.length + ' 台,不可判定 ' + groups.unknowns.length + ',反证排除 ' + groups.rejected.length + '(用时 ' + stats.wallMs + 'ms)'); } } function persistSeen(boards) { var prev = getLanDevices(); var now = Date.now(); var byIp = {}; (boards || []).forEach(function (b) { byIp[b.ip] = { ip: b.ip, observedAt: now, mode: b.mode, kind: b.kind }; }); // 本轮没探到的旧条目保留但不再刷新 observedAt —— 它会自然变灰,不会假装"还在" prev.forEach(function (p) { if (!byIp[p.ip]) byIp[p.ip] = p; }); saveLanDevices(Object.keys(byIp).map(function (k) { return byIp[k]; })); } /* ───────────────────────── 反馈 ───────────────────────── */ function say(kind, text) { state.note = { kind: kind, text: text, at: Date.now() }; renderNote(); var lvl = kind === 'error' ? 'E' : (kind === 'warn' ? 'W' : 'I'); /* 外壳文档(tabs.html)没有日志面板也没有 toast 容器,所以面板自己的这行 * 是**兜底的、必定可见的**出口;页面的 log/toast 只是额外一份。 */ if (typeof window.addLog === 'function') { try { window.addLog(lvl, '设备', text); } catch (_) {} } if (kind !== 'info' && typeof window.showToast === 'function') { try { window.showToast(text, kind === 'error' ? 'error' : 'warning', { force: true }); } catch (_) {} } } /* ───────────────────────── 渲染 ───────────────────────── */ var pillEl = null, pillTxtEl = null, panelEl = null; /* 面板每 2s 会被定时器刷一次;列表 HTML 不变就不重排 DOM(否则输入焦点/滚动 会被重建打掉)。扫描开始、选中设备时显式作废。 */ var lastListHtml = null; function ageText(ts) { if (!ts) return '—'; var s = Math.round((Date.now() - ts) / 1000); if (s < 60) return s + 's 前'; if (s < 3600) return Math.round(s / 60) + 'min 前'; return Math.round(s / 3600) + 'h 前'; } function boardLabel(b) { var d = state.detail[b.ip]; var bits = [b.kind === 'ap' ? 'AP 回退' : 'STA']; if (d) { bits.push('v' + (d.version || '?')); if (d.git_hash) bits.push(d.git_hash.slice(0, 7)); if (d.wifi && Number.isFinite(d.wifi.rssi)) bits.push(d.wifi.rssi + 'dBm'); if (Number.isFinite(d.uptime_sec)) bits.push('up ' + Math.round(d.uptime_sec) + 's'); } else if (b.schema === 'core-only') { bits.push('旧固件(无 gd32_features)'); } if (b.schema === 'full' && !d) bits.push('schema 全'); return bits.join(' · '); } function rowHtml(b) { var isSel = b.ip === deviceLink.getIp(); var stale = !b.observedAt || (Date.now() - b.observedAt) > STALE_MS; var d = state.direct[b.ip]; /* 行用 layout.css 的 .jump-history-item(.jh-tag / .jh-msg / .jh-time 与 .ok/.info 状态色都是既有类),这里只加"可点选"与过期两种修饰。 */ var h = '