Files
DCU_DesktopController/js/can_monitor.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

497 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.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, statusTimer: null, fbInFlight: false };
var el = {};
// ── Click-to-Copy (feedback 值单元格) ──────────────────────
function copyFbVal(text, target) {
if (!text || text === '--') return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
var ids = [
'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',
'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Tec', 'fb1Rec', 'fb1Bus', 'fb1Seq', 'fb1Delta',
'fb1LastId', 'fb1Dlc', 'fb1Flags', 'fb1LastData',
'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 */
];
function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); });
}
/** WiFi 端点:优先本页输入框,否则 ConnectionHub 共享 IP(dcu_esp_ip)。 */
function getBaseUrl() {
if (el.monIp && el.monIp.value && el.monIp.value.trim()) {
return 'http://' + el.monIp.value.trim();
}
if (el.toolIp && el.toolIp.value && el.toolIp.value.trim()) {
return 'http://' + el.toolIp.value.trim();
}
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';
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err');
if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text;
/* 轮询状态即监控连通性(顶栏网络胶囊由 /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,避免并发请求叠加
if (state.fbInFlight) return;
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,0xC1 必被链路层拒
* (ESP32 侧一帧一条拒绝日志),问了也是白问。标记随刷写收尾自撤。 */
if (flashBusyNow()) return;
state.fbInFlight = true;
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 3000);
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: ac.signal });
if (!resp.ok) return;
var data = await resp.json();
var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0;
if (!Number.isFinite(selectedCh)) selectedCh = 0;
for (var i = 0; i < 3; i++) {
var ch = channels[i];
var card = document.getElementById('fbCh' + i);
/* enabled 来自 GD32 HELLO/PING 身份;未知时默认按开启展示 */
var enabled = !ch || ch.enabled === undefined || ch.enabled === null || !!ch.enabled;
if (card) {
card.classList.toggle('ch-disabled', !enabled);
card.title = enabled ? '' : 'GD32 CONFIG_CAN_CH' + i + '_ENABLE=false(HELLO/PING 身份)';
}
if (!enabled) {
renderChannelDisabled(i);
continue;
}
if (!ch || !ch.valid) {
renderChannelEmpty(i);
continue;
}
renderChannel(i, ch);
}
var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries);
if (el.idsnapStatus) {
el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID';
el.idsnapStatus.className = 'pill online';
}
} else {
renderIdTableEmpty();
}
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 正常';
el.fbPollStatus.className = 'pill online';
}
setConnState(true, '已连接');
} catch (e) {
if (e && e.name === 'AbortError') return;
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
setConnState(false, '连接失败');
} finally {
clearTimeout(timer);
state.fbInFlight = false;
}
}
/** 各通道最近一次 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']) {
var hasIds = ch.count > 0;
el[p + 'State'].textContent = hasIds ? '活跃 (' + ch.count + ' ID)' : '无帧(总线空闲)';
el[p + 'State'].className = 'fb-val ' + (hasIds ? 'ok' : 'info');
}
if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx;
if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx;
if (el[p + 'Drop']) { el[p + 'Drop'].textContent = ch.drop; el[p + 'Drop'].className = 'fb-val' + (ch.drop > 0 ? ' warn' : ''); }
if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); }
if (el[p + 'Tec']) {
el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--';
el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : '');
}
if (el[p + 'Rec']) {
el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--';
el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : '');
}
if (el[p + 'Bus']) {
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('控制器睡眠');
if (bs & 4) parts.push('ERR_PASSIVE');
if (bs & 8) parts.push('BUS_OFF');
if (bs & 16) parts.push('ERR_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'; }
var entries = ch.entries;
if (entries && entries.length) {
var last = entries[entries.length - 1];
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc;
if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-');
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
} else {
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--';
if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--';
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
if (i === 0 && el.fbModePill) {
var modeText = ch.mode === 2 ? '桥接(CAN0→1/2)' : (ch.mode === 1 ? '过滤' : '全收');
el.fbModePill.textContent = '模式: ' + modeText;
}
}
function renderChannelEmpty(i) {
var p = 'fb' + i;
if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; }
['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) {
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
});
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) */
function renderChannelDisabled(i) {
var p = 'fb' + i;
if (el[p + 'State']) {
el[p + 'State'].textContent = '固件未启用';
el[p + 'State'].className = 'fb-val warn';
}
['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) {
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
});
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) {
var tbody = el.idsnapBody;
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var frag = document.createDocumentFragment();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + (i + 1) + '</td>' +
'<td class="mono">' + escapeHtml(e.id) + '</td>' +
'<td>' + escapeHtml(e.dlc) + '</td>' +
'<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>' +
'<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>' +
'<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>' +
'<td class="idsnap-data mono">' + escapeHtml(e.data || '--') + '</td>';
frag.appendChild(tr);
}
tbody.innerHTML = '';
tbody.appendChild(frag);
}
function renderIdTableEmpty() {
var tbody = el.idsnapBody;
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
}
async function testConnection() {
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
setConnState(true, '已连接');
showToast('ESP32 已连接', 'success');
} catch (e) {
setConnState(false, '连接失败');
showToast('连接失败: ' + e.message, 'error');
}
}
async function setCanMode(mode) {
try {
var resp = await fetch(getBaseUrl() + '/can/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
} else {
showToast('模式切换失败', 'error');
}
} catch (e) {
showToast('模式切换失败: ' + e.message, 'error');
}
}
/**
* 初始化监控逻辑。可在 can_monitor.html(独立)或 can.html(抽屉)中调用。
* 幂等:重复调用不会叠加多个 interval。
*/
export function initCanMonitor() {
cacheElements();
/* 无监控 DOM 时跳过(纯 can 发送页历史形态) */
if (!el.fb0State && !el.idsnapBody) return;
if (state.fbTimer) {
clearInterval(state.fbTimer);
state.fbTimer = null;
}
if (state.statusTimer) {
clearInterval(state.statusTimer);
state.statusTimer = null;
}
setConnState(false, '未连接');
if (!state.bound) {
state.bound = true;
/* ConnectionHub:只覆盖独立页网络胶囊灯色;
* 嵌入 can.html 时不得碰顶栏与 fbPollStatus(那是 can.js / /can/feedback 自己的态)。 */
deviceLink.subscribe(function (s) {
if (!isStandalone()) return;
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
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', 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 () {
pollFeedback();
});
}
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 容器上事件代理 */
document.querySelectorAll('.feedback-grid').forEach(function (grid) {
grid.addEventListener('click', function (e) {
var target = e.target.closest('.fb-val');
if (!target) return;
copyFbVal(target.textContent.trim(), target);
});
});
/* 通道卡:默认折叠详情,点标题展开/收起 */
document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) {
var toggle = function (e) {
e.preventDefault();
var card = head.closest('.feedback-channel');
if (card) card.classList.toggle('collapsed');
};
head.addEventListener('click', toggle);
head.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') toggle(e);
});
});
}
setTimeout(testConnection, 150);
/* 顶栏系统状态 5s 与 /can/feedback 1Hz 解耦;仅独立页维护 */
if (isStandalone()) {
setTimeout(refreshSystemStatus, 150);
state.statusTimer = setInterval(refreshSystemStatus, 5000);
}
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
/* 独立页 can_monitor.html:有 monIp 时自启;嵌入 can.html 由 can.js 调用 initCanMonitor() */
if (document.getElementById('monIp')) {
initCanMonitor();
}