Files
DCU_DesktopController/js/can_monitor.js
T

497 lines
20 KiB
JavaScript
Raw Normal View History

/**
* 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();
}