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:
+151
-17
@@ -2,11 +2,11 @@
|
||||
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
|
||||
*/
|
||||
import { showToast } from './common/toast.js';
|
||||
import { escapeHtml, flashBusyNow } from './common/utils.js';
|
||||
import { escapeHtml, flashBusyNow, formatUptime } from './common/utils.js';
|
||||
import { getBaseUrl as hubBaseUrl } from './common/base-url.js';
|
||||
import { deviceLink } from './common/device-link.js';
|
||||
|
||||
var state = { fbTimer: null, fbInFlight: false };
|
||||
var state = { fbTimer: null, statusTimer: null, fbInFlight: false };
|
||||
|
||||
var el = {};
|
||||
|
||||
@@ -35,8 +35,9 @@ function copyFbVal(text, target) {
|
||||
}
|
||||
|
||||
var ids = [
|
||||
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
|
||||
'monIp', 'monTestBtn',
|
||||
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
|
||||
'canNetPill', 'canNetTxt', 'canUptimePill', 'canVersionPill',
|
||||
'fbModePill', 'fbPollStatus',
|
||||
'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta',
|
||||
'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData',
|
||||
@@ -45,6 +46,7 @@ var ids = [
|
||||
'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta',
|
||||
'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData',
|
||||
'btnModeAllPass', 'btnModeFilter', 'btnModeBridge',
|
||||
'btnSilent0', 'btnSilent1', 'btnSilent2',
|
||||
'idsnapChannel', 'idsnapStatus', 'idsnapBody',
|
||||
'toast', 'toastIco', 'toastMsg',
|
||||
'toolIp' /* can.html 共用 IP */
|
||||
@@ -65,22 +67,80 @@ function getBaseUrl() {
|
||||
return hubBaseUrl();
|
||||
}
|
||||
|
||||
/** 独立页 can_monitor.html 时维护顶栏;嵌入 can.html 抽屉时跳过(由 can.js 写)。 */
|
||||
function isStandalone() {
|
||||
return !!(el.monIp);
|
||||
}
|
||||
|
||||
function setNetPill(connected, label) {
|
||||
if (!isStandalone()) return;
|
||||
if (el.canNetPill) {
|
||||
el.canNetPill.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
|
||||
}
|
||||
if (el.canNetTxt) el.canNetTxt.textContent = label;
|
||||
}
|
||||
|
||||
function setConnState(ok, text) {
|
||||
/* 无设备静默态:离线时掐掉常驻动画,规则见 css/responsive.css 的
|
||||
html[data-dcu-calm="1"] 段(160Hz 屏上每帧重算+重绘 ≈ 白烧一核)。 */
|
||||
document.documentElement.dataset.dcuCalm = ok ? '0' : '1';
|
||||
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
|
||||
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
|
||||
if (el.monConnTxt) el.monConnTxt.textContent = text;
|
||||
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err');
|
||||
if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text;
|
||||
/* 轮询状态即监控连通性(与顶栏 canConn 去重时仍更新 fbPollStatus) */
|
||||
if (!el.monConnPill && el.fbPollStatus && !ok) {
|
||||
/* 轮询状态即监控连通性(顶栏网络胶囊由 /system/status 统一写) */
|
||||
if (el.fbPollStatus && !ok) {
|
||||
el.fbPollStatus.textContent = '轮询: 离线';
|
||||
el.fbPollStatus.className = 'pill offline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶栏系统状态(与 can.js setConnectionState 对齐):
|
||||
* STA/IP/RSSI 合并胶囊 + 运行时间 + 版本。
|
||||
*/
|
||||
function setSystemStatus(connected, text, buildVersion, buildTime, wifi, uptimeSec) {
|
||||
if (!isStandalone()) return;
|
||||
wifi = wifi || {};
|
||||
var label = text || (connected ? '已连接' : '未连接');
|
||||
if (connected) {
|
||||
var ip = wifi.ip || (el.monIp && el.monIp.value && el.monIp.value.trim()) || '';
|
||||
if (ip) label += ' · ' + ip;
|
||||
var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127;
|
||||
if (rssiOk) label += ' · ' + wifi.rssi + ' dBm';
|
||||
}
|
||||
setNetPill(connected, label);
|
||||
if (el.canUptimePill) {
|
||||
el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null
|
||||
? formatUptime(uptimeSec) : '--');
|
||||
}
|
||||
if (el.canVersionPill) {
|
||||
el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--';
|
||||
el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 周期性刷新顶栏网络状态(与 can.js 一致,5s)。 */
|
||||
async function refreshSystemStatus() {
|
||||
if (!isStandalone()) return;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setSystemStatus(false, 'HTTP ' + resp.status, '', '', {}, null);
|
||||
return;
|
||||
}
|
||||
var data = await resp.json();
|
||||
var wifi = data.wifi || {};
|
||||
var connected = !!wifi.connected;
|
||||
var mode = wifi.mode || 'STA';
|
||||
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
|
||||
setSystemStatus(connected, text, data.version, data.build_time, wifi, data.uptime_sec);
|
||||
} catch (err) {
|
||||
setSystemStatus(false, '连接失败', '', '', {}, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
|
||||
async function pollFeedback() {
|
||||
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
|
||||
@@ -148,6 +208,53 @@ async function pollFeedback() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 各通道最近一次 bus_state(唯一写入点=renderChannel;静默按钮样式与取反目标都读它)。 */
|
||||
var s_lastBusState = [null, null, null];
|
||||
|
||||
/** 每通道"只听不发"按钮样式:只由 bus_state bit5 决定,不做乐观状态。 */
|
||||
function updateSilentButton(i) {
|
||||
var btn = el['btnSilent' + i];
|
||||
if (!btn) return;
|
||||
var bs = s_lastBusState[i];
|
||||
var on = (bs !== null && (bs & 32) !== 0);
|
||||
btn.className = 'btn ' + (on ? 'btn-primary' : 'btn-outline') + ' btn-sm';
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
btn.disabled = (bs === null);
|
||||
btn.title = (bs === null)
|
||||
? '通道状态未知(SPI 离线或尚未轮询到),无法判断'
|
||||
: (on ? '当前只听不发:本端驱动器已关断,接收照常;点击恢复发送'
|
||||
: '当前可正常发送;点击后该通道只听不发(只关本端发送,接收与上报不受影响)');
|
||||
}
|
||||
|
||||
/** POST /can/silent 翻转指定通道静默;新样式等下一次 feedback 的 bit5 回来再刷。 */
|
||||
async function toggleCanTxSilent(ch) {
|
||||
var bs = s_lastBusState[ch];
|
||||
if (bs === null || bs === undefined) {
|
||||
showToast('通道状态未知,请先连上设备', 'error');
|
||||
return;
|
||||
}
|
||||
var on = (bs & 32) === 0;
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/can/silent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ch: ch, on: on ? 1 : 0 }),
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
showToast('CH' + ch + (data.actual ? ' 已切为只听不发' : ' 已恢复发送'), 'success');
|
||||
} else if (data.err === 'paused') {
|
||||
showToast('OTA / 批量发送进行中,暂不可切换静默', 'error');
|
||||
} else {
|
||||
showToast('静默切换失败(err=' + data.err + ')', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('静默切换失败: ' + e.message, 'error');
|
||||
}
|
||||
pollFeedback();
|
||||
}
|
||||
|
||||
function renderChannel(i, ch) {
|
||||
var p = 'fb' + i;
|
||||
if (el[p + 'State']) {
|
||||
@@ -171,17 +278,22 @@ function renderChannel(i, ch) {
|
||||
if (ch.bus_state === undefined || ch.bus_state === null) {
|
||||
el[p + 'Bus'].textContent = '未知';
|
||||
el[p + 'Bus'].className = 'fb-val';
|
||||
s_lastBusState[i] = null;
|
||||
} else {
|
||||
var bs = ch.bus_state;
|
||||
var parts = [];
|
||||
if (bs & 1) parts.push('INIT');
|
||||
if (bs & 2) parts.push('SLEEP');
|
||||
if (bs & 2) parts.push('控制器睡眠');
|
||||
if (bs & 4) parts.push('ERR_PASSIVE');
|
||||
if (bs & 8) parts.push('BUS_OFF');
|
||||
if (bs & 16) parts.push('ERR_WARN');
|
||||
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常');
|
||||
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16)) ? ' warn' : '');
|
||||
if (bs & 32) parts.push('只听不发');
|
||||
/* 未识别的位显示 hex,不回落成"正常"(否则新位会被读成一切正常) */
|
||||
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : ('0x' + bs.toString(16)));
|
||||
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16 | 32)) ? ' warn' : '');
|
||||
s_lastBusState[i] = bs;
|
||||
}
|
||||
updateSilentButton(i);
|
||||
}
|
||||
if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq;
|
||||
if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; }
|
||||
@@ -212,6 +324,8 @@ function renderChannelEmpty(i) {
|
||||
});
|
||||
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
|
||||
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
|
||||
s_lastBusState[i] = null;
|
||||
updateSilentButton(i);
|
||||
}
|
||||
|
||||
/** GD32 编译关闭该通道(APP PING feat can_chN=false) */
|
||||
@@ -226,6 +340,8 @@ function renderChannelDisabled(i) {
|
||||
});
|
||||
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
|
||||
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
|
||||
s_lastBusState[i] = null;
|
||||
updateSilentButton(i);
|
||||
}
|
||||
|
||||
function renderIdTable(entries) {
|
||||
@@ -303,21 +419,31 @@ export function initCanMonitor() {
|
||||
clearInterval(state.fbTimer);
|
||||
state.fbTimer = null;
|
||||
}
|
||||
if (state.statusTimer) {
|
||||
clearInterval(state.statusTimer);
|
||||
state.statusTimer = null;
|
||||
}
|
||||
|
||||
setConnState(false, '未连接');
|
||||
if (!state.bound) {
|
||||
state.bound = true;
|
||||
/* ConnectionHub:只覆盖独立页 monConn 灯的布尔;
|
||||
* 嵌入 can.html 时无 monConnPill,不得碰 fbPollStatus(那是 /can/feedback 自己的轮询态)。 */
|
||||
/* ConnectionHub:只覆盖独立页网络胶囊灯色;
|
||||
* 嵌入 can.html 时不得碰顶栏与 fbPollStatus(那是 can.js / /can/feedback 自己的态)。 */
|
||||
deviceLink.subscribe(function (s) {
|
||||
if (!el.monConnPill) return;
|
||||
if (!isStandalone()) return;
|
||||
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
|
||||
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
|
||||
el.monConnPill.className = 'pill ' + (on ? 'online' : 'offline');
|
||||
if (el.canNetPill) el.canNetPill.className = 'pill link-pill ' + (on ? 'online' : 'offline');
|
||||
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (on ? 'ok' : 'err');
|
||||
});
|
||||
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
|
||||
if (el.monIp) el.monIp.addEventListener('change', testConnection);
|
||||
if (el.monTestBtn) el.monTestBtn.addEventListener('click', function () {
|
||||
testConnection();
|
||||
refreshSystemStatus();
|
||||
});
|
||||
if (el.monIp) el.monIp.addEventListener('change', function () {
|
||||
testConnection();
|
||||
refreshSystemStatus();
|
||||
});
|
||||
/* 嵌入 can.html 时 IP 与发送台共用 */
|
||||
if (el.toolIp && !el.monIp) {
|
||||
el.toolIp.addEventListener('change', function () {
|
||||
@@ -327,6 +453,9 @@ export function initCanMonitor() {
|
||||
if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); });
|
||||
if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); });
|
||||
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
|
||||
[['btnSilent0', 0], ['btnSilent1', 1], ['btnSilent2', 2]].forEach(function (pair) {
|
||||
if (el[pair[0]]) el[pair[0]].addEventListener('click', function () { toggleCanTxSilent(pair[1]); });
|
||||
});
|
||||
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
|
||||
|
||||
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
|
||||
@@ -352,6 +481,11 @@ export function initCanMonitor() {
|
||||
});
|
||||
}
|
||||
setTimeout(testConnection, 150);
|
||||
/* 顶栏系统状态 5s 与 /can/feedback 1Hz 解耦;仅独立页维护 */
|
||||
if (isStandalone()) {
|
||||
setTimeout(refreshSystemStatus, 150);
|
||||
state.statusTimer = setInterval(refreshSystemStatus, 5000);
|
||||
}
|
||||
pollFeedback();
|
||||
state.fbTimer = setInterval(pollFeedback, 1000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user