diff --git a/docs/compose/spec/device-link.md b/docs/compose/spec/device-link.md
index 111a6f0..2ea96d1 100644
--- a/docs/compose/spec/device-link.md
+++ b/docs/compose/spec/device-link.md
@@ -48,6 +48,8 @@ can_monitor、motor、ecat_load、LAN9252 改为从 Hub 读端点/订在线布
3. **端点配置只在 ConnectionHub**:IP(`dcu_esp_ip`)、网卡(`dcu_ecat_adapter`)读写集中。
4. **命令仍页内发**,连接灯只认 DeviceLink 布尔(文案保留调用方语义)。
5. BLE 为一等通道但单独适配:只广播状态,GATT 细节留在 `ble/*`。
+6. **外壳启动强制开总线**:Owner 首轮即 `status→open→motor/start`;成功后不再重复
+ `motor/start`,掉线后 10s 退避重试。有线 EtherCAT 与 WiFi 不争网卡。
### ConnectionHub 客户端 API(`deviceLink`)
@@ -66,6 +68,7 @@ Owner 消息:`hello` / `snapshot` / `set-ip` / `ble-state`。
| 位置 | 行为 |
|------|------|
| `tabs.html` | Owner |
+| Owner `bootstrapEcatBus` | 外壳启动即 open+motor/start,不必等电机页切 EtherCAT |
| `connection.js` | 快照新鲜时覆盖 connected 布尔 |
| `app.js` | IP/baseURL 走 `deviceLink`(保留 `#espIp` 输入回退) |
| `can/dbc.js` + `can.js` | setDeviceIp 广播;顶栏灯订阅 hub |
diff --git a/js/common/device-link.js b/js/common/device-link.js
index 983bb37..190f1ac 100644
--- a/js/common/device-link.js
+++ b/js/common/device-link.js
@@ -23,6 +23,9 @@ var CH_NAME = "dcu-device-link";
var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
var ESP32_POLL_MS = 1000;
var ECAT_POLL_MS = 2000;
+var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
+var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
+var LS_ECAT_SPIN = "dcu_ecat_spin";
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
var DEFAULT_ECAT_ADAPTER = "I210";
@@ -104,6 +107,11 @@ function createOwner() {
var esp32Timer = null;
var ecatTimer = null;
var stopped = false;
+ /* 外壳启动即强制拉起总线(open + motor/start),不必等电机页切 EtherCAT。
+ * 成功后不再重复 motor/start;掉线后按 ECAT_BUS_RETRY_MS 退避重试。 */
+ var ecatBusReady = false;
+ var ecatBusBusy = false;
+ var ecatBusNextTryAt = 0;
function publish() {
snap.ts = nowMs();
@@ -155,6 +163,50 @@ function createOwner() {
}
}
+ /**
+ * 开总线:status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
+ * 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
+ * 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
+ */
+ function bootstrapEcatBus() {
+ if (stopped || ecatBusBusy) return;
+ var now = nowMs();
+ if (ecatBusReady) return;
+ if (now < ecatBusNextTryAt) return;
+ ecatBusBusy = true;
+ ecatBusNextTryAt = now + ECAT_BUS_RETRY_MS;
+
+ var adapter = readAdapter();
+ var body = {};
+ try {
+ var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
+ if (v > 0) body.period_ms = v;
+ var sp = localStorage.getItem(LS_ECAT_SPIN);
+ if (sp === "0") body.spin = false;
+ else if (sp === "1") body.spin = true;
+ } catch (_) {}
+
+ ecatPost("status", {}, 6000).then(function (st) {
+ var open = !!(st && st.ok);
+ return open ? Promise.resolve(st)
+ : ecatPost("open", { substr: adapter }, 20000);
+ }).then(function (r) {
+ if (r && r.ok === false) {
+ throw new Error((r.log && r.log.join(" / ")) || "open 失败");
+ }
+ return ecatPost("motor/start", body, 30000);
+ }).then(function (r) {
+ if (r && r.ok === false) {
+ throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败");
+ }
+ ecatBusReady = true;
+ }).catch(function () {
+ /* 网线没插/从站未上电:退避后由 pollEcat 再触发 */
+ }).then(function () {
+ ecatBusBusy = false;
+ });
+ }
+
function pollEcat() {
if (stopped) return;
var stP = ecatPost("status", {}, 4000).catch(function () { return null; });
@@ -164,7 +216,7 @@ function createOwner() {
var st = arr[0], ld = arr[1];
var e = snap.links.ecat;
e.online = !!(st && st.ok && st.slave);
- e.busOpen = e.online;
+ e.busOpen = !!(st && st.ok);
/* native status 不直接给 op/loop;用 load valid + slave state 粗判 */
e.op = !!(st && st.slave && st.slave.state != null && (st.slave.state & 0x0F) === 8);
e.loadValid = !!(ld && ld.ok && ld.valid === true
@@ -172,7 +224,10 @@ function createOwner() {
e.loop = e.loadValid || e.op;
e.loadPerm = (ld && Number.isFinite(ld.load_perm)) ? ld.load_perm : 0;
e.ageMs = (ld && Number.isFinite(ld.age_ms)) ? ld.age_ms : -1;
+ if (e.busOpen && e.loop) ecatBusReady = true;
+ else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
publish();
+ bootstrapEcatBus();
}).catch(function () {
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
}).then(function () {
@@ -208,6 +263,7 @@ function createOwner() {
if (ch) ch.addEventListener("message", onMsg);
pollEsp32();
pollEcat();
+ bootstrapEcatBus();
publish();
return {
diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js
index 77240d1..62ffdc1 100644
--- a/js/ota/ecat_load.js
+++ b/js/ota/ecat_load.js
@@ -41,8 +41,8 @@ function setTitle(node, title) {
}
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
-var TIP_OFF = TIP_BASE + ';当前不可用:需要 ECAT 总线已打开且过程数据循环在跑'
- + '(如打开电机控制页选 EtherCAT 传输),且 DCU_APP 固件 >= 1.5.61。';
+var TIP_OFF = TIP_BASE + ';当前不可用:控制台外壳启动时会自动 open+motor/start;'
+ + '若仍为 --,检查网线/从站供电,或手动打开电机控制页选 EtherCAT。';
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
function render(d) {
diff --git a/tabs.html b/tabs.html
index f5dbee2..bfb840e 100644
--- a/tabs.html
+++ b/tabs.html
@@ -181,6 +181,6 @@
-
+