Files
DCU_DesktopController/lab/lan9252_pm.html
T
cbb e98950cde2 chore: 解决 stash pop 与 device-link 合并冲突并收回本地 WIP
保留 ConnectionHub(deviceLink 导入/ECAT 自启/pmHold 幂等)与主线
WIP(断线清指标、AUTO_POLL=false、boot_ecat 等)。
2026-09-16 12:31:39 +08:00

652 lines
30 KiB
HTML
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.
<!DOCTYPE html>
<!--
lan9252_pm.html - LAN9252 芯片级电源管理诊断台
─────────────────────────────────────────────
风格复用全站设计体系(css/base.css + layout.css + components.css,与 OTA 中继/电机页同源),
布局沿用 .main-layout 双列栅格:左 400px 操作列(休眠/恢复),右侧观测列(状态仪表+日志)。
设备连接不设独立区块:直接读全局 localStorage['dcu_esp_ip'](js/common/base-url.js 同 key
同默认值 192.168.31.127,与 OTA/CAN/电机页共享,改 IP 请到 OTA 页)。
设备侧(DCU_APP 1.6.23):SPI 0xA2 action 30~34 → EscLan9252::pmDrive()
30(0x1E)=读状态 31(0x1F)=进 D1 32(0x20)=进 D2 33(0x21)=进 D3 34(0x22)=手动唤醒
NRST 兜底复用 action 20/21 → EscLan9252::nrstDrive()(拉低/推挽高 = 复位脉冲)
HTTP 链路:POST {ip}/spi/send body={type:"A2", data:"<action hex>", timeout_ms}
响应 recv hex 首字节 = status:bit0=READY bit1=PM_SLEEP_EN bit3:2=PM_MODE;
0xEE=无效 action 0xE1=HBI 未就绪;唤醒另有 1=成功 0=超时。
恢复可靠性(为什么睡不死):ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),
芯片睡得再深 0xA2 命令照样送达 GD32;NRST 硬复位覆盖 LAN9252 的一切软件可达状态
(D3 睡死/HBI 假死都在内);GD32 自身挂了还有断电重启兜底(固件 hardwareReset 自动重走
受控复位序列)。自动状态轮询默认已关(AUTO_POLL=false),仅手动「读状态/唤醒/休眠」,
避免持续 /spi/send 0xA2 在 ESP32 串口刷「WEB SPI」日志;需恢复自动刷新改 AUTO_POLL。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN9252 电源管理台</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<style>
/* 本页专属少量补充(令牌/组件全走 base/layout/components) */
.pm-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; }
.pm-cell { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); padding:10px 12px; }
.pm-cell .k { color:var(--text-label); font-size:.7rem; letter-spacing:.08em; display:block; margin-bottom:2px; }
.pm-cell .v { font-family:var(--font-display); font-size:1.5rem; font-weight:700; }
.v-ok { color:var(--green); } .v-warn { color:var(--gd32-hi); } .v-bad { color:var(--red); } .v-dim { color:var(--text-label); }
.pm-sleep-banner { display:none; margin-bottom:14px; padding:11px 15px; border-radius:var(--radius-sm);
background:var(--gd32-dim); border:1px solid rgba(252,191,63,.4); color:var(--gd32-hi);
font-weight:600; font-size:.88rem; align-items:center; gap:10px; }
.pm-sleep-banner.show { display:flex; }
.pm-vbtn { display:flex; flex-direction:column; gap:9px; }
.pm-vbtn .btn { width:100%; }
.pm-table { width:100%; border-collapse:collapse; font-size:.78rem; }
.pm-table th,.pm-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; }
.pm-table th { color:var(--text-label); font-weight:600; background:var(--bg-card); }
.pm-table code { font-family:var(--mono); color:var(--mem-hi); }
.pm-log { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm);
font-family:var(--mono); font-size:.73rem; padding:10px 12px; height:300px; overflow-y:auto; }
.pm-log div { padding:1px 0; color:var(--text-dim); word-break:break-all; }
.pm-log .t { color:var(--text-label); }
.pm-log .ok { color:var(--green); } .pm-log .warn { color:var(--gd32-hi); } .pm-log .err { color:var(--red); }
.btn-armed { background:var(--red) !important; color:#fff !important; animation:blink 1s infinite; }
.pm-note { color:var(--text-dim); font-size:.78rem; margin-top:10px; line-height:1.7; }
.pm-note b { color:var(--gd32-hi); font-weight:600; }
.pm-note code { font-family:var(--mono); color:var(--mem-hi); background:var(--bg-card); border-radius:4px; padding:0 5px; }
.pm-chain { list-style:none; margin:0 0 4px; padding:0; }
.pm-chain li { position:relative; padding:6px 0 6px 34px; font-size:.82rem; color:var(--text-dim); }
.pm-chain li b { color:var(--text); }
.pm-chain li::before { content:attr(data-n); position:absolute; left:0; top:5px; width:22px; height:22px;
border-radius:50%; background:var(--gd32-dim); color:var(--gd32-hi); font-size:.72rem; font-weight:700;
display:flex; align-items:center; justify-content:center; border:1px solid rgba(252,191,63,.4); }
.pm-chain li + li::after { content:""; position:absolute; left:10px; top:-14px; height:14px;
border-left:1px dashed var(--border-strong); }
</style>
</head>
<body>
<div class="container">
<!-- ====== HEADER ====== -->
<header class="header-bar">
<div class="header-brand">
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/><path d="M8 2h8" opacity=".5"/>
</svg>
</span>
<div class="brand-text">
<strong>LAN9252 电源管理台</strong>
<span class="brand-sub">EtherCAT ESC · D1/D2/D3 休眠 · DCU_APP ≥1.6.23</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">连接中…</span></span>
<span class="pill" id="pillIp">IP --</span>
</div>
</header>
<!-- ====== MAIN ====== -->
<div class="main-layout">
<!-- 左列:操作 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🌙</span> 进入低功耗档</div>
<div class="card-body">
<div class="pm-vbtn">
<button class="btn btn-primary" id="btnD1">进 D1 (31)</button>
<button class="btn btn-primary" id="btnD2">进 D2 (32)</button>
<button class="btn btn-danger" id="btnD3">进 D3 (33)</button>
</div>
<div class="pm-note">
<b>D1</b> 系统时钟关、晶振+PLL 保持(有自动唤醒路径)。<br>
<b>D2</b> PLL 请求关(两 PHY 已掉电才真关)。<br>
<b>D3</b> 晶振/PLL 全关,最省电、<b>只能手动唤醒</b>(二次确认)。<br>
<b>休眠期间 EtherCAT 中断、主站 wkc 报错属预期</b>;ESC 寄存器配置全保留,唤醒后自清睡眠位。
D3 进入前固件自动掉电两 PHY,唤醒后自动恢复(自协商需数秒)。
</div>
</div>
</div>
<div class="card card--gd32">
<div class="card-head"><span class="ico">🛟</span> 恢复链 —— 为什么睡不死</div>
<div class="card-body">
<ul class="pm-chain">
<li data-n="1"><b>action 34 软唤醒</b>(正常路径,≤2ms)</li>
<li data-n="2"><b>NRST 硬复位</b>:PB10 拉低 60ms 释放,等效上电复位,覆盖 D3 睡死/HBI 假死等一切软件状态</li>
<li data-n="3"><b>GD32 断电重启</b>:复位期间 PB10 浮空由 R44 上拉维持,重启后固件 hardwareReset() 自动重走受控复位</li>
</ul>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-danger" id="btnNrst" style="width:100%">🛟 ECAT_NRST 硬复位 LAN9252</button>
</div>
<div class="pm-note">
<b>可靠性</b>:ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),芯片睡得再深
<code>0xA2</code> 照样送达 GD32——恢复失败的唯一前提是 ESP32 和 GD32 同时失能,那已不是休眠问题。<br>
NRST 复位会清掉 SSC 已配置的芯片状态,EtherCAT 需主站重新走状态机。
</div>
</div>
</div>
<div class="card card--status">
<div class="card-head"><span class="ico">📋</span> status 字节判读(recv 首字节)</div>
<div class="card-body">
<table class="pm-table">
<tr><th>位/值</th><th>含义</th></tr>
<tr><td><code>bit0</code></td><td>READY:1=D0 且 PLL 稳定</td></tr>
<tr><td><code>bit1</code></td><td>PM_SLEEP_EN:1=低功耗档</td></tr>
<tr><td><code>bit3:2</code></td><td>PM_MODE 寄存器位(仅 SLEEP_EN=1 时有效;未睡眠可能残留旧档)</td></tr>
<tr><td><code>0xEE</code></td><td>无效 action</td></tr>
<tr><td><code>0xE1</code></td><td>HBI 未就绪(勿操作电源)</td></tr>
<tr><td><code>唤醒 1/0</code></td><td>成功 / 10ms 超时</td></tr>
</table>
</div>
</div>
</div>
<!-- 右列:观测 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🔋</span> 芯片电源状态 <span style="margin-left:auto;font-size:.68rem;color:var(--text-label);font-weight:400">仅手动读状态</span></div>
<div class="card-body">
<div class="pm-sleep-banner" id="sleepBanner">🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复</div>
<div class="pm-grid">
<div class="pm-cell"><span class="k">READY</span><span class="v v-dim" id="stReady">--</span></div>
<div class="pm-cell"><span class="k">SLEEP_EN</span><span class="v v-dim" id="stSleep">--</span></div>
<div class="pm-cell"><span class="k">电源档</span><span class="v v-dim" id="stMode">--</span></div>
<div class="pm-cell"><span class="k">原始字节</span><span class="v v-dim" id="stRaw">--</span></div>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-outline" id="btnStatus">读状态 (30)</button>
<button class="btn btn-success" id="btnWake">⏻ 手动唤醒 (34)</button>
</div>
<div class="pm-note">唤醒轮询上限 10ms(手册标称 ≤2ms)。超时多半是芯片不在睡眠或 HBI 异常——以「读状态」为准。</div>
</div>
</div>
<div class="card card--mem">
<div class="card-head"><span class="ico">📜</span> 操作日志</div>
<div class="card-body" style="padding-top:10px">
<div class="pm-log" id="log"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var logBox = $("log");
/* DeviceLink 统一健康态(外壳 Owner 经 BroadcastChannel 广播)。
SPI 0xA2 失败 ≠ 设备离线:灯只认共享层;命令错误单独文案。 */
window.__pmSetConn = null;
function setConn(ok, text) {
var p = $("pillConn");
p.className = "pill " + (ok ? "online" : "offline");
$("pillConnTxt").textContent = text;
}
window.__pmSetConn = setConn;
/** SPI/业务失败:不得把「设备离线」打红(除非共享层已判离线)。 */
function setSpiFail(msg) {
log(msg, "err");
var dl = window.__pmDeviceLink;
var s = dl && dl.getSnapshot ? dl.getSnapshot() : null;
if (s && s.links && s.links.esp32 && s.links.esp32.online) {
setConn(true, "设备在线 · SPI 失败");
} else if (!s) {
/* 无外壳/尚未收到快照:退回旧语义 */
setConn(false, "连接失败 · 5s 后重试");
}
/* 有快照且 offline:留给 DeviceLink subscribe 刷新灯 */
}
/* 与 js/common/base-url.js 同 key 同默认值:全局唯一设备 IP,不设独立连接区 */
var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127";
function deviceIp() {
try { var s = localStorage.getItem(LS_IP); if (s && s.trim()) return s.trim(); } catch (e) {}
return DEFAULT_IP;
}
$("pillIp").textContent = "IP " + deviceIp();
function log(msg, cls) {
var d = document.createElement("div");
if (cls) d.className = cls;
var t = document.createElement("span");
t.className = "t";
t.textContent = "[" + new Date().toLocaleTimeString("zh-CN", { hour12: false }) + "] ";
d.appendChild(t);
d.appendChild(document.createTextNode(msg));
logBox.appendChild(d);
while (logBox.childNodes.length > 300) logBox.removeChild(logBox.firstChild);
logBox.scrollTop = logBox.scrollHeight;
}
/* 通用 0xA2 透传:actionHex 单字节 hex。recv hex 首字节 = status */
function sendA2(actionHex, timeoutMs) {
return fetch("http://" + deviceIp() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "A2", data: actionHex, timeout_ms: timeoutMs || 1500 }),
signal: AbortSignal.timeout((timeoutMs || 1500) + 2000),
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok || j.status !== "ok") throw new Error(j.message || ("HTTP " + r.status));
var hex = String(j.recv || "");
if (hex.length < 2) throw new Error("响应 payload 异常: " + JSON.stringify(hex));
return parseInt(hex.slice(0, 2), 16);
});
});
}
var MODES = ["D0", "D1", "D2", "D3"];
/* 有效电源档:SLEEP_EN=0 时芯片在 D0 工作(EtherCAT/READY 正常),
* PM_MODE 位可能残留上次进档写入的值(板级实测常停在 D2=0x09)。
* 只展示寄存器 MODE 会误导;有效档以 SLEEP_EN 为准。 */
function effectiveMode(b) {
if (b === 0xEE || b === 0xE1) return null;
if ((b & 2) === 0) return "D0";
return MODES[(b >> 2) & 3];
}
function rawMode(b) {
return MODES[(b >> 2) & 3];
}
function describe(b) {
if (b === 0xEE) return "无效 action (0xEE)";
if (b === 0xE1) return "HBI 未就绪 (0xE1),请确认 GD32 已启动且 ECAT init 通过";
var eff = effectiveMode(b), reg = rawMode(b);
var extra = ((b & 2) === 0 && reg !== "D0")
? "(寄存器 PM_MODE 残留 " + reg + ",未睡眠 → 有效 D0)" : "";
return "READY=" + (b & 1) + " SLEEP_EN=" + ((b >> 1) & 1) +
" 有效档=" + eff + " 寄存器MODE=" + reg + extra +
" (0x" + b.toString(16).toUpperCase().padStart(2, "0") + ")";
}
function setCell(id, text, cls) {
var e = $(id); e.textContent = text; e.className = "v " + cls;
}
function renderStatus(b) {
var ready = (b & 1) !== 0, sleep = (b & 2) !== 0;
var mode = effectiveMode(b) || MODES[(b >> 2) & 3];
setCell("stReady", ready ? "1" : "0", ready ? "v-ok" : "v-warn");
setCell("stSleep", sleep ? "1" : "0", sleep ? "v-warn" : "v-ok");
setCell("stMode", mode, mode === "D0" ? "v-ok" : "v-warn");
setCell("stRaw", "0x" + b.toString(16).toUpperCase().padStart(2, "0"), "v-dim");
$("sleepBanner").className = "pm-sleep-banner" + (sleep ? " show" : "");
}
/* 跨标签/重载保持睡眠:HBI 读会唤醒芯片,页面销毁后现场丢失会导致
* 切回时自动 poll(1E) 把睡眠叫醒。sessionStorage 记本标签档位;另存一份
* localStorage 供换标签页/重开浏览器时兜底(宁可按睡眠处理少一次自动读,
* 也不能自动 1E 把芯片叫醒)。 */
var SS_SLEEP = "lan9252_pm_asleep";
var LS_SLEEP = "lan9252_pm_asleep_any";
function saveSleepFlag(n) {
try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {}
/* localStorage: "n@ms" —— 带时间戳,换标签/重开浏览器可追溯入睡时刻 */
try { localStorage.setItem(LS_SLEEP, n + "@" + Date.now()); } catch (e) {}
}
function clearSleepFlag() {
try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {}
try { localStorage.removeItem(LS_SLEEP); } catch (e) {}
}
function loadSleepFlag() {
try {
var v = sessionStorage.getItem(SS_SLEEP);
if (v === "1" || v === "2" || v === "3") return parseInt(v, 10);
} catch (e) {}
try {
var lv = localStorage.getItem(LS_SLEEP) || "";
/* 兼容旧值 "1"/"2"/"3" 与新值 "1@1760029000000" */
var m = String(lv).match(/^([123])(?:@(\d+))?$/);
if (m) return parseInt(m[1], 10);
} catch (e) {}
return 0;
}
function renderAssumedSleep(n) {
/* 不读设备,按上次会话档位恢复 UI(芯片可能仍睡着) */
setCell("stReady", "--", "v-dim");
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
setCell("stRaw", "未读", "v-dim");
$("sleepBanner").textContent =
"🌙 上次会话处于 D" + n + " —— 已跳过自动状态读(避免 HBI 读唤醒)。点「手动唤醒」或「读状态」继续(读会唤醒)";
$("sleepBanner").className = "pm-sleep-banner show";
}
/* ---- 自动状态轮询已关闭(默认只手动):此前 2s 轮询会持续打 /spi/send 0xA2,
* 在 ESP32 串口刷「WEB SPI」日志。手动「读状态 / 唤醒 / 休眠」仍可用。
* 若需恢复自动刷新:把 AUTO_POLL 改 true。 ---- */
var AUTO_POLL = false;
var pollFail = 0, pollTimer = null, pollPaused = !AUTO_POLL, pollGen = 0;
/** 主站看门狗豁免是否已置位(pmHold 幂等,防 2s 轮询刷屏) */
var pmHoldOn = false;
function bumpPollGen() { return ++pollGen; }
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
function markAwake() {
clearSleepFlag();
/* 只在「之前置过豁免」时才解除:pollOnce 每 2s 调本函数,
* 无脑 pmHold(0) 会把「主站看门狗豁免解除」刷屏。 */
if (pmHoldOn) pmHold(0);
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复";
}
function pollOnce() {
if (pollPaused) return;
var gen = bumpPollGen();
sendA2("1E").then(function (b) {
if (gen !== pollGen || pollPaused) return; /* 过期响应 */
pollFail = 0;
setConn(true, "已连接");
renderStatus(b);
if ((b & 2) !== 0 && b !== 0xEE && b !== 0xE1) {
/* 睡眠态:本读已把它唤醒;暂停轮询等用户主动操作 */
pollPaused = true;
setConn(true, "睡眠中 · 自动刷新已暂停");
log("检测到睡眠态(本次读取已将其唤醒)。轮询暂停;点「读状态」或「唤醒」后恢复。", "warn");
} else {
if (b !== 0xEE && b !== 0xE1) markAwake();
pollTimer = setTimeout(pollOnce, 2000);
}
}).catch(function () {
if (gen !== pollGen || pollPaused) return;
pollFail++;
if (pollFail >= 3) {
setSpiFail("SPI 轮询失败 ×3(IP " + deviceIp() + ")· 5s 后重试");
pollTimer = setTimeout(probe, 5000);
} else {
pollTimer = setTimeout(pollOnce, 2000);
}
});
}
function resumePolling() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (!AUTO_POLL) {
pollPaused = true;
return;
}
pollPaused = false;
bumpPollGen();
pollTimer = setTimeout(pollOnce, 2000);
}
/* ESP32 可达性探测:只打 /system/status,不碰 LAN9252 HBI(睡眠恢复路径用) */
function probeEspOnly() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok");
})
.catch(function (e) {
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
pollTimer = setTimeout(probeEspOnly, 5000);
});
}
function probe() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "已连接");
log("设备可达: " + deviceIp() +
(AUTO_POLL ? "。" : "。自动状态轮询已关闭,请点「读状态」手动查询。"), "ok");
if (AUTO_POLL) pollOnce();
})
.catch(function (e) {
setSpiFail("ESP32 探测失败: " + e.message + "(IP " + deviceIp() + ")");
pollTimer = setTimeout(probe, 5000);
});
}
function busy(btn, fn) {
btn.addEventListener("click", function () {
btn.disabled = true;
Promise.resolve().then(fn).catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { btn.disabled = false; });
});
}
/* 唤醒/NRST 后重拉 EtherCAT 主站 OP。
* 实测(2026-09-12):软唤醒后芯片 READY≠主站可进 OP——ESC SM/FMMU 在睡眠后失效,
* motor/start 卡在 SAFEOP AL=0x001E Invalid input configuration(偶发能过、多数失败),
* 电机页还会每 2s 探测重试把同一错误刷屏。
* 可靠路径只有:NRST 清 SSC → 等 EEPROM/ESC 启动 → motor/start 走 INIT→PREOP→SAFEOP→OP。
* 故唤醒后一律先 NRST,不再「先试 start 失败再 NRST」。 */
function ecatPost(path, body, timeoutMs) {
return fetch("/ecat/api/" + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 30000),
}).then(function (r) {
return r.json().then(function (j) {
return { ok: !!(j && j.ok), log: (j && j.log) || [], json: j };
});
});
}
function ecatMotorStart(timeoutMs) {
return ecatPost("motor/start", {}, timeoutMs || 30000);
}
/* 主站楔死看门狗豁免:休眠期间 ecat_native 不判死不重拉(否则芯片醒来
* 即被偷偷拉回 OP——2026-09-12 实测休眠 ~20s 自动"复活")。解除时主站
* 自带 60s 冷却,让本页唤醒流程先走完。best-effort:native 未起只记日志。 */
function pmHold(on) {
var want = !!on;
if (pmHoldOn === want) return Promise.resolve();
return ecatPost("pm/hold", { on: want }, 5000).then(function (r) {
if (r.ok) pmHoldOn = want;
log("主站看门狗豁免" + (want ? "置位" : "解除") +
(r.ok ? "" : "失败: " + r.log.join(";")), r.ok ? "ok" : "warn");
}).catch(function (e) {
log("主站看门狗豁免" + (want ? "置位" : "解除") + "失败: " + e.message, "warn");
});
}
function ecatEnsureOpen() {
/* bus_open=0 时 motor/start 直接回「未连接」。与电机页 ensureOpen 同源。 */
return ecatPost("native/health", {}, 6000).then(function (h) {
var open = h.json && h.json.bus_open;
if (open) return { ok: true, log: ["总线已 open"], json: h.json };
var adapter = (window.__pmDeviceLink && window.__pmDeviceLink.getEcatAdapter)
? window.__pmDeviceLink.getEcatAdapter() : "I210";
log("总线未 open,调用 /api/open (" + adapter + ") …");
return ecatPost("open", { substr: adapter }, 20000);
});
}
function nrstPulse() {
log("NRST 拉低 100ms …", "warn");
return sendA2("15").then(function () {
return new Promise(function (r) { setTimeout(r, 100); });
}).then(function () {
return sendA2("14");
}).then(function () {
/* EEPROM 加载 + HBI 探针:2s 不够时 start 会「重枚举没找到从站」 */
log("NRST 已释放,等待 ESC/EEPROM 启动 4s …");
return new Promise(function (r) { setTimeout(r, 4000); });
});
}
function recoverEcatMaster() {
pmHold(0); /* 唤醒流程开始即解除豁免(主站给 60s 冷却,不干扰本流程) */
log("重连 EtherCAT 主站:stop → open → NRST → motor/start …", "warn");
/* 先停在跑的 PDO 循环,避免与 NRST/init 抢总线 */
return ecatPost("motor/stop", {}, 8000).catch(function () { return { ok: true, log: ["stop 跳过"] }; })
.then(function (s) {
if (s && s.log && s.log.length) log("stop: " + s.log.join(";"));
return ecatEnsureOpen();
})
.then(function (op) {
if (!op.ok) log("open 失败: " + op.log.join(";"), "warn");
else log("open: " + op.log.join(";"), "ok");
return nrstPulse();
})
.then(function () {
return sendA2("1E");
})
.then(function (b) {
log("复位后状态: " + describe(b));
renderStatus(b);
markAwake();
return ecatMotorStart(30000);
})
.then(function (r) {
if (r.ok) {
log("主站已进 OP:" + r.log.join(";"), "ok");
setConn(true, "已连接");
resumePolling();
return r;
}
log("motor/start 失败:" + r.log.join(";") + " → 再 stop+open+NRST 重试", "err");
return ecatPost("motor/stop", {}, 8000)
.then(function () { return ecatEnsureOpen(); })
.then(function () { return nrstPulse(); })
.then(function () { return ecatMotorStart(30000); })
.then(function (r2) {
if (r2.ok) {
log("第二次重试主站已进 OP:" + r2.log.join(";"), "ok");
setConn(true, "已连接");
} else {
log("两次后仍失败:" + r2.log.join(";") + "。请查网线/供电,或到电机页手动连接。", "err");
}
resumePolling();
return r2;
});
})
.catch(function (e) {
log("重连主站异常: " + e.message + "(ecat_native 未起?)", "err");
resumePolling();
return { ok: false, log: [e.message] };
});
}
busy($("btnStatus"), function () {
return sendA2("1E").then(function (b) {
renderStatus(b);
log("状态: " + describe(b));
if ((b & 2) === 0 && b !== 0xEE && b !== 0xE1) markAwake();
resumePolling();
});
});
busy($("btnWake"), function () {
log("手动唤醒(HBI 写 BYTE_TEST)…");
bumpPollGen();
clearSleepFlag();
return sendA2("22").then(function (b) {
if (b === 1) {
log("唤醒成功: " + describe(b), "ok");
renderStatus(b);
markAwake();
setConn(true, "已连接");
/* 芯片 READY ≠ 主站 OP;必须 NRST 再 start,否则 SAFEOP AL=0x001E */
return recoverEcatMaster();
} else if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
resumePolling();
} else {
log("唤醒超时(status=0x" + b.toString(16) + "),改走 NRST 硬复位恢复", "err");
renderStatus(b);
return recoverEcatMaster();
}
});
});
function enterMode(n, hex) {
log("进入 D" + n + " …" + (n === 3 ? "(先掉电两 PHY)" : ""));
/* 作废在途 1E,防止旧 poll 响应把睡眠 UI 打回 D0 */
bumpPollGen();
return sendA2(hex).then(function (b) {
if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
return;
}
log("已进入 D" + n + "(固件置信返回,未读回)。", "warn");
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
pollPaused = true;
setConn(true, "睡眠中 · 轮询已暂停");
saveSleepFlag(n);
pmHold(1); /* 请主站看门狗整段静默,别把睡着/刚醒的芯片偷偷拉回 OP */
renderStatus(b);
/* 固件返回可能是新 status 或旧字节;强制按 D{n} 展示并记住 */
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复(切页回来会保持此睡眠态)";
$("sleepBanner").className = "pm-sleep-banner show";
});
}
busy($("btnD1"), function () { return enterMode(1, "1F"); });
busy($("btnD2"), function () { return enterMode(2, "20"); });
/* D3 二次确认:3 秒内再点一次才执行 */
(function () {
var armed = false, timer = null;
$("btnD3").addEventListener("click", function () {
if (!armed) {
armed = true;
$("btnD3").classList.add("btn-armed");
$("btnD3").textContent = "⚠ 再点一次确认进 D3";
timer = setTimeout(function () { disarm(); }, 3000);
return;
}
clearTimeout(timer); disarm();
$("btnD3").disabled = true;
enterMode(3, "21").catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { $("btnD3").disabled = false; });
});
function disarm() {
armed = false;
$("btnD3").classList.remove("btn-armed");
$("btnD3").textContent = "进 D3 (33)";
}
})();
/* NRST 硬复位:0x15 拉低 → 100ms → 0x14 释放 → 等 2s → 重拉主站 */
busy($("btnNrst"), function () {
bumpPollGen();
clearSleepFlag();
return recoverEcatMaster();
});
/* 启动:若上次会话记着睡眠档,跳过自动 1E(会唤醒),只探 ESP32 可达性 */
var restoredSleep = loadSleepFlag();
if (restoredSleep) {
pollPaused = true;
renderAssumedSleep(restoredSleep);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("恢复上次会话:D" + restoredSleep + " 睡眠中。为避免 HBI 读唤醒芯片,已跳过自动状态刷新;点「读状态」或「手动唤醒」继续。", "warn");
/* 新标签/重开浏览器:native 可能刚重启导致 g_pm_hold=0,楔死线程会趁
* 芯片睡着时重拉(D1/D2 报文即唤醒)。必须重新置位豁免。 */
pmHold(1);
probeEspOnly();
} else {
log(AUTO_POLL
? "就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。"
: "就绪。自动状态轮询已关闭(避免持续 SPI 诊断流量);需要时点「读状态」。改设备 IP 请到 OTA 中继页。", "warn");
probe();
}
})();
</script>
<script type="module">
import { deviceLink } from '../js/common/device-link.js?v=20260916a';
window.__pmDeviceLink = deviceLink;
deviceLink.subscribe(function (s) {
if (!s || !s.links || !window.__pmSetConn) return;
var e = s.links.esp32;
window.__pmSetConn(!!e.online, e.online ? '已连接' : '离线');
});
</script>
</body>
</html>