/** * 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 = '