feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
+87
-3
@@ -4,9 +4,41 @@
|
||||
* 各页面共用 localStorage key dcu_esp_ip(OTA / CAN / 电机页一致)。
|
||||
* 本模块只提供纯 localStorage + 默认值的通用实现;页面特有的输入框回退
|
||||
* (如 CAN 页 el.toolIp、OTA 页 #espIp)由各自入口在此基础上包装。
|
||||
*
|
||||
* 写入一律经 saveDeviceIp 的 IPv4 校验:这是"哪个端点算当前设备"的唯一落盘点,
|
||||
* 脏值在这里被拒掉,比在每个调用方各判一次可靠(固件 AP/掉租约时会上报
|
||||
* ip:"0.0.0.0",见 ESP32_COMM/main/server/http_handlers.cpp:1776)。
|
||||
*/
|
||||
export var LS_ESP_IP = 'dcu_esp_ip';
|
||||
export var DEFAULT_ESP_IP = '192.168.31.127';
|
||||
/** @description 最近一次"选中"的设备快照(ip + 版本指纹),供端点一致性核对。 */
|
||||
export var LS_ESP_DEVICE = 'dcu_esp_device';
|
||||
/** @description 局域网发现结果缓存(带 observedAt),只给 UI 当候选种子用。 */
|
||||
export var LS_LAN_DEVICES = 'dcu_lan_devices';
|
||||
|
||||
/**
|
||||
* @description 严格点分十进制 IPv4,并拒掉不能当 API 目标的保留段。
|
||||
* 0/8(含固件脏值 0.0.0.0)与 255.255.255.255 直接拒;169.254/16 是链路本地,
|
||||
* 板子不会在那儿服务 HTTP,也拒——宁可让人重填,也不要网页打到一个
|
||||
* 永远不通的地址上还显示"已连接"。
|
||||
* @param {string} s 待校验字符串
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidIpv4(s) {
|
||||
var parts = String(s == null ? '' : s).trim().split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
var nums = [];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var p = parts[i];
|
||||
if (!/^\d{1,3}$/.test(p)) return false;
|
||||
var n = parseInt(p, 10);
|
||||
if (n > 255) return false;
|
||||
nums.push(n);
|
||||
}
|
||||
if (nums[0] === 0 || nums[0] === 255) return false;
|
||||
if (nums[0] === 169 && nums[1] === 254) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @description 读取 localStorage 中保存的设备 IP,无有效值返回空串。 */
|
||||
export function getSavedDeviceIp() {
|
||||
@@ -17,10 +49,17 @@ export function getSavedDeviceIp() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @description 保存设备 IP 到 localStorage(空值忽略)。 */
|
||||
/**
|
||||
* @description 保存设备 IP。非法值**拒绝且不改写现值**,返回 false——
|
||||
* 不能用 DEFAULT_ESP_IP 去"兜住"脏输入,那会把配置自证成连接成功。
|
||||
* @param {string} ip
|
||||
* @returns {boolean} 是否真的写进去了
|
||||
*/
|
||||
export function saveDeviceIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
|
||||
var v = String(ip == null ? '' : ip).trim();
|
||||
if (!isValidIpv4(v)) return false;
|
||||
try { localStorage.setItem(LS_ESP_IP, v); } catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @description 设备 IP:localStorage 优先,否则默认值。 */
|
||||
@@ -32,3 +71,48 @@ export function getDeviceIp() {
|
||||
export function getBaseUrl() {
|
||||
return 'http://' + getDeviceIp();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 读最近一次选中的设备指纹;结构不对一律当没有。
|
||||
* @returns {{ip:string, version:string, git_hash:string, observedAt:number, source:string}|null}
|
||||
*/
|
||||
export function getDeviceMeta() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LS_ESP_DEVICE);
|
||||
if (!raw) return null;
|
||||
var o = JSON.parse(raw);
|
||||
if (!o || typeof o !== 'object' || !isValidIpv4(o.ip)) return null;
|
||||
return o;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @description 写设备指纹(与 IP 同时机,由 deviceLink.selectDevice 统一调用)。 */
|
||||
export function saveDeviceMeta(meta) {
|
||||
try {
|
||||
localStorage.setItem(LS_ESP_DEVICE, JSON.stringify(meta || {}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 读发现结果缓存;返回数组(可能为空),坏数据直接丢弃不猜。
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function getLanDevices() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LS_LAN_DEVICES);
|
||||
if (!raw) return [];
|
||||
var arr = JSON.parse(raw);
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(function (d) { return d && isValidIpv4(d.ip); });
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @description 存发现结果缓存(上限 16 条,超了截断——它只是候选种子,不是台账)。 */
|
||||
export function saveLanDevices(list) {
|
||||
var arr = Array.isArray(list) ? list.slice(0, 16) : [];
|
||||
try {
|
||||
localStorage.setItem(LS_LAN_DEVICES, JSON.stringify(arr));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
+132
-14
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* connection.js - 连接管理 + 系统状态显示
|
||||
*
|
||||
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
|
||||
* 操作 DOM 元素 #pillWifi/#pillEcat/#pillBle / #bConn 等(三链路胶囊在顶栏)。
|
||||
*
|
||||
* 共享状态(通过 window.* 引用):
|
||||
* window.latestSystemStatus — 最近一次 /system/status 响应
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import {
|
||||
formatBytes, formatTime, formatUptime, escapeHtml,
|
||||
setTextIfChanged, setClassIfChanged, setQuietMode,
|
||||
setTextIfChanged, setClassIfChanged, setQuietMode, setCellSource,
|
||||
} from './utils.js';
|
||||
import { addLog } from './log.js';
|
||||
import { showToast } from './toast.js';
|
||||
@@ -72,37 +72,155 @@ function transferOngoing() {
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶栏三链路胶囊:WiFi / ECAT / BLE 独立亮灭。
|
||||
* **唯一写入点**:WiFi 文案只在这里生成(mode + 连接态),别处禁止再拼字符串。
|
||||
* ECAT 文案由 app.js 写入(0x1008 slave.name)。
|
||||
* @param {boolean|null} wifiOn WiFi 在线;null=按 DeviceLink 快照
|
||||
* @param {boolean|null} [bleOn] BLE 在线;null=读 window.__bleConnState
|
||||
*/
|
||||
/** @description EtherCAT AL 状态位 → 人话(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP)。 */
|
||||
function ecatAlText(state) {
|
||||
if (state === null || state === undefined || !Number.isFinite(Number(state))) return '';
|
||||
switch (Number(state) & 0x0F) {
|
||||
case 1: return 'INIT';
|
||||
case 2: return 'PREOP';
|
||||
case 3: return 'BOOT';
|
||||
case 4: return 'SAFEOP';
|
||||
case 8: return 'OP';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLinkPills(wifiOn, bleOn) {
|
||||
try {
|
||||
var snap = deviceLink.getSnapshot();
|
||||
var fresh = snap && snap.ts && (Date.now() - snap.ts) < 5000;
|
||||
var w = wifiOn;
|
||||
if (fresh && snap.links && snap.links.esp32) w = !!snap.links.esp32.online;
|
||||
if (typeof wifiOn === 'boolean' && !fresh) w = wifiOn;
|
||||
/* WiFi 胶囊:文案 = mode + 连接态 + IP + 信号(STA=Station 客户端模式) */
|
||||
var pw = getEl('pillWifi'), pwT = getEl('pillWifiTxt');
|
||||
if (pw) {
|
||||
var mode = 'STA';
|
||||
var ip = '';
|
||||
var rssi = '';
|
||||
try {
|
||||
if (fresh && snap && snap.esp32Status) {
|
||||
var stWifi = snap.esp32Status.wifi || {};
|
||||
if (stWifi.mode) mode = stWifi.mode;
|
||||
if (stWifi.ip) ip = stWifi.ip;
|
||||
if (stWifi.rssi != null && stWifi.rssi !== '') rssi = String(stWifi.rssi) + ' dBm';
|
||||
}
|
||||
if (!ip) ip = deviceLink.getIp() || '';
|
||||
} catch (_) {}
|
||||
var wifiLabel = mode + (w ? ' 已连接' : ' 未连接');
|
||||
if (w && ip) wifiLabel += ' · ' + ip;
|
||||
if (w && rssi) wifiLabel += ' · ' + rssi;
|
||||
/* 运行时覆盖(重启中/上传中/设备忙):优先显示,避免被 1Hz 轮询冲掉 */
|
||||
if (window.__wifiPillOverride) wifiLabel = window.__wifiPillOverride;
|
||||
setClassIfChanged(pw, 'pill link-pill ' + (window.__wifiPillOverride ? 'processing' : (w ? 'online' : 'offline')));
|
||||
if (pwT) setTextIfChanged(pwT, wifiLabel);
|
||||
}
|
||||
/* ECAT 胶囊:灯色与文案**都由这里出**。此前 app.js 的 0x1008 probe 也写同一颗
|
||||
pill 的 className,两边节奏/判据都不同,实测出现过"绿点 + 文字离线"的矛盾。
|
||||
分级:后端/网卡没开=离线;开了且 PDO 循环在跑=在线;开了但循环没跑=琥珀并标 AL 档。 */
|
||||
var pe = getEl('pillEcat'), peT = getEl('pillEcatTxt');
|
||||
if (pe) {
|
||||
var E = (fresh && snap && snap.links && snap.links.ecat) ? snap.links.ecat : null;
|
||||
var eName = (E && E.deviceName) || window.__ecatProbeName || '';
|
||||
var eCls, eTxt;
|
||||
if (!E) { eCls = 'idle'; eTxt = '未探测'; }
|
||||
else if (!E.busOpen) { eCls = 'offline'; eTxt = '离线(后端/网卡未开)'; }
|
||||
else if (E.loop) { eCls = 'online'; eTxt = eName || (E.op ? 'OP 循环中' : '循环中'); }
|
||||
else {
|
||||
var al = ecatAlText(E.slaveState);
|
||||
eCls = 'processing';
|
||||
eTxt = (eName ? eName + ' · ' : '') + (al ? al + ' 无循环' : '未进 OP');
|
||||
}
|
||||
setClassIfChanged(pe, 'pill link-pill ' + eCls);
|
||||
if (peT) setTextIfChanged(peT, eTxt);
|
||||
}
|
||||
/* BLE 胶囊 */
|
||||
var pb = getEl('pillBle'), pbT = getEl('pillBleTxt');
|
||||
if (pb) {
|
||||
var b = bleOn;
|
||||
if (b === null || b === undefined) {
|
||||
try {
|
||||
var st = window.__bleConnState;
|
||||
if (st === true || st === false) b = st;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (b === null || b === undefined) {
|
||||
setClassIfChanged(pb, 'pill link-pill idle');
|
||||
if (pbT) setTextIfChanged(pbT, '未连接');
|
||||
} else if (b) {
|
||||
setClassIfChanged(pb, 'pill link-pill online');
|
||||
var nm = window.__bleDeviceName || '已连接';
|
||||
var did = window.__bleDeviceId;
|
||||
var bl = did ? (nm + ' · ' + did) : nm;
|
||||
if (pbT) setTextIfChanged(pbT, bl);
|
||||
} else {
|
||||
setClassIfChanged(pb, 'pill link-pill offline');
|
||||
if (pbT) setTextIfChanged(pbT, '未连接');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** WiFi 胶囊临时覆盖文案(重启中/上传中/设备忙);传 null 清除。 */
|
||||
export function setWifiPillOverride(text) {
|
||||
window.__wifiPillOverride = text || null;
|
||||
updateLinkPills(null, null);
|
||||
}
|
||||
|
||||
export function updateConnectionUi(connected, text) {
|
||||
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
|
||||
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
|
||||
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。
|
||||
双通路:WiFi 或 ECAT 任一在线即「在线」;角标标出实际在线链路。 */
|
||||
var src = 'wifi';
|
||||
try {
|
||||
var snap = deviceLink.getSnapshot();
|
||||
if (snap && snap.ts && (Date.now() - snap.ts) < 5000) {
|
||||
var w = !!(snap.links && snap.links.esp32 && snap.links.esp32.online);
|
||||
var e = !!(snap.links && snap.links.ecat && snap.links.ecat.online);
|
||||
/* Hub 可把在线压成离线;**不得**把本页刚判的离线改回在线。
|
||||
* Owner 要连失败 3 次才翻离线,期间若无脑覆盖,
|
||||
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。 */
|
||||
if (!snap.links.esp32.online) connected = false;
|
||||
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。
|
||||
* 双通路修正:WiFi 掉线但 ECAT 仍在线时,整体仍算在线(ECAT 撑着)。 */
|
||||
if (!w && !e) connected = false;
|
||||
else if (!connected && (w || e)) {
|
||||
/* 仅当两路里至少一路 Hub 判在线,且本页因 WiFi 超时误判离线时,
|
||||
* 才允许 Hub 把「在线」加回来(ECAT 撑场)。 */
|
||||
if (e && !w) connected = true;
|
||||
}
|
||||
if (w && e) src = 'dual';
|
||||
else if (e && !w) src = 'ecat';
|
||||
else src = 'wifi';
|
||||
if (!text) text = connected ? '已连接' : '离线';
|
||||
if (connected && e && !w) text = text && text.indexOf('ECAT') < 0
|
||||
? (text + ' · ECAT') : text;
|
||||
} else {
|
||||
/* 无快照:参数 connected 来自 WiFi 轮询 */
|
||||
src = connected ? 'wifi' : 'off';
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
src = connected ? 'wifi' : 'off';
|
||||
}
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
/* 判离线立刻清实时指标:只改灯不等于清数据,否则 APP/缓存下会
|
||||
* 出现「离线 + 温度/电压仍显示」的假活状态。 */
|
||||
if (!connected && typeof window.clearLiveMetricsOnDisconnect === 'function') {
|
||||
try { window.clearLiveMetricsOnDisconnect(); } catch (_) {}
|
||||
}
|
||||
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
|
||||
var pc = getEl('pillConn');
|
||||
var pct = getEl('pillConnTxt');
|
||||
if (pc) {
|
||||
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
|
||||
if (pct) setTextIfChanged(pct, label);
|
||||
}
|
||||
// 系统状态卡徽章
|
||||
// 顶栏三链路:只刷灯/来源角标;WiFi 文案由 updateLinkPills 统一生成
|
||||
updateLinkPills(connected, null);
|
||||
// 系统状态卡徽章(整体:任一数据通路在线即在线)
|
||||
var bc = getEl('bConn');
|
||||
if (bc) {
|
||||
setTextIfChanged(bc, connected ? '在线' : '离线');
|
||||
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
|
||||
setCellSource('bConn', connected ? src : 'off');
|
||||
}
|
||||
setQuietMode(!connected && !transferOngoing());
|
||||
}
|
||||
|
||||
+162
-9
@@ -16,8 +16,10 @@
|
||||
"use strict";
|
||||
|
||||
import {
|
||||
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp
|
||||
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp,
|
||||
isValidIpv4, getDeviceMeta, saveDeviceMeta
|
||||
} from "./base-url.js";
|
||||
import { flashBusyNow } from "./utils.js";
|
||||
import {
|
||||
runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault,
|
||||
ECAT_DEFAULT_ADAPTER,
|
||||
@@ -28,6 +30,7 @@ var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
|
||||
var ESP32_POLL_MS = 1000;
|
||||
var ECAT_POLL_MS = 2000;
|
||||
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
|
||||
var AUTO_SCAN_COOLDOWN_MS = 60000; /* 判离线后最多每 60s 提一次候选设备 */
|
||||
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
|
||||
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
||||
var LS_ECAT_SPIN = "dcu_ecat_spin";
|
||||
@@ -42,7 +45,8 @@ function readIp() {
|
||||
}
|
||||
|
||||
function writeIp(ip) {
|
||||
saveDeviceIp(ip);
|
||||
/* 校验在 base-url.saveDeviceIp 单点做:非法值不改写现值,返回 false。 */
|
||||
return saveDeviceIp(ip);
|
||||
}
|
||||
|
||||
function readAdapter() {
|
||||
@@ -95,10 +99,14 @@ function emptySnapshot() {
|
||||
links: {
|
||||
esp32: { online: false, ageMs: -1, failStreak: 0 },
|
||||
ecat: { online: false, busOpen: false, op: false, loop: false,
|
||||
loadValid: false, loadPerm: 0, ageMs: -1 },
|
||||
loadValid: false, loadPerm: 0, ageMs: -1, deviceName: "",
|
||||
slaveState: null, alStatus: null },
|
||||
ble: { online: false, deviceName: "", owner: null }
|
||||
},
|
||||
esp32Status: null
|
||||
esp32Status: null,
|
||||
/* 端点一致性:'match' | 'mismatch' | 'unverified'(见 checkEndpoint) */
|
||||
endpointCheck: 'unverified',
|
||||
endpointCheckNote: ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +126,8 @@ function createOwner() {
|
||||
var ecatBusReady = false;
|
||||
var ecatBusBusy = false;
|
||||
var ecatBusNextTryAt = 0;
|
||||
var uptimeBase = null; /* {ip, up} 只用于 uptime 倒退检测(换板/复位) */
|
||||
var autoScanAt = 0; /* 上次自动提候选的时刻,见 maybeAutoScan */
|
||||
|
||||
function publish() {
|
||||
snap.ts = nowMs();
|
||||
@@ -134,16 +144,79 @@ function createOwner() {
|
||||
snap.links.esp32.online = true;
|
||||
snap.links.esp32.ageMs = 0;
|
||||
snap.esp32Status = status || null;
|
||||
snap.endpointCheck = checkEndpoint(status);
|
||||
} else {
|
||||
esp32Fails++;
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
|
||||
if (esp32Fails >= ESP32_FAIL_ONLINE) snap.links.esp32.online = false;
|
||||
if (esp32Fails >= ESP32_FAIL_ONLINE) {
|
||||
snap.links.esp32.online = false;
|
||||
snap.endpointCheck = checkEndpoint(null);
|
||||
maybeAutoScan();
|
||||
}
|
||||
}
|
||||
snap.links.esp32.failStreak = esp32Fails;
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* 「现在连的还是我选的那台吗」——判据必须有灵敏度,不能是自证:
|
||||
* 版本变 / 设备自报 IP 与所选不符 / uptime 倒退,三条都能被
|
||||
* "同地址换了一块板"或"板子刚复位"真实触发。
|
||||
* 拉不到 status 时**不改判**(保持上一次结论),否则断网会把
|
||||
* mismatch 说成事实。
|
||||
*/
|
||||
function checkEndpoint(status) {
|
||||
var meta = getDeviceMeta();
|
||||
var ip = readIp();
|
||||
if (!meta || String(meta.ip) !== String(ip)) return 'unverified';
|
||||
if (!status) {
|
||||
/* 拉不到 status 就**没有**证据可判:不能沿用上一条结论,更不能把
|
||||
* "上一个端点的 status" 拿来判当前端点(实测会吐出一条自造的
|
||||
* "设备自报 x.x.x.x 与所选 y.y.y.y 不符"——那是陈旧数据的回声)。 */
|
||||
snap.endpointCheckNote = '';
|
||||
return 'unverified';
|
||||
}
|
||||
var w = status.wifi || {};
|
||||
if (status.version && meta.version && String(status.version) !== String(meta.version)) {
|
||||
snap.endpointCheckNote = '版本与所选不符:' + meta.version + ' → ' + status.version;
|
||||
return 'mismatch';
|
||||
}
|
||||
if (w.connected === true && w.ip && String(w.ip) !== String(ip)) {
|
||||
snap.endpointCheckNote = '设备自报 ' + w.ip + ',与所选 ' + ip + ' 不符';
|
||||
return 'mismatch';
|
||||
}
|
||||
var up = Number(status.uptime_sec);
|
||||
if (Number.isFinite(up)) {
|
||||
if (uptimeBase && uptimeBase.ip === ip && Number.isFinite(uptimeBase.up)
|
||||
&& up + 2 < uptimeBase.up) {
|
||||
snap.endpointCheckNote = 'uptime ' + Math.round(uptimeBase.up) + 's → '
|
||||
+ Math.round(up) + 's:这地址上换板了或刚复位';
|
||||
uptimeBase = { ip: ip, up: up };
|
||||
return 'mismatch';
|
||||
}
|
||||
uptimeBase = { ip: ip, up: up };
|
||||
}
|
||||
snap.endpointCheckNote = '';
|
||||
return status.version ? 'match' : 'unverified';
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线了才提一次候选,**绝不自动改端点**。三个门都是必需的:
|
||||
* 扫描本身会占板子 httpd 的 socket(max_open_sockets=6 + lru_purge,
|
||||
* wifi_ota.cpp:419-436),而 ECAT 环跑 / 刷写期间 httpd 常常本就不响应
|
||||
* (spi_master.cpp:768-771)——那时 failStreak 是假阳性,不加门就成了
|
||||
* "扫→踢连接→更离线→再扫"的正反馈。
|
||||
*/
|
||||
function maybeAutoScan() {
|
||||
var now = nowMs();
|
||||
if (autoScanAt && now - autoScanAt < AUTO_SCAN_COOLDOWN_MS) return;
|
||||
if (flashBusyNow()) return;
|
||||
if (snap.links.ecat && snap.links.ecat.loop) return;
|
||||
autoScanAt = now;
|
||||
if (ch) { try { ch.postMessage({ type: 'auto-scan', ip: readIp() }); } catch (_) {} }
|
||||
}
|
||||
|
||||
function pollEsp32() {
|
||||
if (stopped) return;
|
||||
var ac = null;
|
||||
@@ -172,9 +245,17 @@ function createOwner() {
|
||||
/**
|
||||
* 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start,
|
||||
* DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。
|
||||
* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
|
||||
*/
|
||||
function bootstrapEcatBus() {
|
||||
if (stopped || ecatBusBusy) return;
|
||||
/* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
|
||||
* motorOtaEcatBusy = 电机 OTA 正经邮箱灌 GD32(native worker 独占总线十几秒),
|
||||
* 这条请求只会在锁上排队,排到时把刚恢复的循环再停一遍。 */
|
||||
if (window.bootEcatBusy || window.isUploading || window.motorOtaEcatBusy) return;
|
||||
/* 对面是 BOOT(邮箱 PREOP、不跑 OP/PDO)时不要 motor/start:
|
||||
* 跳转/刷写结束后 bootEcatIsBoot 会钉住,直到 jump_app 确认 APP。 */
|
||||
if (window.bootEcatIsBoot === true) return;
|
||||
var now = nowMs();
|
||||
if (ecatBusReady) return;
|
||||
if (now < ecatBusNextTryAt) return;
|
||||
@@ -215,6 +296,14 @@ function createOwner() {
|
||||
var e = snap.links.ecat;
|
||||
e.online = !!(st && st.ok && st.slave);
|
||||
e.busOpen = !!(st && st.ok);
|
||||
e.deviceName = (st && st.slave && st.slave.name) ? String(st.slave.name) : "";
|
||||
/* 从站 AL 状态原样带上,让灯色与文案同源:1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP。
|
||||
* 只有 ok+slave 就判"在线"太松——实测从站在 INIT、0x1008 空、循环没跑时
|
||||
* 也会被点成绿灯,而文字还写着"离线"。 */
|
||||
e.slaveState = (st && st.slave && st.slave.state != null) ? Number(st.slave.state) : null;
|
||||
e.alStatus = (st && st.slave && st.slave.al_status != null) ? Number(st.slave.al_status) : null;
|
||||
/* 同步到 window:Client 侧 updateLinkPills 在旧快照缺字段时兜底读取 */
|
||||
window.__ecatDeviceName = e.deviceName;
|
||||
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
|
||||
e.op = !!(st && st.slave && st.slave.state != null && (st.slave.state & 0x0F) === 8);
|
||||
e.loadValid = !!(ld && ld.ok && ld.valid === true
|
||||
@@ -222,8 +311,12 @@ function createOwner() {
|
||||
e.loop = e.loadValid || e.op;
|
||||
e.loadPerm = (ld && Number.isFinite(ld.load_perm)) ? ld.load_perm : 0;
|
||||
e.ageMs = (ld && Number.isFinite(ld.age_ms)) ? ld.age_ms : -1;
|
||||
/* 与刚上电同判据:bus_open + loop 才算就绪。
|
||||
* jump_app / commit 后 loop 被 loop_stop 掉、bus 仍开 —— 旧逻辑只在
|
||||
* !busOpen 时清 ready,于是 Owner 永远不再 motor/start,连接要等外部
|
||||
* 自愈才回来。loop 一掉就允许 bootstrap 重拉(刷写中由上面 busy 门拦住)。 */
|
||||
if (e.busOpen && e.loop) ecatBusReady = true;
|
||||
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
|
||||
else ecatBusReady = false;
|
||||
publish();
|
||||
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */
|
||||
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
|
||||
@@ -246,6 +339,14 @@ function createOwner() {
|
||||
if (m.type === "hello") { publish(); return; }
|
||||
if (m.type === "set-ip") {
|
||||
writeIp(m.ip);
|
||||
/* 换了端点=换了问题:旧的核对结论必须作废,否则新端点还没答过话,
|
||||
面板就拿着上一个设备的 status 报"不符"。 */
|
||||
snap.endpointCheck = 'unverified';
|
||||
snap.endpointCheckNote = '';
|
||||
uptimeBase = null; /* uptime 倒退检测只在"同一个端点连续观察"内成立 */
|
||||
/* 无论采纳还是拒绝都把"实际生效的 IP"广播回去:否则各页输入框会停在
|
||||
用户刚打的脏值上,显示连着、实际打的还是旧端点——那正是自证判据。 */
|
||||
publish();
|
||||
try {
|
||||
fetch(baseUrlFromIp() + "/system/status", {
|
||||
cache: "no-store",
|
||||
@@ -287,8 +388,15 @@ function createClient() {
|
||||
var ch = null;
|
||||
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
|
||||
var listeners = [];
|
||||
var autoScanFns = [];
|
||||
var last = null;
|
||||
|
||||
function emitAutoScan(m) {
|
||||
for (var i = 0; i < autoScanFns.length; i++) {
|
||||
try { autoScanFns[i](m || {}); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (var i = 0; i < listeners.length; i++) {
|
||||
try { listeners[i](last); } catch (_) {}
|
||||
@@ -298,7 +406,9 @@ function createClient() {
|
||||
if (ch) {
|
||||
ch.addEventListener("message", function (ev) {
|
||||
var m = ev && ev.data;
|
||||
if (!m || m.type !== "snapshot" || !m.snap) return;
|
||||
if (!m || !m.type) return;
|
||||
if (m.type === "auto-scan") { emitAutoScan(m); return; }
|
||||
if (m.type !== "snapshot" || !m.snap) return;
|
||||
last = m.snap;
|
||||
emit();
|
||||
});
|
||||
@@ -334,10 +444,49 @@ function createClient() {
|
||||
return !!(L && L.online);
|
||||
},
|
||||
|
||||
/* ── WiFi 端点 ── */
|
||||
/* ── WiFi 端点(唯一写手;各页输入框/发现面板都走这里) ── */
|
||||
/**
|
||||
* @description 换设备。**唯一**能改当前端点的入口,门也只在这里判一次:
|
||||
* - IPv4 合法性(base-url.saveDeviceIp 单点校验,非法不改写现值)
|
||||
* - 刷写/OTA 进行中不换(motor/start 会 loop_stop、灌写会话会被掐死)
|
||||
* @returns {{ok:boolean, ip?:string, reason?:string}}
|
||||
*/
|
||||
setIp: function (ip) {
|
||||
writeIp(ip);
|
||||
var v = String(ip == null ? '' : ip).trim();
|
||||
if (!isValidIpv4(v)) return { ok: false, reason: '不是可用 IPv4:' + v };
|
||||
if (flashBusyNow()) return { ok: false, reason: '刷写/OTA 进行中,暂不换端点' };
|
||||
if (!writeIp(v)) return { ok: false, reason: '写入 localStorage 失败' };
|
||||
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
|
||||
return { ok: true, ip: readIp() };
|
||||
},
|
||||
/**
|
||||
* @description 选中一台发现到的设备:比 setIp 多一步指纹登记(版本/来源/时刻),
|
||||
* Owner 之后每次拉 status 都据此核"连的还是不是这台"(snap.endpointCheck)。
|
||||
* @param {{ip:string, version?:string, git_hash?:string, source?:string}} rec
|
||||
*/
|
||||
selectDevice: function (rec) {
|
||||
var ip = rec && rec.ip != null ? String(rec.ip).trim() : '';
|
||||
var r = this.setIp(ip);
|
||||
if (!r.ok) return r;
|
||||
saveDeviceMeta({
|
||||
ip: r.ip,
|
||||
version: rec.version ? String(rec.version) : '',
|
||||
git_hash: rec.git_hash ? String(rec.git_hash) : '',
|
||||
observedAt: nowMs(),
|
||||
source: rec.source || 'lan'
|
||||
});
|
||||
return r;
|
||||
},
|
||||
/** @description 当前登记的端点指纹(无则 null)。 */
|
||||
getDeviceMeta: function () { return getDeviceMeta(); },
|
||||
/** @description 订阅"Owner 判离线、提候选"的信号(不自动改端点)。 */
|
||||
onAutoScan: function (fn) {
|
||||
if (typeof fn !== 'function') return function () {};
|
||||
autoScanFns.push(fn);
|
||||
return function () {
|
||||
var i = autoScanFns.indexOf(fn);
|
||||
if (i >= 0) autoScanFns.splice(i, 1);
|
||||
};
|
||||
},
|
||||
getIp: function () { return readIp(); },
|
||||
wifiBaseUrl: function () { return baseUrlFromIp(); },
|
||||
@@ -371,6 +520,10 @@ function createClient() {
|
||||
var deviceLink;
|
||||
try {
|
||||
if (window.__dcuDeviceLinkOwner) {
|
||||
/* 把标记消费掉:同一文档里本模块若因入口 ?v= 与 JS 间 import 的 URL 不同被
|
||||
* 实例化两次(tabs.html 就是这种:入口标签带 stamp,lan-picker.js 不带),
|
||||
* 只允许有一个 Owner —— 两个 Owner 就是两份 1Hz status 轮询 + 两份快照互相打架。 */
|
||||
window.__dcuDeviceLinkOwner = false;
|
||||
window.__dcuDeviceLinkInstance = createOwner();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
+57
-28
@@ -6,7 +6,9 @@
|
||||
* health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环
|
||||
*
|
||||
* 红线:
|
||||
* - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000)))
|
||||
* - PDO 周期与 DC SYNC0 **始终同值**:dc_cycle_us = round(period_ms*1000),
|
||||
* 周期可在 UI 档位 0.125–4ms 内随时热改,不再钉死 0.25。
|
||||
* 默认仍是 0.25ms(历史稳妥档);≥1ms 时 GD32 发送出口可能变慢,属预期代价。
|
||||
* - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start
|
||||
* (start 会先 loop_stop,热路径假超时 / SSC 楔死风险)
|
||||
* - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 motor/start
|
||||
@@ -22,12 +24,30 @@
|
||||
/** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */
|
||||
export const ECAT_DEFAULT_ADAPTER = "I210";
|
||||
|
||||
/** 默认 PDO 周期(ms):1ms+DC 会让发送出口回到 ~1s,必须 250µs 档。 */
|
||||
/** 默认 PDO 周期(ms)。 */
|
||||
export const ECAT_DEFAULT_PERIOD_MS = 0.25;
|
||||
|
||||
/** DC SYNC0 目标周期(µs)。 */
|
||||
/** 允许的 PDO 周期上下限(ms)——与 spi_test 档位一致,可随时热改。 */
|
||||
export const ECAT_PERIOD_MIN_MS = 0.125;
|
||||
export const ECAT_PERIOD_MAX_MS = 4;
|
||||
|
||||
/** 默认 DC SYNC0 周期(µs):与默认 period 0.25ms 对齐;热改后 DC 跟随 period。 */
|
||||
export const ECAT_DC_CYCLE_US = 250;
|
||||
|
||||
/** 把任意 period 钳进合法区间;非法/缺省回落默认 0.25ms。 */
|
||||
export function clampEcatPeriodMs(v) {
|
||||
var per = Number(v);
|
||||
if (!(per > 0)) return ECAT_DEFAULT_PERIOD_MS;
|
||||
if (per < ECAT_PERIOD_MIN_MS) return ECAT_PERIOD_MIN_MS;
|
||||
if (per > ECAT_PERIOD_MAX_MS) return ECAT_PERIOD_MAX_MS;
|
||||
return per;
|
||||
}
|
||||
|
||||
/** period_ms → DC SYNC0 µs(与 period 锁死同值,不再是恒 250)。 */
|
||||
export function periodToDcUs(perMs) {
|
||||
return Math.round(clampEcatPeriodMs(perMs) * 1000);
|
||||
}
|
||||
|
||||
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
|
||||
const LS_ECAT_SPIN = "dcu_ecat_spin";
|
||||
const LS_ECAT_ADAPTER = "dcu_ecat_adapter";
|
||||
@@ -42,20 +62,15 @@ function readAdapterRaw() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 motor/start body:period 钳到 ≤0.5ms 且默认 0.25,DC 钉 250µs。
|
||||
* 构造 motor/start body:period 钳进 0.125–4ms,DC 与 period 同值。
|
||||
* @param {{period_ms?:number, spin?:boolean|null, fromLocalStorage?:boolean}} [opts]
|
||||
*/
|
||||
export function resolveEcatStartBody(opts) {
|
||||
opts = opts || {};
|
||||
var body = {};
|
||||
var per = Number(opts.period_ms);
|
||||
if (!opts.fromLocalStorage && opts.period_ms == null) {
|
||||
per = ECAT_DEFAULT_PERIOD_MS;
|
||||
} else if (!(per > 0) || per > 0.5) {
|
||||
per = ECAT_DEFAULT_PERIOD_MS;
|
||||
}
|
||||
var per = clampEcatPeriodMs(opts.period_ms);
|
||||
body.period_ms = per;
|
||||
body.dc_cycle_us = Math.max(ECAT_DC_CYCLE_US, Math.round(per * 1000));
|
||||
body.dc_cycle_us = periodToDcUs(per);
|
||||
body.dc_shift_us = 0;
|
||||
var spin = opts.spin;
|
||||
if (spin == null && opts.fromLocalStorage) {
|
||||
@@ -79,8 +94,8 @@ export function readStoredEcatPeriod() {
|
||||
localStorage.setItem(LS_PERIOD_MIG, "1");
|
||||
}
|
||||
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
|
||||
/* 用户显式设过才用;否则 0.25ms */
|
||||
if (v > 0 && v !== 1) return v;
|
||||
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
|
||||
if (v > 0 && v >= ECAT_PERIOD_MIN_MS && v <= ECAT_PERIOD_MAX_MS) return v;
|
||||
return ECAT_DEFAULT_PERIOD_MS;
|
||||
} catch (_) {
|
||||
return ECAT_DEFAULT_PERIOD_MS;
|
||||
@@ -113,16 +128,26 @@ export function proveEcatLoop(h, opts) {
|
||||
|
||||
/**
|
||||
* 「DC/周期是否被*证明*配错」。缺字段 = 证明不了 = 不动总线(repairEcatDc 旧规则收编于此)。
|
||||
* 判据:motor 路径必须 DC on;period / dc_cycle_us 各自在合法区间;
|
||||
* 两者都有值时必须同值(period_ms*1000 ≈ dc_cycle_us)。
|
||||
* 周期本身可以是 0.125–4ms 内任意档 —— 与 period 锁死的 DC 不再判错。
|
||||
* @returns {"ok"|"misconfigured"|"unknown"}
|
||||
*/
|
||||
export function proveEcatDc(h) {
|
||||
if (!h || h.ok !== true) return "unknown";
|
||||
if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";
|
||||
if (h.dc === 1 && Number(h.dc_cycle_us) === ECAT_DC_CYCLE_US &&
|
||||
(h.period_ms == null || Math.abs(Number(h.period_ms) - ECAT_DEFAULT_PERIOD_MS) < 0.001)) {
|
||||
return "ok";
|
||||
if (h.dc !== 1) return "misconfigured";
|
||||
var per = h.period_ms == null ? null : Number(h.period_ms);
|
||||
var dc = h.dc_cycle_us == null ? null : Number(h.dc_cycle_us);
|
||||
var perOk = per == null || (per >= ECAT_PERIOD_MIN_MS && per <= ECAT_PERIOD_MAX_MS);
|
||||
var dcMin = Math.round(ECAT_PERIOD_MIN_MS * 1000);
|
||||
var dcMax = Math.round(ECAT_PERIOD_MAX_MS * 1000);
|
||||
var dcOk = dc == null || (dc >= dcMin && dc <= dcMax);
|
||||
if (!perOk || !dcOk) return "misconfigured";
|
||||
if (per != null && dc != null) {
|
||||
if (Math.abs(Math.round(per * 1000) - dc) > 1) return "misconfigured";
|
||||
}
|
||||
return "misconfigured";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,10 +308,12 @@ export async function runEcatBootstrap(opts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DC/周期自愈:被**证明**配错(free-run 或 ≥1ms)时拉回 250µs。
|
||||
* 判据在 proveEcatDc 里(唯一实现):证明不了 = 不动总线。这条规则的存在理由就是
|
||||
* healthCheck 那份内联副本 —— 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包
|
||||
* 就打出一记 loop_stop 的 motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
|
||||
* DC/周期自愈:被**证明**配错(free-run / period 越界 / period 与 DC 不同值)时
|
||||
* 按当前合法 period 把 DC 对齐回去。判据在 proveEcatDc 里(唯一实现):
|
||||
* 证明不了 = 不动总线。这条规则的存在理由就是 healthCheck 那份内联副本 ——
|
||||
* 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包就打出一记 loop_stop 的
|
||||
* motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
|
||||
* 修复目标是「period 与 DC 同值」,不是「一律拉回 0.25」。
|
||||
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
|
||||
* @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返,
|
||||
* 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。
|
||||
@@ -304,13 +331,15 @@ export async function repairEcatDc(ecatPost, knownHealth) {
|
||||
if (proveEcatDc(h) !== "misconfigured") return null;
|
||||
/* native 正在全量重连(close→open→OP,秒级)时再插一记 motor/start 是自相残杀:
|
||||
* 两条恢复路各自 loop_stop,最后谁都不知道总线是谁停的。 */
|
||||
if (h.recovering) return null;
|
||||
return ecatPost("motor/start", {
|
||||
period_ms: ECAT_DEFAULT_PERIOD_MS,
|
||||
dc_cycle_us: ECAT_DC_CYCLE_US,
|
||||
dc_shift_us: 0,
|
||||
reason: "auto_dc_repair",
|
||||
}, 30000);
|
||||
if (h && h.recovering) return null;
|
||||
var per = readStoredEcatPeriod();
|
||||
if (h && h.period_ms != null) {
|
||||
var hp = Number(h.period_ms);
|
||||
if (hp >= ECAT_PERIOD_MIN_MS && hp <= ECAT_PERIOD_MAX_MS) per = hp;
|
||||
}
|
||||
var body = resolveEcatStartBody({ period_ms: per });
|
||||
body.reason = "auto_dc_repair";
|
||||
return ecatPost("motor/start", body, 30000);
|
||||
}
|
||||
|
||||
/** @description 读当前网卡子串(localStorage → 默认 I210)。 */
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* 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 = '<li class="jump-history-item ok' + (isSel ? ' lan-cur' : '')
|
||||
+ (stale ? ' lan-stale' : '') + '" data-ip="' + escapeHtml(b.ip) + '" title="'
|
||||
+ (stale ? '尝试连接(结果已过期)' : '设为当前设备') + '">';
|
||||
h += '<span class="jh-tag">' + escapeHtml(b.kind === 'ap' ? 'AP' : 'STA') + '</span>';
|
||||
h += '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(b.ip) + '</span> '
|
||||
+ '<span class="lan-meta">' + escapeHtml(boardLabel(b)) + '</span></span>';
|
||||
h += '<span class="lan-acts">'
|
||||
+ '<button class="btn btn-ghost btn-sm" data-act="detail" data-ip="' + escapeHtml(b.ip) + '">详情</button>'
|
||||
+ '<button class="btn btn-ghost btn-sm" data-act="direct" data-ip="' + escapeHtml(b.ip) + '">本页直测</button>'
|
||||
+ '</span>';
|
||||
h += '<span class="jh-time">' + escapeHtml(ageText(b.observedAt)) + '</span>';
|
||||
if (d) {
|
||||
h += '<span class="lan-sub">' + (d.ok
|
||||
? '本页可达 ✓ HTTP ' + d.http + ' · ' + d.ms + 'ms'
|
||||
: '本页取不到:' + escapeHtml(d.name + ' ' + d.msg)
|
||||
+ '(服务侧扫到了,多半是浏览器对本机访问私有网段的限制)')
|
||||
+ '</span>';
|
||||
}
|
||||
h += '</li>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function renderPill() {
|
||||
if (!pillEl) return;
|
||||
var snap = deviceLink.getSnapshot();
|
||||
var ip = (snap && snap.ip) || deviceLink.getIp();
|
||||
var st = snap && snap.esp32Status;
|
||||
var on = !!(snap && snap.links && snap.links.esp32 && snap.links.esp32.online);
|
||||
var txt = ip;
|
||||
if (st && st.version) txt += ' · v' + st.version;
|
||||
if (st && st.wifi && Number.isFinite(st.wifi.rssi)) txt += ' · ' + st.wifi.rssi + 'dBm';
|
||||
/* 顶栏这颗就是各页在用的 .pill 组件(online/offline/processing 是既有状态类),
|
||||
不另造配色。mismatch 用 processing(琥珀)而不是 offline:设备是活的,
|
||||
只是"不像我选的那台"。 */
|
||||
var cls = 'pill ' + (snap && snap.endpointCheck === 'mismatch' ? 'processing'
|
||||
: (on ? 'online' : 'offline'));
|
||||
if (pillTxtEl && pillTxtEl.textContent !== txt) pillTxtEl.textContent = txt;
|
||||
if (pillEl.className !== cls) pillEl.className = cls;
|
||||
pillEl.title = (snap && snap.endpointCheckNote)
|
||||
? ('端点核对:' + snap.endpointCheck + ' — ' + snap.endpointCheckNote)
|
||||
: ('端点核对:' + ((snap && snap.endpointCheck) || 'unverified'));
|
||||
}
|
||||
|
||||
function renderNote() {
|
||||
var n = panelEl && panelEl.querySelector('.lan-note');
|
||||
if (!n) return;
|
||||
var t = state.note ? state.note.text : '';
|
||||
var cls = 'lan-note' + (state.note && state.note.kind !== 'info' ? ' lan-note-' + state.note.kind : '');
|
||||
if (n.textContent !== t) n.textContent = t;
|
||||
if (n.className !== cls) n.className = cls;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!panelEl) { renderPill(); return; }
|
||||
var mask = panelEl.querySelector('.lan-mask');
|
||||
if (mask) mask.classList.toggle('show', !!state.open);
|
||||
renderPill();
|
||||
|
||||
var head = panelEl.querySelector('.lan-ep');
|
||||
if (head) {
|
||||
var snap = deviceLink.getSnapshot();
|
||||
var ec = snap ? snap.endpointCheck : 'unverified';
|
||||
var t = '当前端点 ' + getDeviceIp() + ' · 核对 ' + ec
|
||||
+ ((snap && snap.endpointCheckNote) ? ':' + snap.endpointCheckNote : '');
|
||||
if (head.textContent !== t) head.textContent = t;
|
||||
head.className = 'lan-ep' + (ec === 'mismatch' ? ' lan-note-warn' : '');
|
||||
}
|
||||
|
||||
var cidrIn = panelEl.querySelector('.lan-cidr');
|
||||
if (cidrIn && cidrIn !== document.activeElement && cidrIn.value !== state.cidr) {
|
||||
cidrIn.value = state.cidr || '';
|
||||
}
|
||||
var pub = panelEl.querySelector('.lan-pub');
|
||||
if (pub) pub.checked = !!state.allowPublic;
|
||||
|
||||
var prog = panelEl.querySelector('.lan-progress');
|
||||
if (prog) {
|
||||
var ptxt = state.scanning
|
||||
? ('扫描中… ' + state.progress.done + '/' + state.progress.total
|
||||
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length)
|
||||
: (state.scan
|
||||
? ('上一轮:候选 ' + state.scan.candidates + ' · 端口开 ' + state.scan.tcpOpen
|
||||
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length
|
||||
+ ' · 反证排除 ' + state.rejected.length + ' · 用时 ' + state.scan.wallMs + 'ms')
|
||||
: '还没扫。快扫只打几个已知地址(≈1s);全段扫整条 /24(≈10s)。');
|
||||
if (prog.textContent !== ptxt) prog.textContent = ptxt;
|
||||
prog.classList.toggle('lan-busy', !!state.scanning);
|
||||
}
|
||||
|
||||
var list = panelEl.querySelector('.lan-list');
|
||||
if (list) {
|
||||
var rows = state.rows.slice(0, MAX_ROWS);
|
||||
var snapNow = deviceLink.getSnapshot();
|
||||
var epIp = deviceLink.getIp();
|
||||
var html = '';
|
||||
if (snapNow) {
|
||||
var on = snapNow.links && snapNow.links.esp32 && snapNow.links.esp32.online;
|
||||
var st2 = snapNow.esp32Status || {};
|
||||
html += '<li class="jump-history-item info lan-cur">'
|
||||
+ '<span class="jh-tag">当前</span>'
|
||||
+ '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(epIp) + '</span> '
|
||||
+ '<span class="lan-meta">' + (on ? '在线' : '离线(连续 3 次拉不到 status)')
|
||||
+ (st2.version ? ' · v' + escapeHtml(st2.version) : '')
|
||||
+ (st2.wifi && Number.isFinite(st2.wifi.rssi) ? ' · ' + st2.wifi.rssi + 'dBm' : '')
|
||||
+ (Number.isFinite(st2.uptime_sec) ? ' · up ' + Math.round(st2.uptime_sec) + 's' : '')
|
||||
+ '</span></span></li>';
|
||||
}
|
||||
html += rows.map(function (b) { return rowHtml(b); }).join('');
|
||||
if (state.rows.length > MAX_ROWS) {
|
||||
html += '<li class="lan-cut">列表截断到 ' + MAX_ROWS + ' 行,实际 ' + state.rows.length + ' 台</li>';
|
||||
}
|
||||
if (!rows.length && !state.scanning) {
|
||||
html += '<li class="lan-empty">本轮没扫到板子。别急着当成"板子没了",先看下面的计数与反证列表:</li>';
|
||||
}
|
||||
if (lastListHtml !== html) { list.innerHTML = html; lastListHtml = html; }
|
||||
}
|
||||
|
||||
var misc = panelEl.querySelector('.lan-misc');
|
||||
if (misc) {
|
||||
var m = '';
|
||||
if (state.unknowns.length) {
|
||||
m += '<div class="lan-block"><b>不可判定 ' + state.unknowns.length + ' 个</b>'
|
||||
+ '(连不上 / 读超时 / 形状合但落不进判据)——超时不等于"不在",'
|
||||
+ 'ECAT 环跑与刷写期间设备 httpd 本来就可能不响应。<ul>'
|
||||
+ state.unknowns.slice(0, MAX_ROWS).map(function (u) {
|
||||
return '<li>' + escapeHtml(u.ip) + ' — ' + escapeHtml(u.reason || '') + '</li>';
|
||||
}).join('') + '</ul></div>';
|
||||
}
|
||||
if (state.rejected.length) {
|
||||
m += '<div class="lan-block"><b>反证排除 ' + state.rejected.length + ' 个</b>(有证据不是板子,不是"没扫到")<ul>'
|
||||
+ state.rejected.slice(0, MAX_ROWS).map(function (r) {
|
||||
return '<li>' + escapeHtml(r.ip) + ':' + (r.port || 80) + ' — '
|
||||
+ escapeHtml(r.reason || '') + (r.head ? ' 「' + escapeHtml(String(r.head).slice(0, 60)) + '」' : '') + '</li>';
|
||||
}).join('') + '</ul></div>';
|
||||
}
|
||||
if (state.skipped.length) {
|
||||
m += '<div class="lan-block"><b>剔掉 ' + state.skipped.length + ' 个</b><ul>'
|
||||
+ state.skipped.map(function (s) {
|
||||
return '<li>' + escapeHtml(String(s[0])) + ' — ' + escapeHtml(String(s[1])) + '</li>';
|
||||
}).join('') + '</ul></div>';
|
||||
}
|
||||
if (state.local) {
|
||||
m += '<div class="lan-block">本机:' + escapeHtml(state.local.default_src_ip || '?')
|
||||
+ ' → 段 ' + escapeHtml(state.local.cidr || '(取不到)')
|
||||
+ ';本机地址 ' + escapeHtml((state.local.local_ips || []).join(', ') || '—') + '</div>';
|
||||
}
|
||||
m += '<div class="lan-block lan-hint">两块同款板除了 IP 无法区分(固件不上报 mac/chip_id)。'
|
||||
+ '认领办法:选中一行看「详情」的 uptime,或按板上复位键后立刻快扫——新上电那台 uptime < 30s。'
|
||||
+ 'AP 回退的板子自称 192.168.31.1:PC 还连着路由器 WLAN 时它在三层上不可达,'
|
||||
+ '要真人连热点 ESP32_OTA_RELAY 才探得到。</div>';
|
||||
if (misc.innerHTML !== m) misc.innerHTML = m;
|
||||
}
|
||||
|
||||
renderNote();
|
||||
var busy = panelEl.querySelector('[data-act="fast"]');
|
||||
if (busy) busy.disabled = state.scanning;
|
||||
var full = panelEl.querySelector('[data-act="full"]');
|
||||
if (full) full.disabled = state.scanning;
|
||||
var cancelBtn = panelEl.querySelector('[data-act="cancel"]');
|
||||
if (cancelBtn) cancelBtn.style.display = state.scanning ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ───────────────────────── 挂载 ───────────────────────── */
|
||||
|
||||
var PANEL_HTML =
|
||||
'<div class="lan-mask" role="dialog" aria-modal="true" aria-label="局域网设备发现">'
|
||||
+ '<div class="lan-box" tabindex="-1">'
|
||||
+ '<div class="lan-head"><span>设备发现</span>'
|
||||
+ '<span class="lan-ep"></span>'
|
||||
+ '<button class="btn btn-ghost btn-sm" data-act="close">关闭 (Esc)</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="lan-body">'
|
||||
+ '<div class="lan-tools">'
|
||||
+ '<button class="btn btn-primary btn-sm" data-act="fast">快扫</button>'
|
||||
+ '<button class="btn btn-outline btn-sm" data-act="full">全段扫描</button>'
|
||||
+ '<button class="btn btn-ghost btn-sm" data-act="cancel">取消</button>'
|
||||
+ '<span class="lan-field"><label class="form-label" for="lanCidr">CIDR</label>'
|
||||
+ '<input class="input lan-cidr" id="lanCidr" type="text" placeholder="留空 = 本机 /24"></span>'
|
||||
+ '<label class="lan-check"><input class="lan-pub" type="checkbox"> 允许公网段</label>'
|
||||
+ '</div>'
|
||||
+ '<div class="lan-progress"></div>'
|
||||
+ '<ul class="lan-list" role="listbox" aria-label="发现的设备"></ul>'
|
||||
+ '<div class="lan-note"></div>'
|
||||
+ '<div class="lan-misc"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
|
||||
function bindPanel() {
|
||||
panelEl.addEventListener('click', async function (ev) {
|
||||
/* 遮罩空白处(就是 .lan-mask 自己)= 收起;行内按钮先被 data-act 接走。 */
|
||||
if (ev.target && ev.target.classList && ev.target.classList.contains('lan-mask')) {
|
||||
state.open = false;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
var t = ev.target.closest ? ev.target.closest('[data-act],[data-ip]') : null;
|
||||
if (!t) return;
|
||||
var act = t.getAttribute('data-act');
|
||||
var ip = t.getAttribute('data-ip') || '';
|
||||
if (act === 'close') { state.open = false; render(); return; }
|
||||
if (act === 'fast') { runScan('fast'); return; }
|
||||
if (act === 'full') {
|
||||
var c = panelEl.querySelector('.lan-cidr');
|
||||
state.cidr = c && c.value ? c.value.trim() : '';
|
||||
runScan('full');
|
||||
return;
|
||||
}
|
||||
if (act === 'cancel') {
|
||||
state.cancel = true;
|
||||
say('warn', '取消中:不再发下一批;在途那批(≤' + BATCH_IPS + ' 个地址)仍会跑完');
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (act === 'detail') {
|
||||
say('info', '取 ' + ip + ' 的完整 status…');
|
||||
var r = await directProbe(ip, '/system/status', 3000);
|
||||
if (r.ok && r.json) {
|
||||
state.detail[ip] = r.json;
|
||||
state.note = null;
|
||||
} else {
|
||||
state.note = { kind: 'warn', text: ip + ' 完整 status 取不到:'
|
||||
+ (r.name || ('HTTP ' + r.http)) + ' ' + (r.msg || ''), at: Date.now() };
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (act === 'direct') {
|
||||
var d = await directProbe(ip, PROBE_PATH, 2500);
|
||||
state.direct[ip] = d;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
/* 行本体 = 选择。唯一的写手是 deviceLink.selectDevice,门也在它里面。 */
|
||||
if (ip) {
|
||||
var rec = { ip: ip, source: 'lan' };
|
||||
var det = state.detail[ip];
|
||||
if (det && det.version) { rec.version = det.version; rec.git_hash = det.git_hash; }
|
||||
var res = deviceLink.selectDevice(rec);
|
||||
if (!res.ok) { say('error', '未切换:' + (res.reason || '未知原因')); return; }
|
||||
say('info', '已选 ' + ip + ' —— 页面能不能连它,看顶栏灯(Owner 下一次 1Hz 轮询即出,≤1s)');
|
||||
lastListHtml = null;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
var cidrIn = panelEl.querySelector('.lan-cidr');
|
||||
if (cidrIn) {
|
||||
cidrIn.addEventListener('change', function () { state.cidr = cidrIn.value.trim(); });
|
||||
}
|
||||
var pub = panelEl.querySelector('.lan-pub');
|
||||
if (pub) {
|
||||
pub.addEventListener('change', function () { state.allowPublic = pub.checked; });
|
||||
}
|
||||
}
|
||||
|
||||
function mountHost(anchor) {
|
||||
panelEl = document.createElement('div');
|
||||
panelEl.innerHTML = PANEL_HTML;
|
||||
(anchor || document.body).appendChild(panelEl);
|
||||
bindPanel();
|
||||
|
||||
/* 弹出式:遮罩铺满 + 周围高斯模糊;点遮罩空白处 / Esc / 关闭按钮都收起。 */
|
||||
function openPanel() {
|
||||
state.open = true;
|
||||
render();
|
||||
var box = panelEl.querySelector('.lan-box');
|
||||
if (box && box.focus) { try { box.focus({ preventScroll: true }); } catch (_) { box.focus(); } }
|
||||
if (!state.scan && !state.scanning) runScan('fast');
|
||||
}
|
||||
function closePanel() { state.open = false; render(); }
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape' && state.open) { state.cancel = true; closePanel(); }
|
||||
});
|
||||
|
||||
pillEl = document.getElementById('lanPill');
|
||||
pillTxtEl = document.getElementById('lanPillTxt');
|
||||
var btn = document.getElementById('lanScanBtn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (state.open) closePanel(); else openPanel();
|
||||
});
|
||||
}
|
||||
/* 外壳是扫描的宿主:iframe 里的入口只发消息叫它开面板。 */
|
||||
window.addEventListener('message', function (ev) {
|
||||
var d = ev && ev.data;
|
||||
if (!d || d.__dcuLan !== 'open') return;
|
||||
openPanel();
|
||||
});
|
||||
/* Owner 判离线后的一次性提候选:只扫种子,绝不自动改端点。 */
|
||||
deviceLink.onAutoScan(function () {
|
||||
if (state.scanning) return;
|
||||
var now = Date.now();
|
||||
if (now - state.autoScanned < 60000) return;
|
||||
state.autoScanned = now;
|
||||
say('warn', '当前端点连续拉不到 status,自动补一轮快扫(只列候选,不自动换设备)');
|
||||
runScan('fast');
|
||||
});
|
||||
deviceLink.subscribe(function () { renderPill(); });
|
||||
render();
|
||||
setInterval(function () {
|
||||
if (state.open) render(); else renderPill();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function mountGuest() {
|
||||
var btn = document.getElementById('lanScanBtnPage') || document.getElementById('lanScanBtn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
try {
|
||||
window.parent.postMessage({ __dcuLan: 'open' }, window.location.origin);
|
||||
} catch (e) {
|
||||
say('error', '叫不开外壳面板:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
|
||||
/* file:// 下 localStorage 与 http://127.0.0.1:2625 是两份,flashBusyNow()
|
||||
* 会静默失效、门成空的。不挂载,直接说清楚怎么打开。 */
|
||||
var body = document.body;
|
||||
if (body) {
|
||||
var w = document.createElement('div');
|
||||
w.className = 'lan-note lan-note-error';
|
||||
w.textContent = '设备发现未挂载:页面必须经本地服务打开(双击 esp32_app.vbs,或 http://127.0.0.1:2625/tabs.html)。';
|
||||
body.insertBefore(w, body.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (window.self === window.top) {
|
||||
mountHost(document.getElementById('lanMount'));
|
||||
} else {
|
||||
mountGuest();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -430,7 +430,8 @@ setTimeout(function () {
|
||||
ipInput.addEventListener('change', function () {
|
||||
window.consecutivePollFailures = 0;
|
||||
networkDiagDone = false;
|
||||
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
|
||||
/* 不在这里说"IP 已修改":写入可能已被 deviceLink.setIp 的校验/刷写门拒掉,
|
||||
那句准确的成功/失败日志由 js/app.js 的同一个 change 事件负责,别重复播报。 */
|
||||
if (!statusPollInFlight) {
|
||||
if (statusTimerId) clearTimeout(statusTimerId);
|
||||
scheduleStatusPoll(0);
|
||||
|
||||
@@ -105,3 +105,36 @@ export function setQuietMode(quiet) {
|
||||
document.documentElement.dataset.dcuCalm = v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 更新数据格右上角来源角标(WiFi / ECAT / BLE)。
|
||||
* 双通路无感切换:哪条链路**最后写入**本格,角标就标哪条(last-writer-wins)。
|
||||
* 同格若两路都在写,后到的会盖掉先到的来源——切换瞬间角标跟着变是预期行为。
|
||||
* @param {string|Element} cell 带 data-cell 的格子,或格子内任意元素
|
||||
* @param {'wifi'|'ecat'|'ble'|'off'} src 来源
|
||||
*/
|
||||
export function setCellSource(cell, src) {
|
||||
var root = cell;
|
||||
if (typeof cell === 'string') {
|
||||
root = document.querySelector('[data-cell="' + cell + '"]');
|
||||
} else if (cell && cell.nodeType === 1 && !cell.classList.contains('src-badge')) {
|
||||
root = typeof cell.closest === 'function' ? cell.closest('[data-cell]') : null;
|
||||
}
|
||||
if (!root || !root.querySelector) return;
|
||||
/* 不用 :scope —— 部分环境对它支持不稳,角标是格子内唯一直属 .src-badge */
|
||||
var badges = root.getElementsByClassName('src-badge');
|
||||
var badge = badges && badges[0];
|
||||
if (!badge) return;
|
||||
/* dual = WiFi 与 ECAT 同时在线(双通路) */
|
||||
var map = {
|
||||
wifi: 'WiFi', ecat: 'ECAT', ble: 'BLE', off: '--',
|
||||
dual: 'WiFi·ECAT',
|
||||
};
|
||||
var label = map[src] || '--';
|
||||
var kind = map[src] ? src : 'off';
|
||||
/* dual 用 wifi 同色系加粗边,避免第三套色 */
|
||||
var cls = 'src-badge src-' + (kind === 'dual' ? 'dual' : kind);
|
||||
if (badge.className !== cls) badge.className = cls;
|
||||
if (badge.textContent !== label) badge.textContent = label;
|
||||
if (root.dataset.src !== kind) root.dataset.src = kind;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user