feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+62 -7
View File
@@ -68,15 +68,63 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
| `pages/motor.html` | 电机控制台(CAN FD,WiFi / EtherCAT 双链路) | `js/motor/main.js`、`js/motor/transport*.js`、`js/motor/tick.worker.js` |
| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI/ECAT 通信测速(标签名「SPI/ECAT 通信测速」;含 EtherCAT 接收看板,完全自包含,删文件即可移除) | 内联 |
## 设备发现与选择(局域网)
顶栏 `🔍 设备`(`tabs.html`)与 OTA 页 IP 行的 `🔍 发现`(`pages/index.html`)打开同一个面板:
扫出局域网里哪几台是这块 ESP32 中继板,点一行就把当前设备端点改成它,六个标签一起跟着切。
- **探测原语在服务侧且无状态**:`POST /api/lan/probe {"ips":[≤64],"ports":[80],"timeout_ms":750,"local":true}`
→ 逐 IP 一条判决 + `scan` 计数 + `local`(本机默认路由地址与派生 /24)。实现 `lan_probe.py`,
不建 job 表、不常驻线程、不落盘;分批/进度/取消/缓存全在 `js/common/lan-picker.js`。
- **判据只有一处**(`lan_probe.classify`),页面不复算。探测固定打 `/system/status?lite=1`
(固件里这条路径 256 B 栈缓冲、不抢 JSON 工作锁、不发 SPI 事务)。三态判决:
`board`(形状对得上,STA 需自报 IP 与探测地址一致)/ `rejected`(**有反证**:非 200、非 JSON、
无 `wifi` 对象、STA 自称在别处)/ `unknown`(连不上、读超时、或形状合但落不进前两类)。
超时**不算**"不是板子",也不算"没找到",单独列出来。
- **两档扫描**:`快扫` 先打种子地址(当前端点、192.168.31.127、192.168.31.1、上次发现到的,≤8 个,实测 ~0.1–1s),
**种子没命中就自动扩到整段**再扫一次——设备换了 DHCP 地址正是快扫最常见的失效场景;扩段后仍没命中才说"本轮没扫到"。
`全段` 直接打默认路由派生的 /24(实测 253 个地址:服务端各批耗时之和 6.0s,页面双路并发墙钟约 3.5s)。
耗时由"不存在的地址等满 750ms 截止"主导,服务侧批内并发 48(跨不同地址,一块板子全程只被占 1 个 socket)。
面板上报的"用时"是页面墙钟,不是各批累加。CIDR 可手填;
非默认路由的网段(比如 EtherCAT 那块 I210)永远不自动扫。
- **不打扰正在跑的东西**:当前已选端点不进候选表(它的活体判决就是 `device-link` 那条 1Hz
`/system/status` 轮询,不再另开 socket——设备 httpd 只有 6 个 socket 且开了 `lru_purge`,
多开会踢掉网页自己的 keep-alive);`flashBusyNow()`(localStorage `dcu_flash_busy_until`,跨 iframe)
为真时既不能发起扫描也不能换端点;离线自动补扫额外要求 ECAT 环没在跑且距上次 ≥60s,
且**只列候选、绝不自动换设备**。
- **端点只有一个写手**:`deviceLink.setIp / selectDevice`,IPv4 校验在 `js/common/base-url.js:saveDeviceIp`
单点做,非法值**拒绝且不改写现值**,各页输入框只是视图。选中时记一份设备指纹(版本 / git_hash),
Owner 之后每次拉 status 都核 `snap.endpointCheck = match|mismatch|unverified`——设备换了、
或这地址被另一块板占了,会被标出来而不是悄悄当成同一台。
- **已知限制(面板里也这么写)**:固件 `/system/status` 不上报 mac/chip_id,两块同款板只能靠 IP 与
`uptime_sec` 区分(认领办法:按板上复位键后快扫,新上电那台 uptime < 30s)。STA 失败回退 AP 的板子
自称 `192.168.31.1`,PC 还连着路由器 WLAN 时它在三层上不可达,扫不到是正确行为,要真人连
`ESP32_OTA_RELAY`。mDNS(README 旧版曾写 `esp32-ota-relay.local`)**没有**编进固件。
- 自测:`python scripts/test_lan_probe.py`(离线,假板绑本机网卡地址跑真 socket,含"起/关服务判决必须翻转"
的灵敏度对照);`python scripts/check_lan_discovery.py --expect 1`(真网段门槛,
用 RFC 5737 `192.0.2.0/28` 做反向对照,前置不满足报 BLOCKED/exit 2 而不算通过)。
## 链路胶囊(WiFi / ECAT / BLE)的灯色与文案
`connection.js:updateLinkPills()` 是**唯一**写这三颗 pill 的 class 与文字的地方,判据全部取自
`device-link` 快照。历史上 ECAT 那颗有三个写手(快照一条、`app.js` 的 0x1008 probe 一条、
重连边沿检测一条),节奏和判据都不同,实测出现过"绿点 + 文字离线"的自相矛盾;现在
`app.js:refreshEcatPillName()` 只把读到的名字公布到 `window.__ecatProbeName`,不再碰 DOM。
ECAT 分级:`!busOpen` → 离线(后端/网卡未开);`busOpen && loop` → 在线(绿,显示从站名);
`busOpen && !loop` → 琥珀并标 AL 档位(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP,见快照
`links.ecat.slaveState`)。注意 `links.ecat.online` 仍是老判据(status 有回且带 slave 对象即真),
只表示"总线侧有应答",不代表能跑——`updateConnectionUi()` 的整体在线判定还在用它,
要收紧得单独定(会影响 OTA 页那张大在线卡)。
## 目录约定
- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
**所有权**:`.btn` 共享 chrome 在 `layout.css`;`components.css` 管跨页 `.toast`
(居中单条);`motor.css` 的 `.btn` / `.toast-container` 是电机页主题覆盖与
堆叠 Toast,**不要**再往 layout/components 抄一份电机专用规则。
- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、
- `js/common/` —— 跨页公用:`base-url`(设备 IP + IPv4 校验单点)、`connection`、`polling`、`log`、
`toast`(居中)、`toast-stack`(容器堆叠,电机页)、`ecat-bus`(ECAT 接入策略单点)、
`device-link`、`utils`、`heap-low-history`。
`device-link`(端点唯一写手 + 全链路 Owner)、`lan-picker`(设备发现面板)、`utils`、`heap-low-history`。
- `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module,跨域引用只走 `js/common/`。
- 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。
@@ -117,12 +165,19 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
改那边要动另一个 git 仓库,把页面搬过去又会让两种链路分裂成两份页面,
所以在 `esp32_server.py` 里同源转发(端口在 8090-8095 间自动探测,见 `_ecat_proxy`)。
- EtherCAT 链路的三处已知差异(都在 `transport-ecat.js` 头注释里写明):
- EtherCAT 链路的已知差异(都在 `transport-ecat.js` 头注释里写明):
1. 每通道 TEC/REC/err 过程数据里没有 → 一律回 `null` 让界面显示 `--`,
不编 0 冒充正常(区分"通信正常但无此数据"与"真的是 0");
2. CONFIG(`0x000N0101`) / 版本(`0x000N00FF`) / 节点 ID 应答不在状态窗口里,
由传输层从批量接收区累积后回填,上层无感;
3. 没有固件上传 + GD32 relay 通路 → `supportsOta=false`,OTA 页签自动置灰。
3. 电机 OTA 的**进料口**不同:WiFi 走 `POST /motor/ota`(ESP32 存 flash,回读算
SHA-256 比对)再经 SPI 0xD1 喂;EtherCAT 走 CoE 邮箱 `0x2511/0x2510`
把明文灌进 GD32 的 48KB 暂存区,**从站在电机进 BOOT 之前**核分块 CRC32 与
整镜像 CRC32(`DCU_APP/comm/ecat_motor_ota.cpp`)。AES 握手、CAN 单发停等、
重发、`0AAC` 运行确认这套引擎两条链路完全同一个,`ota.js` 的流程也只写一遍。
刷写期间 native worker 独占总线并停 PDO 循环(各通道运动帧一起暂停,与 WiFi
模式刷写等效),结束后自动补一次 PREOP→SAFEOP→OP——从站的 SM2 过程数据看门狗
会在这十几秒里以 AL `0x001B` 把 OP 拖回 SAFEOP,不补就是"刷完假在线"。
- EtherCAT 的 PDO 周期由后台循环独立维持(从站 SM2 有 **1000ms 过程数据看门狗**,
停超 1 秒会 OP→SAFEOP 掉线报 AL `0x001B`),与界面刷新率无关;
故切走链路时**不停**后台循环。
@@ -308,8 +363,8 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
把真实的 99.2% 报成 96%,一度被误读成"HTTP 轮询负载吃掉 3 个点"。修复后读数与
cadence_diag 的拍间时间线(不依赖 cycles 轮询)互相印证。教训:**两条独立测量
路径对不上时,先怀疑测量本身,再怀疑系统**;带载/空载的区分要用拍间时间线做基准。
- 前端:`motorDbg.state.tp.setPdoPeriod(0.25)`(恢复默认 `setPdoPeriod(4)`)。
取值存 localStorage `dcu_ecat_period_ms`,后端重启后由
- 前端:`motorDbg.state.tp.setPdoPeriod(0.5)`(合法档 0.125–4ms,DC 与 period 同值;
默认 `setPdoPeriod(0.25)`)。取值存 localStorage `dcu_ecat_period_ms`,后端重启后由
`ensureStarted` 自动带回;状态栏 <1ms 显示 µs(`PDO 250µs`,与后端
`_fmt_period` 同口径),且每拍从快照同步周期 —— 别的标签页或脚本改的
不会显示成过期值。
@@ -414,7 +469,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
`ensureStarted` 自动带回、换回 4ms 自旋标注消失)。
`ensureStarted` 自动带回、`setPdoPeriod(4)` 可热改到 4ms 档)。
- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
以便分辨「正常空闲」和「真实通信错误」。
- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
+106
View File
@@ -133,3 +133,109 @@ body::after {
outline-offset: 2px;
border-radius: 4px;
}
/* ============================================================
局域网设备发现(js/common/lan-picker.js)
这里只放弹窗本身和它特有的排布。按钮 / 表单 / 输入框 / 列表行
一律复用 layout.css 的既有类(.btn .btn-outline .btn-sm .input
.input-group .form-label .jump-history-item .jh-tag .jh-msg .jh-time
与 .ok/.err/.info 状态色),不再另造一套控件外观。
============================================================ */
.lan-slot { display: flex; align-items: center; gap: 8px; padding: 0 6px; }
/* 遮罩:居中弹出 + 周围高斯模糊(backdrop-filter 作用于遮罩下方的整个页面) */
.lan-mask {
position: fixed; inset: 0; z-index: 900;
display: none; align-items: center; justify-content: center;
padding: 24px;
background: rgba(4, 8, 14, .62);
-webkit-backdrop-filter: blur(9px) saturate(.85);
backdrop-filter: blur(9px) saturate(.85);
animation: lanFade .16s ease;
}
.lan-mask.show { display: flex; }
@keyframes lanFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lanPop {
from { opacity: 0; transform: translateY(10px) scale(.985); }
to { opacity: 1; transform: none; }
}
/* 盒子沿用 .card 的渐变底与顶部子系统色带,描边/阴影/圆角同 .modal-box */
.lan-box {
position: relative;
width: min(760px, 100%); max-height: min(82vh, 780px);
overflow: auto;
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.96) 100%);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0,0,0,.6);
animation: lanPop .18s ease;
}
.lan-box::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, var(--esp32) 0%, var(--ble) 55%, var(--gd32) 100%);
opacity: .8;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.lan-head {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 14px 18px; border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display); font-size: 13px; font-weight: 600;
letter-spacing: .05em; color: var(--text);
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.96) 100%);
}
.lan-ep {
font-family: var(--font-mono); font-size: 11px; font-weight: 400;
letter-spacing: 0; color: var(--text-dim);
}
.lan-head .btn { margin-left: auto; }
.lan-body { padding: 14px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.lan-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lan-field { display: flex; align-items: center; gap: 8px; }
.lan-field .form-label { margin-bottom: 0; }
.lan-field .input { width: 172px; padding: 7px 10px; font-size: 12px; }
.lan-check {
display: flex; align-items: center; gap: 6px; cursor: pointer;
font-family: var(--font-display); font-size: 11px;
letter-spacing: .08em; text-transform: uppercase; color: var(--text-label);
}
.lan-progress { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-label); }
.lan-progress.lan-busy { color: var(--esp32); }
/* 列表行 = .jump-history-item 既有排布,但改成网格:
没有操作按钮的"当前端点"行和有按钮的行才能按同一套列宽对齐
(flex 布局下两行的右侧列会各自漂移,就是"对不齐"的来源)。 */
.lan-list {
list-style: none; margin: 0; padding: 0; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-card);
}
.lan-list .jump-history-item {
display: grid; align-items: center; gap: 8px;
grid-template-columns: 46px minmax(0, 1fr) auto 52px;
padding: 6px 12px; cursor: pointer;
}
.lan-list .jump-history-item > .jh-msg { min-width: 0; }
.lan-list .jump-history-item > .jh-time { text-align: right; }
.lan-list .jump-history-item > .lan-sub { grid-column: 1 / -1; }
.lan-list .jump-history-item:hover { background: var(--bg-hover); }
.lan-list .jump-history-item.lan-cur { background: var(--esp32-dim); }
.lan-list .jump-history-item.lan-stale { opacity: .55; }
.lan-ip { color: var(--text); font-weight: 700; }
.lan-meta { color: var(--text-dim); font-weight: 400; }
.lan-acts { display: flex; gap: 4px; flex-shrink: 0; }
.lan-sub { flex-basis: 100%; font-size: 11px; color: var(--text-label); }
.lan-empty, .lan-cut { padding: 12px; font-size: 12px; color: var(--text-dim); text-align: center; }
.lan-note { min-height: 1.2em; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-dim); }
.lan-note-warn { color: var(--amber); }
.lan-note-error { color: var(--red); }
.lan-misc {
display: flex; flex-direction: column; gap: 8px;
border-top: 1px solid var(--border-subtle); padding-top: 12px;
}
.lan-block { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-label); }
.lan-block b { color: var(--text-dim); font-weight: 600; }
.lan-block ul { margin: 4px 0 0; padding-left: 18px; }
.lan-hint { color: var(--text-dim); }
+13
View File
@@ -199,6 +199,19 @@ body.can-page {
z-index: 5;
backdrop-filter: blur(14px);
background: rgba(15,22,31,.86);
gap: 12px;
padding: 12px 16px;
}
.can-header .header-status {
gap: 6px;
}
.can-header .header-status .pill {
padding: 5px 10px;
font-size: 11px;
}
.can-header .header-status .link-pill {
padding: 5px 11px 5px 8px;
gap: 6px;
}
/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0),
* 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */
+52 -1
View File
@@ -119,6 +119,27 @@
overflow: hidden;
transition: border-color .22s, transform .22s, box-shadow .22s;
}
/* ---------- 数据来源角标(WiFi / ECAT / BLE) ----------
右上角小胶囊:标出本格当前数据从哪条链路来。
为双路径无感切换预留:通路变化时 JS 调 setCellSource() 改 data-src + 文案。 */
.src-badge {
position: absolute; top: 5px; right: 5px;
font-family: var(--font-mono); font-size: 9px; font-weight: 600;
letter-spacing: .06em; line-height: 1;
padding: 3px 5px; border-radius: 4px;
border: 1px solid transparent;
opacity: .88; pointer-events: none; user-select: none;
z-index: 2;
}
.src-badge.src-wifi { color: var(--esp32); background: var(--esp32-dim); border-color: rgba(47,216,193,.25); }
.src-badge.src-ecat { color: var(--mem-hi); background: var(--mem-dim); border-color: rgba(168,139,250,.28); }
.src-badge.src-ble { color: var(--ble); background: rgba(95,168,255,.12); border-color: rgba(95,168,255,.25); }
.src-badge.src-dual {
color: var(--text); background: linear-gradient(90deg, var(--esp32-dim), var(--mem-dim));
border-color: rgba(47,216,193,.35);
}
.src-badge.src-off { color: var(--text-label); background: rgba(255,255,255,.04); border-color: var(--border); }
.metric::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--metric-accent, var(--esp32));
@@ -169,8 +190,10 @@
/* ================= 主状态徽章 ================= */
.badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.badge-stat {
position: relative; /* src-badge 绝对定位锚点(缺了会飘到卡片/页面角) */
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 16px 13px; text-align: center;
border-radius: var(--radius); padding: 22px 16px 13px; text-align: center;
overflow: hidden;
transition: border-color .22s, transform .22s, box-shadow .22s;
}
.badge-stat:hover {
@@ -718,6 +741,34 @@ code { color: var(--esp32-hi); font-family: var(--font-mono); }
.ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; }
.ble-low-table td:first-child { color: var(--text-dim); }
/* ================= ESP32 WiFi 使能开关(PC9 / WLESS_EN) ================= */
.wifi-en-inline {
margin-left: auto;
display: flex; align-items: center; gap: 8px;
position: relative; z-index: 2;
}
.wifi-en-inline-label {
font-size: 12px; font-weight: 600; color: var(--text-dim); white-space: nowrap;
}
.wifi-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
.wifi-switch--sm { width: 36px; height: 20px; }
.wifi-switch input { opacity: 0; width: 0; height: 0; }
.wifi-slider {
position: absolute; inset: 0; cursor: pointer; border-radius: 999px;
background: #3a4a63; transition: background .18s ease;
}
.wifi-slider::before {
content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px;
border-radius: 50%; background: #fff; transition: transform .18s ease;
}
.wifi-switch--sm .wifi-slider::before {
width: 14px; height: 14px; left: 3px; top: 3px;
}
.wifi-switch input:checked + .wifi-slider { background: var(--green); }
.wifi-switch input:checked + .wifi-slider::before { transform: translateX(20px); }
.wifi-switch--sm input:checked + .wifi-slider::before { transform: translateX(16px); }
.wifi-switch input:focus-visible + .wifi-slider { outline: 2px solid var(--esp32); outline-offset: 2px; }
/* ================= BLE 连接按钮(launch 风格) =================
圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。
checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */
+21
View File
@@ -103,6 +103,27 @@
font-family: var(--font-mono);
}
/* ---- 顶栏三链路胶囊(WiFi / ECAT / BLE) ---- */
.link-pill { gap: 6px; padding: 5px 10px 5px 8px; }
.link-pill-tag {
font-size: 10px; font-weight: 700; letter-spacing: .04em;
padding: 1px 5px; border-radius: 4px;
color: var(--esp32); background: var(--esp32-dim);
}
.link-pill-tag.tag-ecat { color: var(--mem-hi); background: var(--mem-dim); }
.link-pill-tag.tag-ble { color: var(--ble); background: rgba(95,168,255,.12); }
.link-pill #pillWifiTxt,
.link-pill #pillEcatTxt,
.link-pill #pillBleTxt,
.link-pill #canNetTxt {
font-size: 11px; overflow: hidden; text-overflow: ellipsis;
}
/* WiFi 要带 IP+信号;ECAT/BLE 连接后显示设备名 */
.link-pill #pillWifiTxt,
.link-pill #canNetTxt { max-width: 22em; }
.link-pill #pillEcatTxt { max-width: 14em; }
.link-pill #pillBleTxt { max-width: 18em; }
/* ================= 主布局 ================= */
.main-layout {
display: grid;
+58 -2
View File
@@ -69,6 +69,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# EtherCAT 客户端 / 进程治理模块(R8)
import ecat_client
import ecat_proc
import lan_probe
ecat_proc.init(BASE_DIR)
@@ -495,6 +496,11 @@ class Handler(SimpleHTTPRequestHandler):
p, rel = UI_STATE_ROUTES[path_l]
self._handle_ui_state_post(p, rel)
return
if path_l == "/api/lan/probe":
# 局域网设备发现: 无状态, 一次请求探一批(≤24), 探完就回。
# 编排(候选表/分批/进度/取消/缓存)全在页面 js/common/lan-picker.js。
self._handle_lan_probe()
return
if path.startswith(ECAT_PROXY_PREFIX):
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 ecat_client)。
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
@@ -504,9 +510,14 @@ class Handler(SimpleHTTPRequestHandler):
except ValueError:
length = 0
raw = self.rfile.read(length) if length > 0 else b"{}"
# /api/open 正常只要 ~0.1s(实测 107ms),但刷写 commit 后那一拍不算正常:
# 新 APP 起板看到 warm 残值会自己补一发 LAN9252 RST#(esc_lan9252.cpp:441),
# 芯片+PHY 整颗复位 → I210 链路重协商 1~2s,open 的广播/statecheck 全撞上
# 去,实测能跨过 6s 档 → 代理假超时 → 非幂等只回 503(2026-10-09 21:49 现场)。
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0
"/eeprom/read", "/eeprom/verify",
"/open")) else 6.0
# EtherCAT 直灌 BOOT: run 要走 519 个邮箱帧 + 整区预擦, 几十秒量级。
# 默认 6s 会在"正在正常刷写"的过程中假失败 —— 浏览器报错离场, native
# 却还在往 flash 里写, 比不刷更糟。同一路由下的 open/push 都是纯内存
@@ -637,6 +648,51 @@ class Handler(SimpleHTTPRequestHandler):
return
self._post_json(json.dumps(result, ensure_ascii=False).encode("utf-8"))
def _handle_lan_probe(self):
"""POST /api/lan/probe — 局域网设备探测原语(一批 ≤24 个 IP,探完就回)。
刻意不建 job 表 / 不常驻线程 / 不落缓存:编排全在页面
(js/common/lan-picker.js),这里由每连接一线程的 ThreadingHTTPServer
自然限流。页面中途关标签最坏是让本线程跑满这一批的截止时长。
"""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
raw = self.rfile.read(min(length, 65536)) if length > 0 else b""
if length == 0 or length > 65536:
self._post_json(json.dumps({"ok": False, "errors": ["bad-body"]},
ensure_ascii=False).encode("utf-8"), 400)
return
try:
obj = json.loads(raw.decode("utf-8", "replace"))
except ValueError:
obj = {}
if not isinstance(obj, dict):
obj = {}
ips = obj.get("ips") if isinstance(obj.get("ips"), list) else []
ports = obj.get("ports") if isinstance(obj.get("ports"), list) else None
if not ports:
ports = [80]
try:
tmo = int(obj.get("timeout_ms") or 750)
except (TypeError, ValueError):
tmo = 750
try:
out = lan_probe.probe_batch(
ips, ports=ports, path=obj.get("path") or lan_probe.PROBE_PATH,
timeout_ms=tmo)
except Exception as e: # noqa: BLE001
out = {"ok": False, "results": [], "scan": {"candidates": len(ips)},
"errors": ["%s: %s" % (type(e).__name__, e)]}
if obj.get("local"):
# 同一次往返带回"本机在哪段",页面才说得出扫的是哪段、依据是什么。
try:
out["local"] = lan_probe.local_net()
except Exception as e: # noqa: BLE001
out["local"] = {"ok": False, "err": type(e).__name__}
self._post_json(json.dumps(out, ensure_ascii=False).encode("utf-8"))
def _handle_ui_state_post(self, path, rel):
"""配置快照 POST(R11:motor/lab 共用,表驱动)。"""
self._write_ui_state_route(path, rel)
@@ -735,7 +791,7 @@ def _run_browser(args):
url = f"http://127.0.0.1:{port}/tabs.html"
print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口
print(f"ESP32 调试控制台: {url} (Ctrl+C 退出)")
print("页面里的设备 IP 决定 API 目标(默认 192.168.31.127), 本服务只提供页面")
print("设备端点由页面决定(顶栏『发现』选择或 IP 输入框手填); 本服务另有 /api/lan/probe 探测原语")
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
+127 -24
View File
@@ -12,7 +12,8 @@ import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import {
updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi,
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl,
updateLinkPills, setWifiPillOverride
} from './common/connection.js';
import {
scheduleStatusPoll, startPolling, pausePollingForUpload,
@@ -35,6 +36,7 @@ import {
} from './ota/stages.js?v=20260916d';
import { startEcatLoadPoller } from './ota/ecat_load.js';
import { deviceLink } from './common/device-link.js';
import { isValidIpv4 } from './common/base-url.js';
import {
handleFileSelect, pickFileViaFSAPI, setupDropZone,
parseIntelHex, isIntelHexText, convertHexFileToBin,
@@ -47,7 +49,7 @@ import {
} from './ota/upload-gd32.js';
import {
bootEcatProbe, bootEcatFlash, bootEcatAbort, bootEcatPick, bootEcatReboot, refreshBootEcatUi
} from './ota/boot_ecat.js';
} from './ota/boot_ecat.js?v=20261013b';
// ==================== 工具函数 ====================
function formatPercent(value) {
@@ -55,10 +57,13 @@ function formatPercent(value) {
return value.toFixed(1).replace(/\.0$/, '') + '%';
}
/** 设备 IP:优先本页 #espIp 输入框,否则 ConnectionHub。
* 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。 */
* 只在值变化时 setIp——Owner 会把 set-ip 当成立即重探,读路径每次写会 1Hz 风暴。
* 输入框不是合法 IPv4 时**不参与拼 URL**:否则本页打到脏地址、Owner 与其余
* 标签还打在旧端点上,会出现"灯是绿的、上传是失败的"这种分裂状态。 */
function getEspIp() {
var el = document.getElementById('espIp');
var ip = el && el.value ? el.value.trim() : '';
if (ip && !isValidIpv4(ip)) return deviceLink.getIp();
if (!ip) return deviceLink.getIp();
if (ip !== deviceLink.getIp()) {
try { deviceLink.setIp(ip); } catch (_) {}
@@ -95,8 +100,10 @@ function resetStageFlow() {
if (statusText) statusText.textContent = '待机';
}
function applyDisconnectedRuntimeState() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
var elIp = document.getElementById('pillIP');
if (elIp) elIp.textContent = '--';
var elRssi = document.getElementById('pillRSSI');
if (elRssi) elRssi.textContent = '--';
// 断线约 1s(本轮轮询失败)即清实时指标,防止残留旧温度/电压误导
clearLiveMetricsOnDisconnect();
// ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度
@@ -107,13 +114,8 @@ function applyDisconnectedRuntimeState() {
resetStageFlow();
}
function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) {
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
}
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
setWifiPillOverride('重启中 ' + elapsed + 's');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '重启中';
if (bc) bc.className = 'v warn';
@@ -125,20 +127,14 @@ function applyUploadingRuntimeState() {
otaSessionState === OtaSessionState.DONE) {
return;
}
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '上传中';
setWifiPillOverride('上传中');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn';
}
/** 设备忙:只更新顶栏连接胶囊 + 系统状态徽章,不写下方重复文案。 */
/** 设备忙:覆盖 WiFi 胶囊文案 + 系统状态徽章。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙';
setWifiPillOverride(pillText || '设备忙');
var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn';
@@ -207,6 +203,8 @@ window.clearLogs = clearLogs;
window.setLogFilter = setLogFilter;
window.toggleAutoScroll = toggleAutoScroll;
window.updateConnectionUi = updateConnectionUi;
window.updateLinkPills = updateLinkPills;
window.setWifiPillOverride = setWifiPillOverride;
window.updateHealthUi = updateHealthUi;
window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection;
@@ -257,6 +255,46 @@ document.addEventListener('click', function (ev) {
}
});
// ==================== ESP32 WiFi 使能(PC9 → ESP32 EN) ====================
// 走 EtherCAT SDO 0x2503(/ecat/api/esp32/en),与 WiFi 解耦。
// action:0=拉高(开) 1=拉低保持(关)
function sendEsp32En(action) {
return fetch('/ecat/api/esp32/en', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action }),
signal: AbortSignal.timeout(8000)
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok || j.ok === false) throw new Error((j.log && j.log[0]) || j.err || ('HTTP ' + r.status));
return typeof j.status === 'number' ? j.status : -1;
});
});
}
function setWifiEnChecked(on) {
var sw = document.getElementById('esp32WifiSw');
if (sw) sw.checked = !!on;
}
function applyEsp32WifiSwitch(on) {
sendEsp32En(on ? 0 : 1).then(function () {
setWifiEnChecked(on);
showToast(on ? 'ESP32 WiFi 已开启' : 'ESP32 WiFi 已关断', on ? 'success' : 'warning');
}).catch(function (e) {
setWifiEnChecked(!on);
showToast('EtherCAT 下发失败: ' + e.message, 'error');
});
}
(function bindWifiEnSwitch() {
var sw = document.getElementById('esp32WifiSw');
if (!sw) return;
sw.addEventListener('change', function () {
applyEsp32WifiSwitch(sw.checked);
});
})();
// 共享固件文件选择(原内联 onchange="handleSharedFileSelect(event, chip)")
['esp32', 'gd32'].forEach(function (chip) {
var input = document.getElementById(chip === 'esp32' ? 'fileInputEsp32' : 'fileInputGd32');
@@ -298,8 +336,11 @@ startPolling();
* 外壳 Owner 是更权威的「设备在线」来源,离线布尔一翻就清。 */
deviceLink.subscribe(function (s) {
try {
if (!s || !s.ts || !s.links || !s.links.esp32) return;
if (!s || !s.ts || !s.links) return;
if ((Date.now() - s.ts) >= 5000) return;
/* 顶栏三链路:快照一到就刷 WiFi/ECAT 灯(BLE 另走 __bleConnState) */
if (window.updateLinkPills) window.updateLinkPills(null, null);
if (!s.links.esp32) return;
if (s.links.esp32.online) return;
clearLiveMetricsOnDisconnect();
} catch (_) {}
@@ -308,6 +349,50 @@ deviceLink.subscribe(function (s) {
// GD32 CPU 负载率(EtherCAT 通道,独立于 /system/status 轮询;页面上无该指标则自动空转)
startEcatLoadPoller();
/* ── ECAT 胶囊:0x1008 设备名只在「断开→重连」时读一次,平时不轮询 ──
* 路径:/ecat/api/ota/ecat probe 的 name(对象字典 0x1008,如 GD32E508 DCU)
* 注意:status.slave.name 是 SOEM SII 名(DCU Project),不是这里要的。 */
function refreshEcatPillName() {
/* 只负责取从站名字(0x1008)并公布到 window.__ecatProbeName;
灯色与文案由 connection.js 的 updateLinkPills 单点出。这里以前连 className 一起写,
和快照那条路节奏不同,实测出现过"绿点 + 文字离线"的自相矛盾。 */
fetch('/ecat/api/ota/ecat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'probe' }),
cache: 'no-store',
signal: AbortSignal.timeout(5000),
}).then(function (r) { return r.json(); }).then(function (st) {
/* probe 对 APP 返回 ok:false,name 仍是 0x1008 读数 */
window.__ecatProbeName = (st && st.name) ? String(st.name).trim() : '';
updateLinkPills();
}).catch(function () {
window.__ecatProbeName = '';
updateLinkPills();
});
}
(function bindEcatNameOnReconnect() {
var lastOnline = null;
deviceLink.subscribe(function (s) {
var on = !!(s && s.links && s.links.ecat && s.links.ecat.busOpen);
if (!on) {
/* 总线没开:名字作废,灯色/文案交给 updateLinkPills 按快照出,不在这里另写一套 */
if (lastOnline !== false) {
lastOnline = false;
window.__ecatProbeName = '';
updateLinkPills();
}
return;
}
/* 首次在线 或 断开后重连 → 读一次 0x1008;保持在线则不再读 */
if (lastOnline !== true) {
lastOnline = true;
refreshEcatPillName();
}
});
})();
// IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp');
if (ipInput) {
@@ -316,13 +401,31 @@ if (ipInput) {
if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim();
} catch (_) {}
ipInput.addEventListener('change', function () {
try { deviceLink.setIp(ipInput.value.trim() || '192.168.31.127'); } catch (_) {}
var r = null;
try { r = deviceLink.setIp(ipInput.value.trim()); } catch (_) { r = { ok: false, reason: '写入异常' }; }
if (!r || !r.ok) {
/* 拒绝要说得出原因,也不把输入框停在脏值上:静默收下脏 IP 会让六个标签
打到旧端点、输入框显示新值,"看起来连着"就是假绿灯。 */
var why = (r && r.reason) || '未接受';
addLog('E', '系统', '端点未改:' + why);
showToast('端点未改:' + why, 'error', { force: true });
var eff = deviceLink.getIp();
if (eff && ipInput.value.trim() !== eff) ipInput.value = eff;
return;
}
window.consecutivePollFailures = 0;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
addLog('I', '系统', 'IP 已修改 → ' + r.ip + ',重新启动轮询...');
if (!window.statusPollInFlight) {
scheduleStatusPoll(0);
}
});
/* 端点被别处改了(发现面板选中、另一页手输)要回流到本输入框,
否则六个标签共用新端点、这一页的框还显示旧值。用户正在敲时不覆盖。 */
deviceLink.subscribe(function (s) {
if (!s || !s.ip) return;
if (document.activeElement === ipInput) return;
if (ipInput.value.trim() !== s.ip) ipInput.value = s.ip;
});
}
// 初始化按钮状态与提示
+8 -2
View File
@@ -84,8 +84,14 @@ export async function connectBle() {
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
window.__bleDeviceName = device.name || '未知设备';
window.__bleDeviceId = device.id || '-';
/* 顶栏 BLE 胶囊:连接后直接显示「设备名 · 设备ID」(替代未连接) */
try {
var pct = document.getElementById('pillBleTxt');
if (pct) pct.textContent = window.__bleDeviceName + ' · ' + window.__bleDeviceId;
if (window.updateLinkPills) window.updateLinkPills(null, true);
} catch (_) {}
}
setBleStage(1, '已选择', null);
+21 -7
View File
@@ -110,12 +110,24 @@ export function setReadyHint(html) {
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
window.__bleConnState = !!connected;
// 顶栏 BLE 胶囊:连接后显示「设备名 · 设备ID」,未连接显示「未连接」
var pc = document.getElementById('pillBle');
var pct = document.getElementById('pillBleTxt');
if (pc) pc.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
if (pct) {
if (connected) {
var nm = window.__bleDeviceName || '已连接';
var id = window.__bleDeviceId;
pct.textContent = id ? (nm + ' · ' + id) : nm;
} else {
pct.textContent = '未连接';
}
}
try {
if (window.updateLinkPills) window.updateLinkPills(null, !!connected);
} catch (_) {}
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
@@ -141,6 +153,8 @@ export function setConnectionState(connected) {
els.abortBtn.disabled = !state.uploading;
if (!connected) {
window.__bleDeviceName = '';
window.__bleDeviceId = '';
setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。');
} else if (!state.notificationsEnabled) {
setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。');
@@ -164,8 +178,8 @@ export function resetUiAfterDisconnect() {
state.lastUploadStatus = 'idle';
state.deviceSha256 = null;
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
window.__bleDeviceName = '';
window.__bleDeviceId = '';
if (els.versionText) els.versionText.textContent = '未读取';
var pv = document.getElementById('pillVer');
if (pv) pv.textContent = 'v--';
+24 -16
View File
@@ -3,7 +3,7 @@
*
* CAN 页面的编排逻辑:
* - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象)
* - setConnectionState:顶栏 pills(连接/IP/RSSI/运行/版本)
* - setConnectionState:顶栏 pills(网络状态合并 IP/RSSI、运行/版本)
* - testConnection:GET /system/status,静默刷新顶栏(5s)
* - clearDbc / handleDbcFile / handleDbcError:DBC 文件读取与解析流程编排
* - bindEvents:绑定所有事件监听器(调用子模块的 bindNodeFilterEvents 等)
@@ -19,7 +19,7 @@
/* JS 间 import 一律不带 ?v=,版本戳只出现在 HTML 入口标签上,
* 避免同一模块因 query 不同被浏览器实例化两次(state/toast 单例分裂) */
import {
state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary,
state, el, getBaseUrl, getDeviceIp, parseDbc, readDbcText, buildSummary,
rebuildMergedDbc, makeDbcSourceId
} from './can/dbc.js';
import {
@@ -44,7 +44,7 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js?v=20260801f';
} from './can/send.js?v=20261010a';
import {
scheduleCanPersist,
loadCanSession,
@@ -71,7 +71,7 @@ function cacheElements() {
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
'toastMsg', 'canConnPill', 'canConnTxt', 'canIpPill', 'canRssiPill', 'canUptimePill',
'toastMsg', 'canNetPill', 'canNetTxt', 'canUptimePill',
'canVersionPill', 'canChannelPill',
'dbcSummaryPill', 'messageSummaryPill',
'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn',
@@ -84,19 +84,19 @@ function cacheElements() {
// ==================== 连接状态(仅顶栏 pills,与工作台去重) ====================
function setConnectionState(connected, text, buildVersion, buildTime, wifi, uptimeSec) {
if (el.canConnPill) {
el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline');
}
if (el.canConnTxt) el.canConnTxt.textContent = text;
/* STA/IP/RSSI 合并成一个网络胶囊,减少顶栏碎片(与 OTA 页 link-pill 文案一致) */
wifi = wifi || {};
if (el.canIpPill) {
var label = text || (connected ? '已连接' : '未连接');
if (connected) {
var ip = wifi.ip || getDeviceIp();
el.canIpPill.textContent = 'IP ' + (ip || '--');
}
if (el.canRssiPill) {
if (ip) label += ' · ' + ip;
var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127;
el.canRssiPill.textContent = rssiOk ? ('RSSI ' + wifi.rssi + ' dBm') : 'RSSI --';
if (rssiOk) label += ' · ' + wifi.rssi + ' dBm';
}
if (el.canNetPill) {
el.canNetPill.className = 'pill link-pill ' + (connected ? 'online' : 'offline');
}
if (el.canNetTxt) el.canNetTxt.textContent = label;
if (el.canUptimePill) {
el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null
? formatUptime(uptimeSec) : '--');
@@ -122,7 +122,15 @@ async function testConnection() {
var data = await resp.json();
state.latestStatus = data;
var wifi = data.wifi || {};
if (wifi.ip) setDeviceIp(wifi.ip);
/* 不再用设备自报 IP 回写共享端点:AP 回退或 STA 掉租约时固件上报的是
* 初值 "0.0.0.0"(ESP32_COMM/main/server/http_handlers.cpp:1776),
* 回写会让 http://0.0.0.0(=回环)成为六个标签一起用的端点。
* 这里只提示不符,换不换由人在发现面板/输入框点。 */
var curIp = deviceLink.getIp();
if (wifi.ip && curIp && String(wifi.ip) !== String(curIp)) {
showToast('设备自报 IP=' + wifi.ip + ',与当前端点 ' + curIp + ' 不符(AP 回退或已换址)',
'warning', { force: true });
}
var connected = !!wifi.connected;
var mode = wifi.mode || 'STA';
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
@@ -412,13 +420,13 @@ function init() {
}
setTimeout(testConnection, 150);
/* 周期性刷新顶栏 STA/IP/RSSI/运行时间(与监控 1Hz 解耦,避免重复 pill) */
/* 周期性刷新顶栏网络状态/运行时间(与监控 1Hz 解耦,避免重复 pill) */
setInterval(testConnection, 5000);
/* ConnectionHub 快照只覆盖在线布尔,不改 testConnection 写的语义文案(STA/HTTP…) */
deviceLink.subscribe(function (s) {
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
if (el.canConnPill) el.canConnPill.className = 'pill ' + (on ? 'online' : 'offline');
if (el.canNetPill) el.canNetPill.className = 'pill link-pill ' + (on ? 'online' : 'offline');
});
/* 右侧抽屉:CAN 接收监控 */
initCanMonitor();
+1 -11
View File
@@ -7,9 +7,8 @@
* 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。
*/
import {
DEFAULT_ESP_IP, getSavedDeviceIp, saveDeviceIp
DEFAULT_ESP_IP, getSavedDeviceIp
} from '../common/base-url.js';
import { deviceLink } from '../common/device-link.js';
// ==================== 共享状态 ====================
export var state = {
@@ -47,15 +46,6 @@ export function getDeviceIp() {
return DEFAULT_ESP_IP;
}
/** @description 记住设备 IP(status 回报或其它页写入),并广播给 ConnectionHub。 */
export function setDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
var v = String(ip).trim();
saveDeviceIp(v);
try { deviceLink.setIp(v); } catch (_) {}
if (el.toolIp) el.toolIp.value = v;
}
/** @description 拼接设备基础 URL(http://<ip>)。 */
export function getBaseUrl() {
return 'http://' + getDeviceIp();
+2
View File
@@ -82,6 +82,8 @@ var GD32_ERR_DESC = {
0xE3: 'TEC 过高(错误计数 >96,对端 ACK 失败率高)',
0xE4: '邮箱满(3 个硬件邮箱全 PENDING,对端不 ACK)',
0xE5: '参数无效(通道未启用/DLC 越界)',
0xE6: '只听不发(该通道收发器已 Silent,S 脚置高;接收正常,可在 CAN 监控页切回发送)',
0xE7: '该通道正被电机 OTA 独占(刷写结束自动恢复;重试无用,不是总线故障)',
0xEE: 'SPI 帧解析失败(GD32 收到的帧 CRC/SOF 校验失败,通常是 SPI 字节丢失或 CS 时序冲突)'
};
+151 -17
View File
@@ -2,11 +2,11 @@
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js';
import { escapeHtml, flashBusyNow } from './common/utils.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, fbInFlight: false };
var state = { fbTimer: null, statusTimer: null, fbInFlight: false };
var el = {};
@@ -35,8 +35,9 @@ function copyFbVal(text, target) {
}
var ids = [
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
'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',
@@ -45,6 +46,7 @@ var ids = [
'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 */
@@ -65,22 +67,80 @@ function getBaseUrl() {
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';
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
if (el.monConnTxt) el.monConnTxt.textContent = text;
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err');
if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text;
/* 轮询状态即监控连通性(与顶栏 canConn 去重时仍更新 fbPollStatus) */
if (!el.monConnPill && el.fbPollStatus && !ok) {
/* 轮询状态即监控连通性(顶栏网络胶囊由 /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,避免并发请求叠加
@@ -148,6 +208,53 @@ async function pollFeedback() {
}
}
/** 各通道最近一次 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']) {
@@ -171,17 +278,22 @@ function renderChannel(i, ch) {
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('SLEEP');
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');
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常');
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16)) ? ' 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'; }
@@ -212,6 +324,8 @@ function renderChannelEmpty(i) {
});
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) */
@@ -226,6 +340,8 @@ function renderChannelDisabled(i) {
});
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) {
@@ -303,21 +419,31 @@ export function initCanMonitor() {
clearInterval(state.fbTimer);
state.fbTimer = null;
}
if (state.statusTimer) {
clearInterval(state.statusTimer);
state.statusTimer = null;
}
setConnState(false, '未连接');
if (!state.bound) {
state.bound = true;
/* ConnectionHub:只覆盖独立页 monConn 灯的布尔;
* 嵌入 can.html 时无 monConnPill,不得碰 fbPollStatus(那是 /can/feedback 自己的轮询态)。 */
/* ConnectionHub:只覆盖独立页网络胶囊灯色;
* 嵌入 can.html 时不得碰顶栏与 fbPollStatus(那是 can.js / /can/feedback 自己的态)。 */
deviceLink.subscribe(function (s) {
if (!el.monConnPill) return;
if (!isStandalone()) return;
if (!s || !s.ts || (Date.now() - s.ts) >= 5000) return;
var on = !!(s.links && s.links.esp32 && s.links.esp32.online);
el.monConnPill.className = 'pill ' + (on ? 'online' : 'offline');
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', testConnection);
if (el.monIp) el.monIp.addEventListener('change', testConnection);
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 () {
@@ -327,6 +453,9 @@ export function initCanMonitor() {
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 容器上事件代理 */
@@ -352,6 +481,11 @@ export function initCanMonitor() {
});
}
setTimeout(testConnection, 150);
/* 顶栏系统状态 5s 与 /can/feedback 1Hz 解耦;仅独立页维护 */
if (isStandalone()) {
setTimeout(refreshSystemStatus, 150);
state.statusTimer = setInterval(refreshSystemStatus, 5000);
}
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
+87 -3
View File
@@ -4,9 +4,41 @@
* 各页面共用 localStorage key dcu_esp_ip(OTA / CAN / 电机页一致)。
* 本模块只提供纯 localStorage + 默认值的通用实现;页面特有的输入框回退
* (如 CAN 页 el.toolIp、OTA 页 #espIp)由各自入口在此基础上包装。
*
* 写入一律经 saveDeviceIp 的 IPv4 校验:这是"哪个端点算当前设备"的唯一落盘点,
* 脏值在这里被拒掉,比在每个调用方各判一次可靠(固件 AP/掉租约时会上报
* ip:"0.0.0.0",见 ESP32_COMM/main/server/http_handlers.cpp:1776)。
*/
export var LS_ESP_IP = 'dcu_esp_ip';
export var DEFAULT_ESP_IP = '192.168.31.127';
/** @description 最近一次"选中"的设备快照(ip + 版本指纹),供端点一致性核对。 */
export var LS_ESP_DEVICE = 'dcu_esp_device';
/** @description 局域网发现结果缓存(带 observedAt),只给 UI 当候选种子用。 */
export var LS_LAN_DEVICES = 'dcu_lan_devices';
/**
* @description 严格点分十进制 IPv4,并拒掉不能当 API 目标的保留段。
* 0/8(含固件脏值 0.0.0.0)与 255.255.255.255 直接拒;169.254/16 是链路本地,
* 板子不会在那儿服务 HTTP,也拒——宁可让人重填,也不要网页打到一个
* 永远不通的地址上还显示"已连接"。
* @param {string} s 待校验字符串
* @returns {boolean}
*/
export function isValidIpv4(s) {
var parts = String(s == null ? '' : s).trim().split('.');
if (parts.length !== 4) return false;
var nums = [];
for (var i = 0; i < 4; i++) {
var p = parts[i];
if (!/^\d{1,3}$/.test(p)) return false;
var n = parseInt(p, 10);
if (n > 255) return false;
nums.push(n);
}
if (nums[0] === 0 || nums[0] === 255) return false;
if (nums[0] === 169 && nums[1] === 254) return false;
return true;
}
/** @description 读取 localStorage 中保存的设备 IP,无有效值返回空串。 */
export function getSavedDeviceIp() {
@@ -17,10 +49,17 @@ export function getSavedDeviceIp() {
return '';
}
/** @description 保存设备 IP 到 localStorage(空值忽略)。 */
/**
* @description 保存设备 IP。非法值**拒绝且不改写现值**,返回 false——
* 不能用 DEFAULT_ESP_IP 去"兜住"脏输入,那会把配置自证成连接成功。
* @param {string} ip
* @returns {boolean} 是否真的写进去了
*/
export function saveDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
var v = String(ip == null ? '' : ip).trim();
if (!isValidIpv4(v)) return false;
try { localStorage.setItem(LS_ESP_IP, v); } catch (_) { return false; }
return true;
}
/** @description 设备 IP:localStorage 优先,否则默认值。 */
@@ -32,3 +71,48 @@ export function getDeviceIp() {
export function getBaseUrl() {
return 'http://' + getDeviceIp();
}
/**
* @description 读最近一次选中的设备指纹;结构不对一律当没有。
* @returns {{ip:string, version:string, git_hash:string, observedAt:number, source:string}|null}
*/
export function getDeviceMeta() {
try {
var raw = localStorage.getItem(LS_ESP_DEVICE);
if (!raw) return null;
var o = JSON.parse(raw);
if (!o || typeof o !== 'object' || !isValidIpv4(o.ip)) return null;
return o;
} catch (_) {}
return null;
}
/** @description 写设备指纹(与 IP 同时机,由 deviceLink.selectDevice 统一调用)。 */
export function saveDeviceMeta(meta) {
try {
localStorage.setItem(LS_ESP_DEVICE, JSON.stringify(meta || {}));
} catch (_) {}
}
/**
* @description 读发现结果缓存;返回数组(可能为空),坏数据直接丢弃不猜。
* @returns {Array<object>}
*/
export function getLanDevices() {
try {
var raw = localStorage.getItem(LS_LAN_DEVICES);
if (!raw) return [];
var arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr.filter(function (d) { return d && isValidIpv4(d.ip); });
} catch (_) {}
return [];
}
/** @description 存发现结果缓存(上限 16 条,超了截断——它只是候选种子,不是台账)。 */
export function saveLanDevices(list) {
var arr = Array.isArray(list) ? list.slice(0, 16) : [];
try {
localStorage.setItem(LS_LAN_DEVICES, JSON.stringify(arr));
} catch (_) {}
}
+132 -14
View File
@@ -1,7 +1,7 @@
/**
* connection.js - 连接管理 + 系统状态显示
*
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
* 操作 DOM 元素 #pillWifi/#pillEcat/#pillBle / #bConn 等(三链路胶囊在顶栏)。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应
@@ -14,7 +14,7 @@
*/
import {
formatBytes, formatTime, formatUptime, escapeHtml,
setTextIfChanged, setClassIfChanged, setQuietMode,
setTextIfChanged, setClassIfChanged, setQuietMode, setCellSource,
} from './utils.js';
import { addLog } from './log.js';
import { showToast } from './toast.js';
@@ -72,37 +72,155 @@ function transferOngoing() {
} catch (_) { return false; }
}
/**
* 顶栏三链路胶囊:WiFi / ECAT / BLE 独立亮灭。
* **唯一写入点**:WiFi 文案只在这里生成(mode + 连接态),别处禁止再拼字符串。
* ECAT 文案由 app.js 写入(0x1008 slave.name)。
* @param {boolean|null} wifiOn WiFi 在线;null=按 DeviceLink 快照
* @param {boolean|null} [bleOn] BLE 在线;null=读 window.__bleConnState
*/
/** @description EtherCAT AL 状态位 → 人话(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP)。 */
function ecatAlText(state) {
if (state === null || state === undefined || !Number.isFinite(Number(state))) return '';
switch (Number(state) & 0x0F) {
case 1: return 'INIT';
case 2: return 'PREOP';
case 3: return 'BOOT';
case 4: return 'SAFEOP';
case 8: return 'OP';
default: return '';
}
}
export function updateLinkPills(wifiOn, bleOn) {
try {
var snap = deviceLink.getSnapshot();
var fresh = snap && snap.ts && (Date.now() - snap.ts) < 5000;
var w = wifiOn;
if (fresh && snap.links && snap.links.esp32) w = !!snap.links.esp32.online;
if (typeof wifiOn === 'boolean' && !fresh) w = wifiOn;
/* WiFi 胶囊:文案 = mode + 连接态 + IP + 信号(STA=Station 客户端模式) */
var pw = getEl('pillWifi'), pwT = getEl('pillWifiTxt');
if (pw) {
var mode = 'STA';
var ip = '';
var rssi = '';
try {
if (fresh && snap && snap.esp32Status) {
var stWifi = snap.esp32Status.wifi || {};
if (stWifi.mode) mode = stWifi.mode;
if (stWifi.ip) ip = stWifi.ip;
if (stWifi.rssi != null && stWifi.rssi !== '') rssi = String(stWifi.rssi) + ' dBm';
}
if (!ip) ip = deviceLink.getIp() || '';
} catch (_) {}
var wifiLabel = mode + (w ? ' 已连接' : ' 未连接');
if (w && ip) wifiLabel += ' · ' + ip;
if (w && rssi) wifiLabel += ' · ' + rssi;
/* 运行时覆盖(重启中/上传中/设备忙):优先显示,避免被 1Hz 轮询冲掉 */
if (window.__wifiPillOverride) wifiLabel = window.__wifiPillOverride;
setClassIfChanged(pw, 'pill link-pill ' + (window.__wifiPillOverride ? 'processing' : (w ? 'online' : 'offline')));
if (pwT) setTextIfChanged(pwT, wifiLabel);
}
/* ECAT 胶囊:灯色与文案**都由这里出**。此前 app.js 的 0x1008 probe 也写同一颗
pill 的 className,两边节奏/判据都不同,实测出现过"绿点 + 文字离线"的矛盾。
分级:后端/网卡没开=离线;开了且 PDO 循环在跑=在线;开了但循环没跑=琥珀并标 AL 档。 */
var pe = getEl('pillEcat'), peT = getEl('pillEcatTxt');
if (pe) {
var E = (fresh && snap && snap.links && snap.links.ecat) ? snap.links.ecat : null;
var eName = (E && E.deviceName) || window.__ecatProbeName || '';
var eCls, eTxt;
if (!E) { eCls = 'idle'; eTxt = '未探测'; }
else if (!E.busOpen) { eCls = 'offline'; eTxt = '离线(后端/网卡未开)'; }
else if (E.loop) { eCls = 'online'; eTxt = eName || (E.op ? 'OP 循环中' : '循环中'); }
else {
var al = ecatAlText(E.slaveState);
eCls = 'processing';
eTxt = (eName ? eName + ' · ' : '') + (al ? al + ' 无循环' : '未进 OP');
}
setClassIfChanged(pe, 'pill link-pill ' + eCls);
if (peT) setTextIfChanged(peT, eTxt);
}
/* BLE 胶囊 */
var pb = getEl('pillBle'), pbT = getEl('pillBleTxt');
if (pb) {
var b = bleOn;
if (b === null || b === undefined) {
try {
var st = window.__bleConnState;
if (st === true || st === false) b = st;
} catch (_) {}
}
if (b === null || b === undefined) {
setClassIfChanged(pb, 'pill link-pill idle');
if (pbT) setTextIfChanged(pbT, '未连接');
} else if (b) {
setClassIfChanged(pb, 'pill link-pill online');
var nm = window.__bleDeviceName || '已连接';
var did = window.__bleDeviceId;
var bl = did ? (nm + ' · ' + did) : nm;
if (pbT) setTextIfChanged(pbT, bl);
} else {
setClassIfChanged(pb, 'pill link-pill offline');
if (pbT) setTextIfChanged(pbT, '未连接');
}
}
} catch (_) {}
}
/** WiFi 胶囊临时覆盖文案(重启中/上传中/设备忙);传 null 清除。 */
export function setWifiPillOverride(text) {
window.__wifiPillOverride = text || null;
updateLinkPills(null, null);
}
export function updateConnectionUi(connected, text) {
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。
双通路:WiFi 或 ECAT 任一在线即「在线」;角标标出实际在线链路。 */
var src = 'wifi';
try {
var snap = deviceLink.getSnapshot();
if (snap && snap.ts && (Date.now() - snap.ts) < 5000) {
var w = !!(snap.links && snap.links.esp32 && snap.links.esp32.online);
var e = !!(snap.links && snap.links.ecat && snap.links.ecat.online);
/* Hub 可把在线压成离线;**不得**把本页刚判的离线改回在线。
* Owner 要连失败 3 次才翻离线,期间若无脑覆盖,
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。 */
if (!snap.links.esp32.online) connected = false;
* OTA 页超时判离线后灯又会被点绿 → 拔线后永远「已连接」。
* 双通路修正:WiFi 掉线但 ECAT 仍在线时,整体仍算在线(ECAT 撑着)。 */
if (!w && !e) connected = false;
else if (!connected && (w || e)) {
/* 仅当两路里至少一路 Hub 判在线,且本页因 WiFi 超时误判离线时,
* 才允许 Hub 把「在线」加回来(ECAT 撑场)。 */
if (e && !w) connected = true;
}
if (w && e) src = 'dual';
else if (e && !w) src = 'ecat';
else src = 'wifi';
if (!text) text = connected ? '已连接' : '离线';
if (connected && e && !w) text = text && text.indexOf('ECAT') < 0
? (text + ' · ECAT') : text;
} else {
/* 无快照:参数 connected 来自 WiFi 轮询 */
src = connected ? 'wifi' : 'off';
}
} catch (_) {}
} catch (_) {
src = connected ? 'wifi' : 'off';
}
var label = text || (connected ? '已连接' : '未连接');
/* 判离线立刻清实时指标:只改灯不等于清数据,否则 APP/缓存下会
* 出现「离线 + 温度/电压仍显示」的假活状态。 */
if (!connected && typeof window.clearLiveMetricsOnDisconnect === 'function') {
try { window.clearLiveMetricsOnDisconnect(); } catch (_) {}
}
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = getEl('pillConn');
var pct = getEl('pillConnTxt');
if (pc) {
setClassIfChanged(pc, connected ? 'pill online' : 'pill offline');
if (pct) setTextIfChanged(pct, label);
}
// 系统状态卡徽章
// 顶栏三链路:只刷灯/来源角标;WiFi 文案由 updateLinkPills 统一生成
updateLinkPills(connected, null);
// 系统状态卡徽章(整体:任一数据通路在线即在线)
var bc = getEl('bConn');
if (bc) {
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
setCellSource('bConn', connected ? src : 'off');
}
setQuietMode(!connected && !transferOngoing());
}
+162 -9
View File
@@ -16,8 +16,10 @@
"use strict";
import {
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp
getDeviceIp, saveDeviceIp, getBaseUrl as baseUrlFromIp,
isValidIpv4, getDeviceMeta, saveDeviceMeta
} from "./base-url.js";
import { flashBusyNow } from "./utils.js";
import {
runEcatBootstrap, repairEcatDc, getEcatAdapterOrDefault,
ECAT_DEFAULT_ADAPTER,
@@ -28,6 +30,7 @@ var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
var ESP32_POLL_MS = 1000;
var ECAT_POLL_MS = 2000;
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
var AUTO_SCAN_COOLDOWN_MS = 60000; /* 判离线后最多每 60s 提一次候选设备 */
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
var LS_ECAT_SPIN = "dcu_ecat_spin";
@@ -42,7 +45,8 @@ function readIp() {
}
function writeIp(ip) {
saveDeviceIp(ip);
/* 校验在 base-url.saveDeviceIp 单点做:非法值不改写现值,返回 false。 */
return saveDeviceIp(ip);
}
function readAdapter() {
@@ -95,10 +99,14 @@ function emptySnapshot() {
links: {
esp32: { online: false, ageMs: -1, failStreak: 0 },
ecat: { online: false, busOpen: false, op: false, loop: false,
loadValid: false, loadPerm: 0, ageMs: -1 },
loadValid: false, loadPerm: 0, ageMs: -1, deviceName: "",
slaveState: null, alStatus: null },
ble: { online: false, deviceName: "", owner: null }
},
esp32Status: null
esp32Status: null,
/* 端点一致性:'match' | 'mismatch' | 'unverified'(见 checkEndpoint) */
endpointCheck: 'unverified',
endpointCheckNote: ''
};
}
@@ -118,6 +126,8 @@ function createOwner() {
var ecatBusReady = false;
var ecatBusBusy = false;
var ecatBusNextTryAt = 0;
var uptimeBase = null; /* {ip, up} 只用于 uptime 倒退检测(换板/复位) */
var autoScanAt = 0; /* 上次自动提候选的时刻,见 maybeAutoScan */
function publish() {
snap.ts = nowMs();
@@ -134,16 +144,79 @@ function createOwner() {
snap.links.esp32.online = true;
snap.links.esp32.ageMs = 0;
snap.esp32Status = status || null;
snap.endpointCheck = checkEndpoint(status);
} else {
esp32Fails++;
snap.links.esp32.failStreak = esp32Fails;
snap.links.esp32.ageMs = lastEsp32OkAt ? (nowMs() - lastEsp32OkAt) : -1;
if (esp32Fails >= ESP32_FAIL_ONLINE) snap.links.esp32.online = false;
if (esp32Fails >= ESP32_FAIL_ONLINE) {
snap.links.esp32.online = false;
snap.endpointCheck = checkEndpoint(null);
maybeAutoScan();
}
}
snap.links.esp32.failStreak = esp32Fails;
publish();
}
/**
* 「现在连的还是我选的那台吗」——判据必须有灵敏度,不能是自证:
* 版本变 / 设备自报 IP 与所选不符 / uptime 倒退,三条都能被
* "同地址换了一块板"或"板子刚复位"真实触发。
* 拉不到 status 时**不改判**(保持上一次结论),否则断网会把
* mismatch 说成事实。
*/
function checkEndpoint(status) {
var meta = getDeviceMeta();
var ip = readIp();
if (!meta || String(meta.ip) !== String(ip)) return 'unverified';
if (!status) {
/* 拉不到 status 就**没有**证据可判:不能沿用上一条结论,更不能把
* "上一个端点的 status" 拿来判当前端点(实测会吐出一条自造的
* "设备自报 x.x.x.x 与所选 y.y.y.y 不符"——那是陈旧数据的回声)。 */
snap.endpointCheckNote = '';
return 'unverified';
}
var w = status.wifi || {};
if (status.version && meta.version && String(status.version) !== String(meta.version)) {
snap.endpointCheckNote = '版本与所选不符:' + meta.version + ' → ' + status.version;
return 'mismatch';
}
if (w.connected === true && w.ip && String(w.ip) !== String(ip)) {
snap.endpointCheckNote = '设备自报 ' + w.ip + ',与所选 ' + ip + ' 不符';
return 'mismatch';
}
var up = Number(status.uptime_sec);
if (Number.isFinite(up)) {
if (uptimeBase && uptimeBase.ip === ip && Number.isFinite(uptimeBase.up)
&& up + 2 < uptimeBase.up) {
snap.endpointCheckNote = 'uptime ' + Math.round(uptimeBase.up) + 's → '
+ Math.round(up) + 's:这地址上换板了或刚复位';
uptimeBase = { ip: ip, up: up };
return 'mismatch';
}
uptimeBase = { ip: ip, up: up };
}
snap.endpointCheckNote = '';
return status.version ? 'match' : 'unverified';
}
/**
* 离线了才提一次候选,**绝不自动改端点**。三个门都是必需的:
* 扫描本身会占板子 httpd 的 socket(max_open_sockets=6 + lru_purge,
* wifi_ota.cpp:419-436),而 ECAT 环跑 / 刷写期间 httpd 常常本就不响应
* (spi_master.cpp:768-771)——那时 failStreak 是假阳性,不加门就成了
* "扫→踢连接→更离线→再扫"的正反馈。
*/
function maybeAutoScan() {
var now = nowMs();
if (autoScanAt && now - autoScanAt < AUTO_SCAN_COOLDOWN_MS) return;
if (flashBusyNow()) return;
if (snap.links.ecat && snap.links.ecat.loop) return;
autoScanAt = now;
if (ch) { try { ch.postMessage({ type: 'auto-scan', ip: readIp() }); } catch (_) {} }
}
function pollEsp32() {
if (stopped) return;
var ac = null;
@@ -172,9 +245,17 @@ function createOwner() {
/**
* 开总线:R6 —— 策略只在 js/common/ecat-bus.js(health→open→motor/start,
* DC 250µs,已健康则复用)。外壳 Owner 启动时强制执行,用户不必先去电机页。
* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
*/
function bootstrapEcatBus() {
if (stopped || ecatBusBusy) return;
/* BOOT/OTA 刷写中禁止重拉:motor/start 会 loop_stop,会掐死灌写会话。
* motorOtaEcatBusy = 电机 OTA 正经邮箱灌 GD32(native worker 独占总线十几秒),
* 这条请求只会在锁上排队,排到时把刚恢复的循环再停一遍。 */
if (window.bootEcatBusy || window.isUploading || window.motorOtaEcatBusy) return;
/* 对面是 BOOT(邮箱 PREOP、不跑 OP/PDO)时不要 motor/start:
* 跳转/刷写结束后 bootEcatIsBoot 会钉住,直到 jump_app 确认 APP。 */
if (window.bootEcatIsBoot === true) return;
var now = nowMs();
if (ecatBusReady) return;
if (now < ecatBusNextTryAt) return;
@@ -215,6 +296,14 @@ function createOwner() {
var e = snap.links.ecat;
e.online = !!(st && st.ok && st.slave);
e.busOpen = !!(st && st.ok);
e.deviceName = (st && st.slave && st.slave.name) ? String(st.slave.name) : "";
/* 从站 AL 状态原样带上,让灯色与文案同源:1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP。
* 只有 ok+slave 就判"在线"太松——实测从站在 INIT、0x1008 空、循环没跑时
* 也会被点成绿灯,而文字还写着"离线"。 */
e.slaveState = (st && st.slave && st.slave.state != null) ? Number(st.slave.state) : null;
e.alStatus = (st && st.slave && st.slave.al_status != null) ? Number(st.slave.al_status) : null;
/* 同步到 window:Client 侧 updateLinkPills 在旧快照缺字段时兜底读取 */
window.__ecatDeviceName = e.deviceName;
/* 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
@@ -222,8 +311,12 @@ 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;
/* 与刚上电同判据:bus_open + loop 才算就绪。
* jump_app / commit 后 loop 被 loop_stop 掉、bus 仍开 —— 旧逻辑只在
* !busOpen 时清 ready,于是 Owner 永远不再 motor/start,连接要等外部
* 自愈才回来。loop 一掉就允许 bootstrap 重拉(刷写中由上面 busy 门拦住)。 */
if (e.busOpen && e.loop) ecatBusReady = true;
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
else ecatBusReady = false;
publish();
/* 自愈:DC/周期被配错时发送出口秒级 —— R6 单点 repairEcatDc */
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
@@ -246,6 +339,14 @@ function createOwner() {
if (m.type === "hello") { publish(); return; }
if (m.type === "set-ip") {
writeIp(m.ip);
/* 换了端点=换了问题:旧的核对结论必须作废,否则新端点还没答过话,
面板就拿着上一个设备的 status 报"不符"。 */
snap.endpointCheck = 'unverified';
snap.endpointCheckNote = '';
uptimeBase = null; /* uptime 倒退检测只在"同一个端点连续观察"内成立 */
/* 无论采纳还是拒绝都把"实际生效的 IP"广播回去:否则各页输入框会停在
用户刚打的脏值上,显示连着、实际打的还是旧端点——那正是自证判据。 */
publish();
try {
fetch(baseUrlFromIp() + "/system/status", {
cache: "no-store",
@@ -287,8 +388,15 @@ function createClient() {
var ch = null;
try { ch = new BroadcastChannel(CH_NAME); } catch (_) { ch = null; }
var listeners = [];
var autoScanFns = [];
var last = null;
function emitAutoScan(m) {
for (var i = 0; i < autoScanFns.length; i++) {
try { autoScanFns[i](m || {}); } catch (_) {}
}
}
function emit() {
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](last); } catch (_) {}
@@ -298,7 +406,9 @@ function createClient() {
if (ch) {
ch.addEventListener("message", function (ev) {
var m = ev && ev.data;
if (!m || m.type !== "snapshot" || !m.snap) return;
if (!m || !m.type) return;
if (m.type === "auto-scan") { emitAutoScan(m); return; }
if (m.type !== "snapshot" || !m.snap) return;
last = m.snap;
emit();
});
@@ -334,10 +444,49 @@ function createClient() {
return !!(L && L.online);
},
/* ── WiFi 端点 ── */
/* ── WiFi 端点(唯一写手;各页输入框/发现面板都走这里) ── */
/**
* @description 换设备。**唯一**能改当前端点的入口,门也只在这里判一次:
* - IPv4 合法性(base-url.saveDeviceIp 单点校验,非法不改写现值)
* - 刷写/OTA 进行中不换(motor/start 会 loop_stop、灌写会话会被掐死)
* @returns {{ok:boolean, ip?:string, reason?:string}}
*/
setIp: function (ip) {
writeIp(ip);
var v = String(ip == null ? '' : ip).trim();
if (!isValidIpv4(v)) return { ok: false, reason: '不是可用 IPv4:' + v };
if (flashBusyNow()) return { ok: false, reason: '刷写/OTA 进行中,暂不换端点' };
if (!writeIp(v)) return { ok: false, reason: '写入 localStorage 失败' };
if (ch) { try { ch.postMessage({ type: "set-ip", ip: readIp() }); } catch (_) {} }
return { ok: true, ip: readIp() };
},
/**
* @description 选中一台发现到的设备:比 setIp 多一步指纹登记(版本/来源/时刻),
* Owner 之后每次拉 status 都据此核"连的还是不是这台"(snap.endpointCheck)。
* @param {{ip:string, version?:string, git_hash?:string, source?:string}} rec
*/
selectDevice: function (rec) {
var ip = rec && rec.ip != null ? String(rec.ip).trim() : '';
var r = this.setIp(ip);
if (!r.ok) return r;
saveDeviceMeta({
ip: r.ip,
version: rec.version ? String(rec.version) : '',
git_hash: rec.git_hash ? String(rec.git_hash) : '',
observedAt: nowMs(),
source: rec.source || 'lan'
});
return r;
},
/** @description 当前登记的端点指纹(无则 null)。 */
getDeviceMeta: function () { return getDeviceMeta(); },
/** @description 订阅"Owner 判离线、提候选"的信号(不自动改端点)。 */
onAutoScan: function (fn) {
if (typeof fn !== 'function') return function () {};
autoScanFns.push(fn);
return function () {
var i = autoScanFns.indexOf(fn);
if (i >= 0) autoScanFns.splice(i, 1);
};
},
getIp: function () { return readIp(); },
wifiBaseUrl: function () { return baseUrlFromIp(); },
@@ -371,6 +520,10 @@ function createClient() {
var deviceLink;
try {
if (window.__dcuDeviceLinkOwner) {
/* 把标记消费掉:同一文档里本模块若因入口 ?v= 与 JS 间 import 的 URL 不同被
* 实例化两次(tabs.html 就是这种:入口标签带 stamp,lan-picker.js 不带),
* 只允许有一个 Owner —— 两个 Owner 就是两份 1Hz status 轮询 + 两份快照互相打架。 */
window.__dcuDeviceLinkOwner = false;
window.__dcuDeviceLinkInstance = createOwner();
}
} catch (_) {}
+57 -28
View File
@@ -6,7 +6,9 @@
* health →(未就绪)status →(未开)open 网卡 → motor/start 进 OP + 起 PDO 循环
*
* 红线:
* - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000)))
* - PDO 周期与 DC SYNC0 **始终同值**:dc_cycle_us = round(period_ms*1000),
* 周期可在 UI 档位 0.125–4ms 内随时热改,不再钉死 0.25。
* 默认仍是 0.25ms(历史稳妥档);≥1ms 时 GD32 发送出口可能变慢,属预期代价。
* - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start
* (start 会先 loop_stop,热路径假超时 / SSC 楔死风险)
* - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 motor/start
@@ -22,12 +24,30 @@
/** 网卡匹配子串默认值(代码/页面/测试的唯一来源;文档漂移以此为准)。 */
export const ECAT_DEFAULT_ADAPTER = "I210";
/** 默认 PDO 周期(ms):1ms+DC 会让发送出口回到 ~1s,必须 250µs 档。 */
/** 默认 PDO 周期(ms)。 */
export const ECAT_DEFAULT_PERIOD_MS = 0.25;
/** DC SYNC0 目标周期(µs)。 */
/** 允许的 PDO 周期上下限(ms)——与 spi_test 档位一致,可随时热改。 */
export const ECAT_PERIOD_MIN_MS = 0.125;
export const ECAT_PERIOD_MAX_MS = 4;
/** 默认 DC SYNC0 周期(µs):与默认 period 0.25ms 对齐;热改后 DC 跟随 period。 */
export const ECAT_DC_CYCLE_US = 250;
/** 把任意 period 钳进合法区间;非法/缺省回落默认 0.25ms。 */
export function clampEcatPeriodMs(v) {
var per = Number(v);
if (!(per > 0)) return ECAT_DEFAULT_PERIOD_MS;
if (per < ECAT_PERIOD_MIN_MS) return ECAT_PERIOD_MIN_MS;
if (per > ECAT_PERIOD_MAX_MS) return ECAT_PERIOD_MAX_MS;
return per;
}
/** period_ms → DC SYNC0 µs(与 period 锁死同值,不再是恒 250)。 */
export function periodToDcUs(perMs) {
return Math.round(clampEcatPeriodMs(perMs) * 1000);
}
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
const LS_ECAT_SPIN = "dcu_ecat_spin";
const LS_ECAT_ADAPTER = "dcu_ecat_adapter";
@@ -42,20 +62,15 @@ function readAdapterRaw() {
}
/**
* 构造 motor/start body:period 钳到 ≤0.5ms 且默认 0.25,DC 钉 250µs。
* 构造 motor/start body:period 钳进 0.125–4ms,DC 与 period 同值。
* @param {{period_ms?:number, spin?:boolean|null, fromLocalStorage?:boolean}} [opts]
*/
export function resolveEcatStartBody(opts) {
opts = opts || {};
var body = {};
var per = Number(opts.period_ms);
if (!opts.fromLocalStorage && opts.period_ms == null) {
per = ECAT_DEFAULT_PERIOD_MS;
} else if (!(per > 0) || per > 0.5) {
per = ECAT_DEFAULT_PERIOD_MS;
}
var per = clampEcatPeriodMs(opts.period_ms);
body.period_ms = per;
body.dc_cycle_us = Math.max(ECAT_DC_CYCLE_US, Math.round(per * 1000));
body.dc_cycle_us = periodToDcUs(per);
body.dc_shift_us = 0;
var spin = opts.spin;
if (spin == null && opts.fromLocalStorage) {
@@ -79,8 +94,8 @@ export function readStoredEcatPeriod() {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
/* 用户显式设过才用;否则 0.25ms */
if (v > 0 && v !== 1) return v;
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
if (v > 0 && v >= ECAT_PERIOD_MIN_MS && v <= ECAT_PERIOD_MAX_MS) return v;
return ECAT_DEFAULT_PERIOD_MS;
} catch (_) {
return ECAT_DEFAULT_PERIOD_MS;
@@ -113,16 +128,26 @@ export function proveEcatLoop(h, opts) {
/**
* 「DC/周期是否被*证明*配错」。缺字段 = 证明不了 = 不动总线(repairEcatDc 旧规则收编于此)。
* 判据:motor 路径必须 DC on;period / dc_cycle_us 各自在合法区间;
* 两者都有值时必须同值(period_ms*1000 ≈ dc_cycle_us)。
* 周期本身可以是 0.125–4ms 内任意档 —— 与 period 锁死的 DC 不再判错。
* @returns {"ok"|"misconfigured"|"unknown"}
*/
export function proveEcatDc(h) {
if (!h || h.ok !== true) return "unknown";
if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";
if (h.dc === 1 && Number(h.dc_cycle_us) === ECAT_DC_CYCLE_US &&
(h.period_ms == null || Math.abs(Number(h.period_ms) - ECAT_DEFAULT_PERIOD_MS) < 0.001)) {
return "ok";
if (h.dc !== 1) return "misconfigured";
var per = h.period_ms == null ? null : Number(h.period_ms);
var dc = h.dc_cycle_us == null ? null : Number(h.dc_cycle_us);
var perOk = per == null || (per >= ECAT_PERIOD_MIN_MS && per <= ECAT_PERIOD_MAX_MS);
var dcMin = Math.round(ECAT_PERIOD_MIN_MS * 1000);
var dcMax = Math.round(ECAT_PERIOD_MAX_MS * 1000);
var dcOk = dc == null || (dc >= dcMin && dc <= dcMax);
if (!perOk || !dcOk) return "misconfigured";
if (per != null && dc != null) {
if (Math.abs(Math.round(per * 1000) - dc) > 1) return "misconfigured";
}
return "misconfigured";
return "ok";
}
/**
@@ -283,10 +308,12 @@ export async function runEcatBootstrap(opts) {
}
/**
* DC/周期自愈:被**证明**配错(free-run 或 ≥1ms)时拉回 250µs。
* 判据在 proveEcatDc 里(唯一实现):证明不了 = 不动总线。这条规则的存在理由就是
* healthCheck 那份内联副本 —— 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包
* 就打出一记 loop_stop 的 motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
* DC/周期自愈:被**证明**配错(free-run / period 越界 / period 与 DC 不同值)时
* 按当前合法 period 把 DC 对齐回去。判据在 proveEcatDc 里(唯一实现):
* 证明不了 = 不动总线。这条规则的存在理由就是 healthCheck 那份内联副本 ——
* 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包就打出一记 loop_stop 的
* motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。
* 修复目标是「period 与 DC 同值」,不是「一律拉回 0.25」。
* @param {(path:string,body?:object,timeoutMs?:number)=>Promise<any>} ecatPost
* @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返,
* 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。
@@ -304,13 +331,15 @@ export async function repairEcatDc(ecatPost, knownHealth) {
if (proveEcatDc(h) !== "misconfigured") return null;
/* native 正在全量重连(close→open→OP,秒级)时再插一记 motor/start 是自相残杀:
* 两条恢复路各自 loop_stop,最后谁都不知道总线是谁停的。 */
if (h.recovering) return null;
return ecatPost("motor/start", {
period_ms: ECAT_DEFAULT_PERIOD_MS,
dc_cycle_us: ECAT_DC_CYCLE_US,
dc_shift_us: 0,
reason: "auto_dc_repair",
}, 30000);
if (h && h.recovering) return null;
var per = readStoredEcatPeriod();
if (h && h.period_ms != null) {
var hp = Number(h.period_ms);
if (hp >= ECAT_PERIOD_MIN_MS && hp <= ECAT_PERIOD_MAX_MS) per = hp;
}
var body = resolveEcatStartBody({ period_ms: per });
body.reason = "auto_dc_repair";
return ecatPost("motor/start", body, 30000);
}
/** @description 读当前网卡子串(localStorage → 默认 I210)。 */
+703
View File
@@ -0,0 +1,703 @@
/**
* lan-picker.js — 局域网设备发现面板(一份实现,两个入口)
*
* 入口:tabs.html 顶栏的 🔍(外壳文档 = 扫描的宿主)与 pages/index.html IP 行
* 的「发现」。iframe 里的入口只请求外壳开面板;外壳是同源父文档,所以不需要
* 选举——同一时刻只有一份扫描状态。
*
* 分工(重要):
* - 判据只在服务侧一处:lan_probe.classify() 说是不是板子,页面不复算,
* 免得出现第二个"什么算设备"的口径。
* - 编排只在这里:候选表、分批、进度、取消、缓存、渲染。
* - 换端点只走 deviceLink.selectDevice()(唯一的写手 + 唯一的门)。
*
* 当前已选端点**不重复开连接**去探:Owner 那条 1Hz /system/status 轮询已经是
* 它的活体判决(板子 httpd 只有 6 个 socket 且开了 lru_purge,多开一条会踢掉
* 网页自己的 keep-alive)。所以候选表把当前端点剔掉,行内容由快照渲染。
*/
"use strict";
import { deviceLink } from './device-link.js';
import {
isValidIpv4, getLanDevices, saveLanDevices, getDeviceIp
} from './base-url.js';
import { escapeHtml, flashBusyNow } from './utils.js';
var PROBE_PATH = '/system/status?lite=1';
var BATCH_IPS = 64; // 一次 /api/lan/probe 的 IP 数(与服务侧 BATCH_MAX_IPS 一致)
var CLIENT_PARALLEL = 2; // 同时在途的批数(整段 /24 实测 ~3.5–6s)
var BATCH_TIMEOUT_MS = 6000; // 一批的 fetch 上限(服务侧一批最坏 ~1.8s)
var PER_IP_TIMEOUT_MS = 750; // 交给服务侧的单目标硬截止
var STALE_MS = 20000; // 结果"新鲜"窗口,过期行变灰、按钮改「尝试连接」
var SEED_IPS = ['192.168.31.127', '192.168.31.1'];
var MAX_ROWS = 40; // 未知/拒绝列表各截断到这里,但计数照全报
var state = {
open: false,
scanning: false,
cancel: false,
tier: '',
cidr: '',
local: null,
rows: [], // verdict=board
unknowns: [], // 连不上/读超时/形状合但落不进判据
rejected: [], // 有反证的
scan: null,
skipped: [],
detail: Object.create(null), // ip -> 完整 status(人点了「详情」才要)
direct: Object.create(null), // ip -> 本页直测结论(B 路证据)
note: null,
progress: { done: 0, total: 0 },
autoScanned: 0
};
/* ───────────────────────── 纯函数(不碰 DOM,便于推理与离线核对) ───────────────────────── */
/** @description 192.168.31.0/24 → 1..254 主机地址。只支持 /24(掩码更宽请直接填 CIDR)。 */
export function cidrToIps(cidr) {
var s = String(cidr || '').trim();
var bits = s.split('/');
var q = bits[0].split('.');
if (q.length !== 4 || !isValidIpv4(q.join('.'))) return { err: 'CIDR 不合法:' + s };
var mask = bits.length > 1 ? parseInt(bits[1], 10) : 24;
if (mask !== 24) return { err: '只支持 /24(当前 /' + mask + ');需要更小的段请逐个手填 IP' };
if (bits.length > 1 && q[3] !== '0') return { err: 'CIDR 网络地址段末应为 .0:' + s };
var base = q.slice(0, 3).join('.');
var ips = [];
for (var i = 1; i <= 254; i++) ips.push(base + '.' + i);
return { ips: ips, cidr: base + '.0/24' };
}
/** @description 私有网段判定(10/8、172.16/12、192.168/16)。 */
export function isPrivateIp(ip) {
var q = String(ip).split('.');
var a = parseInt(q[0], 10), b = parseInt(q[1], 10);
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
return false;
}
/**
* @description 候选表 = 去重 + 剔掉"探了也没意义"的地址。
* 被剔掉的每一条都带原因并显示出来——静默丢弃会让本机 :80 服务冒充板子。
* @param {{ips:string[], localIps?:string[], endpointIp?:string, allowPublic?:boolean}} o
*/
export function buildCandidates(o) {
var local = {};
(o.localIps || []).forEach(function (ip) { local[ip] = 1; });
var out = [], skipped = [], seen = {};
(o.ips || []).forEach(function (ip) {
var v = String(ip || '').trim();
if (!isValidIpv4(v)) { if (v) skipped.push([v, '不是 IPv4']); return; }
if (seen[v]) return;
seen[v] = 1;
if (/^127\./.test(v)) { skipped.push([v, '回环']); return; }
if (/^169\.254\./.test(v)) { skipped.push([v, '链路本地']); return; }
if (local[v]) { skipped.push([v, '本机地址']); return; }
if (o.endpointIp && o.endpointIp === v) {
skipped.push([v, '当前端点:由 1Hz 轮询判活,不重复开连接']); return;
}
if (!isPrivateIp(v) && !o.allowPublic) {
skipped.push([v, '非私有网段(要探请勾"允许公网段")']); return;
}
out.push(v);
});
return { ips: out, skipped: skipped };
}
/** @description 把服务侧逐 IP 结果分成三堆。判决本身来自服务侧,这里不重算。 */
export function groupResults(results) {
var g = { boards: [], unknowns: [], rejected: [] };
(results || []).forEach(function (r) {
if (!r || !r.ip) return;
/* 服务侧不返回时间戳,这里补上"本轮观测时刻"——否则行会被当成过期数据变灰。 */
if (!r.observedAt) r.observedAt = Date.now();
if (r.verdict === 'board') g.boards.push(r);
else if (r.verdict === 'rejected') g.rejected.push(r);
else g.unknowns.push(r);
});
return g;
}
/** @description 快扫种子 = 当前端点 + 历史默认 + 上次发现到的(都过一遍候选过滤)。 */
export function seedIps(endpointIp, lastSeen) {
var list = SEED_IPS.slice();
if (endpointIp) list.unshift(endpointIp);
(lastSeen || []).forEach(function (ip) { if (list.indexOf(ip) < 0) list.push(ip); });
return list.slice(0, 8);
}
/* ───────────────────────── 服务侧调用 ───────────────────────── */
async function apiProbe(body, timeoutMs) {
var resp = await fetch('/api/lan/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || BATCH_TIMEOUT_MS)
});
var text = await resp.text();
var j = null;
try { j = text ? JSON.parse(text) : null; } catch (_) {
throw new Error('探测响应非合法 JSON(HTTP ' + resp.status + ')');
}
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.json = j;
throw err;
}
return j || {};
}
/** @description 「本页直测」:让页面自己打一次 lite,证明*这个文档*能不能到它。
* 服务侧扫到(A)与页面能连(B)是两条独立证据,浏览器对 127.0.0.1→私有子网
* 的限制(Local Network Access)只会影响 B,所以这条要能单独出结论。 */
async function directProbe(ip, path, timeoutMs) {
var t0 = (window.performance || Date).now();
try {
var r = await fetch('http://' + ip + (path || PROBE_PATH), {
mode: 'cors', cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs || 2500)
});
var j = null;
try { j = await r.json(); } catch (_) { j = null; }
return { ok: r.ok && j !== null, http: r.status, json: j, ms: Math.round((window.performance || Date).now() - t0) };
} catch (e) {
return {
ok: false,
name: (e && e.name) || 'Error',
msg: (e && e.message) || String(e),
ms: Math.round((window.performance || Date).now() - t0)
};
}
}
/* ───────────────────────── 扫描编排 ───────────────────────── */
function canStartScan() {
if (state.scanning) return '上一批还在跑(先取消或等它结束)';
/* 门放在发起处一次判完:刷写中扫段会抢设备 httpd 的 6 个 socket 并触发
* lru_purge 踢连接,等于把正在灌写的会话掐死。 */
if (flashBusyNow()) return '刷写/OTA 进行中,不发起扫描(会占设备 httpd 的 socket 并可能踢掉正在灌写的连接)';
return '';
}
async function runScan(tier) {
var refuse = canStartScan();
if (refuse) { say('warn', refuse); return; }
state.scanning = true;
state.cancel = false;
state.tier = tier;
state.rows = []; state.unknowns = []; state.rejected = [];
state.skipped = []; state.scan = null; state.note = null;
lastListHtml = null;
state.progress = { done: 0, total: 0 };
render();
var groups = { boards: [], unknowns: [], rejected: [] };
/* durationMs 是各批服务端耗时之和(并发下会大于真实等待),只用于对账;
* 给人看的"多久"用页面墙钟,别把累加值当耗时报。 */
var stats = { candidates: 0, tcpOpen: 0, httpProbed: 0, board: 0, rejected: 0, unknown: 0, durationMs: 0, wallMs: 0 };
var t0 = Date.now();
var err = '';
try {
var endpoint = deviceLink.getIp();
var lastSeen = getLanDevices().map(function (d) { return d.ip; });
// 一次空探测顺便取本机网段(不浪费往返;候选为 0 时服务侧立即返回)
var hello = await apiProbe({ ips: [], ports: [80], local: true }, 4000);
state.local = hello.local || null;
var localIps = (hello.local && hello.local.local_ips) || [];
function fullIps() {
var cidr = state.cidr || (hello.local && hello.local.cidr) || '';
if (!cidr) throw new Error('取不到本机默认路由,无法派生 /24。请在 CIDR 框手填(如 192.168.31.0/24)后重试。');
var c = cidrToIps(cidr);
if (c.err) throw new Error(c.err);
state.cidr = c.cidr;
return c.ips;
}
async function sweep(raw) {
var built = buildCandidates({
ips: raw, localIps: localIps, endpointIp: endpoint,
allowPublic: !!state.allowPublic
});
state.skipped = state.skipped.concat(built.skipped);
var ips = built.ips;
/* 当前端点不进候选表(由 Owner 的 1Hz 快照判活),但作为一行结果单独摆着。 */
stats.candidates += ips.length;
state.progress.total += ips.length;
render();
var queue = [];
for (var i = 0; i < ips.length; i += BATCH_IPS) queue.push(ips.slice(i, i + BATCH_IPS));
/* 批间并发 CLIENT_PARALLEL;服务侧一批内并发 48(跨不同地址,
* 一块板子全程只被占 1 个 socket)。JS 单线程,await 恢复后的累加是原子的。 */
async function worker() {
while (queue.length && !state.cancel) {
var batch = queue.shift();
if (!batch) break;
var out = await apiProbe({
ips: batch, ports: [80], path: PROBE_PATH,
timeout_ms: PER_IP_TIMEOUT_MS
});
var g = groupResults(out.results);
groups.boards = groups.boards.concat(g.boards);
groups.unknowns = groups.unknowns.concat(g.unknowns);
groups.rejected = groups.rejected.concat(g.rejected);
var s = out.scan || {};
['tcpOpen', 'httpProbed', 'board', 'rejected', 'unknown'].forEach(function (k) {
stats[k] = (stats[k] || 0) + (s[k] || 0);
});
stats.durationMs += (s.durationMs || 0);
if (s.truncated && s.pending && s.pending.length) {
state.skipped.push(['—', '本批预算用尽,未探 ' + s.pending.length + ' 个']);
}
(out.errors || []).forEach(function (e2) { state.skipped.push(['—', '服务侧: ' + e2]); });
state.progress.done = Math.min(state.progress.done + batch.length, state.progress.total);
state.rows = groups.boards; state.unknowns = groups.unknowns;
state.rejected = groups.rejected;
render();
}
}
var workers = [];
for (var w = 0; w < Math.max(1, CLIENT_PARALLEL); w++) workers.push(worker());
await Promise.all(workers);
}
if (tier === 'fast') {
await sweep(seedIps(endpoint, lastSeen));
/* 种子没命中就自动扩到整段:设备换 DHCP 地址正是最常见的失效原因,
* 而整段实测 ~4s(并发 48),没理由让用户再点一次按钮。 */
if (!groups.boards.length && !state.cancel) {
var seg = (state.local && state.local.cidr) || '/24';
state.skipped.push(['—', '种子未命中 → 自动扩到整段 ' + seg]);
say('warn', '种子里没有设备(多半是地址变了)——自动扩到整段 ' + seg);
await sweep(fullIps());
}
} else {
await sweep(fullIps());
}
if (state.cancel) say('warn', '已取消:已探测 ' + state.progress.done + '/'
+ state.progress.total + ' 个地址,结果保留在下面(取消只表示不再发下一批,在途那批仍会跑完)');
} catch (e) {
err = (e && e.message) || String(e);
}
state.scanning = false;
stats.wallMs = Date.now() - t0;
state.scan = stats;
if (err) state.note = { kind: 'error', text: err, at: Date.now() };
// 只把"确认是板子"的写回候选种子缓存;未知/拒绝的不配当种子。
persistSeen(groups.boards);
render();
if (!err) {
say('info', '扫描完成:候选 ' + stats.candidates + ' → 端口开 ' + stats.tcpOpen
+ ' → 板子 ' + groups.boards.length + ' 台,不可判定 ' + groups.unknowns.length
+ ',反证排除 ' + groups.rejected.length + '(用时 ' + stats.wallMs + 'ms)');
}
}
function persistSeen(boards) {
var prev = getLanDevices();
var now = Date.now();
var byIp = {};
(boards || []).forEach(function (b) { byIp[b.ip] = { ip: b.ip, observedAt: now, mode: b.mode, kind: b.kind }; });
// 本轮没探到的旧条目保留但不再刷新 observedAt —— 它会自然变灰,不会假装"还在"
prev.forEach(function (p) { if (!byIp[p.ip]) byIp[p.ip] = p; });
saveLanDevices(Object.keys(byIp).map(function (k) { return byIp[k]; }));
}
/* ───────────────────────── 反馈 ───────────────────────── */
function say(kind, text) {
state.note = { kind: kind, text: text, at: Date.now() };
renderNote();
var lvl = kind === 'error' ? 'E' : (kind === 'warn' ? 'W' : 'I');
/* 外壳文档(tabs.html)没有日志面板也没有 toast 容器,所以面板自己的这行
* 是**兜底的、必定可见的**出口;页面的 log/toast 只是额外一份。 */
if (typeof window.addLog === 'function') {
try { window.addLog(lvl, '设备', text); } catch (_) {}
}
if (kind !== 'info' && typeof window.showToast === 'function') {
try { window.showToast(text, kind === 'error' ? 'error' : 'warning', { force: true }); } catch (_) {}
}
}
/* ───────────────────────── 渲染 ───────────────────────── */
var pillEl = null, pillTxtEl = null, panelEl = null;
/* 面板每 2s 会被定时器刷一次;列表 HTML 不变就不重排 DOM(否则输入焦点/滚动
会被重建打掉)。扫描开始、选中设备时显式作废。 */
var lastListHtml = null;
function ageText(ts) {
if (!ts) return '—';
var s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return s + 's 前';
if (s < 3600) return Math.round(s / 60) + 'min 前';
return Math.round(s / 3600) + 'h 前';
}
function boardLabel(b) {
var d = state.detail[b.ip];
var bits = [b.kind === 'ap' ? 'AP 回退' : 'STA'];
if (d) {
bits.push('v' + (d.version || '?'));
if (d.git_hash) bits.push(d.git_hash.slice(0, 7));
if (d.wifi && Number.isFinite(d.wifi.rssi)) bits.push(d.wifi.rssi + 'dBm');
if (Number.isFinite(d.uptime_sec)) bits.push('up ' + Math.round(d.uptime_sec) + 's');
} else if (b.schema === 'core-only') {
bits.push('旧固件(无 gd32_features)');
}
if (b.schema === 'full' && !d) bits.push('schema 全');
return bits.join(' · ');
}
function rowHtml(b) {
var isSel = b.ip === deviceLink.getIp();
var stale = !b.observedAt || (Date.now() - b.observedAt) > STALE_MS;
var d = state.direct[b.ip];
/* 行用 layout.css 的 .jump-history-item(.jh-tag / .jh-msg / .jh-time 与
.ok/.info 状态色都是既有类),这里只加"可点选"与过期两种修饰。 */
var h = '<li class="jump-history-item ok' + (isSel ? ' lan-cur' : '')
+ (stale ? ' lan-stale' : '') + '" data-ip="' + escapeHtml(b.ip) + '" title="'
+ (stale ? '尝试连接(结果已过期)' : '设为当前设备') + '">';
h += '<span class="jh-tag">' + escapeHtml(b.kind === 'ap' ? 'AP' : 'STA') + '</span>';
h += '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(b.ip) + '</span> '
+ '<span class="lan-meta">' + escapeHtml(boardLabel(b)) + '</span></span>';
h += '<span class="lan-acts">'
+ '<button class="btn btn-ghost btn-sm" data-act="detail" data-ip="' + escapeHtml(b.ip) + '">详情</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="direct" data-ip="' + escapeHtml(b.ip) + '">本页直测</button>'
+ '</span>';
h += '<span class="jh-time">' + escapeHtml(ageText(b.observedAt)) + '</span>';
if (d) {
h += '<span class="lan-sub">' + (d.ok
? '本页可达 ✓ HTTP ' + d.http + ' · ' + d.ms + 'ms'
: '本页取不到:' + escapeHtml(d.name + ' ' + d.msg)
+ '(服务侧扫到了,多半是浏览器对本机访问私有网段的限制)')
+ '</span>';
}
h += '</li>';
return h;
}
function renderPill() {
if (!pillEl) return;
var snap = deviceLink.getSnapshot();
var ip = (snap && snap.ip) || deviceLink.getIp();
var st = snap && snap.esp32Status;
var on = !!(snap && snap.links && snap.links.esp32 && snap.links.esp32.online);
var txt = ip;
if (st && st.version) txt += ' · v' + st.version;
if (st && st.wifi && Number.isFinite(st.wifi.rssi)) txt += ' · ' + st.wifi.rssi + 'dBm';
/* 顶栏这颗就是各页在用的 .pill 组件(online/offline/processing 是既有状态类),
不另造配色。mismatch 用 processing(琥珀)而不是 offline:设备是活的,
只是"不像我选的那台"。 */
var cls = 'pill ' + (snap && snap.endpointCheck === 'mismatch' ? 'processing'
: (on ? 'online' : 'offline'));
if (pillTxtEl && pillTxtEl.textContent !== txt) pillTxtEl.textContent = txt;
if (pillEl.className !== cls) pillEl.className = cls;
pillEl.title = (snap && snap.endpointCheckNote)
? ('端点核对:' + snap.endpointCheck + ' — ' + snap.endpointCheckNote)
: ('端点核对:' + ((snap && snap.endpointCheck) || 'unverified'));
}
function renderNote() {
var n = panelEl && panelEl.querySelector('.lan-note');
if (!n) return;
var t = state.note ? state.note.text : '';
var cls = 'lan-note' + (state.note && state.note.kind !== 'info' ? ' lan-note-' + state.note.kind : '');
if (n.textContent !== t) n.textContent = t;
if (n.className !== cls) n.className = cls;
}
function render() {
if (!panelEl) { renderPill(); return; }
var mask = panelEl.querySelector('.lan-mask');
if (mask) mask.classList.toggle('show', !!state.open);
renderPill();
var head = panelEl.querySelector('.lan-ep');
if (head) {
var snap = deviceLink.getSnapshot();
var ec = snap ? snap.endpointCheck : 'unverified';
var t = '当前端点 ' + getDeviceIp() + ' · 核对 ' + ec
+ ((snap && snap.endpointCheckNote) ? ':' + snap.endpointCheckNote : '');
if (head.textContent !== t) head.textContent = t;
head.className = 'lan-ep' + (ec === 'mismatch' ? ' lan-note-warn' : '');
}
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn && cidrIn !== document.activeElement && cidrIn.value !== state.cidr) {
cidrIn.value = state.cidr || '';
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) pub.checked = !!state.allowPublic;
var prog = panelEl.querySelector('.lan-progress');
if (prog) {
var ptxt = state.scanning
? ('扫描中… ' + state.progress.done + '/' + state.progress.total
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length)
: (state.scan
? ('上一轮:候选 ' + state.scan.candidates + ' · 端口开 ' + state.scan.tcpOpen
+ ' · 板子 ' + state.rows.length + ' · 不可判定 ' + state.unknowns.length
+ ' · 反证排除 ' + state.rejected.length + ' · 用时 ' + state.scan.wallMs + 'ms')
: '还没扫。快扫只打几个已知地址(≈1s);全段扫整条 /24(≈10s)。');
if (prog.textContent !== ptxt) prog.textContent = ptxt;
prog.classList.toggle('lan-busy', !!state.scanning);
}
var list = panelEl.querySelector('.lan-list');
if (list) {
var rows = state.rows.slice(0, MAX_ROWS);
var snapNow = deviceLink.getSnapshot();
var epIp = deviceLink.getIp();
var html = '';
if (snapNow) {
var on = snapNow.links && snapNow.links.esp32 && snapNow.links.esp32.online;
var st2 = snapNow.esp32Status || {};
html += '<li class="jump-history-item info lan-cur">'
+ '<span class="jh-tag">当前</span>'
+ '<span class="jh-msg"><span class="lan-ip">' + escapeHtml(epIp) + '</span> '
+ '<span class="lan-meta">' + (on ? '在线' : '离线(连续 3 次拉不到 status)')
+ (st2.version ? ' · v' + escapeHtml(st2.version) : '')
+ (st2.wifi && Number.isFinite(st2.wifi.rssi) ? ' · ' + st2.wifi.rssi + 'dBm' : '')
+ (Number.isFinite(st2.uptime_sec) ? ' · up ' + Math.round(st2.uptime_sec) + 's' : '')
+ '</span></span></li>';
}
html += rows.map(function (b) { return rowHtml(b); }).join('');
if (state.rows.length > MAX_ROWS) {
html += '<li class="lan-cut">列表截断到 ' + MAX_ROWS + ' 行,实际 ' + state.rows.length + ' 台</li>';
}
if (!rows.length && !state.scanning) {
html += '<li class="lan-empty">本轮没扫到板子。别急着当成"板子没了",先看下面的计数与反证列表:</li>';
}
if (lastListHtml !== html) { list.innerHTML = html; lastListHtml = html; }
}
var misc = panelEl.querySelector('.lan-misc');
if (misc) {
var m = '';
if (state.unknowns.length) {
m += '<div class="lan-block"><b>不可判定 ' + state.unknowns.length + ' 个</b>'
+ '(连不上 / 读超时 / 形状合但落不进判据)——超时不等于"不在",'
+ 'ECAT 环跑与刷写期间设备 httpd 本来就可能不响应。<ul>'
+ state.unknowns.slice(0, MAX_ROWS).map(function (u) {
return '<li>' + escapeHtml(u.ip) + ' — ' + escapeHtml(u.reason || '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.rejected.length) {
m += '<div class="lan-block"><b>反证排除 ' + state.rejected.length + ' 个</b>(有证据不是板子,不是"没扫到")<ul>'
+ state.rejected.slice(0, MAX_ROWS).map(function (r) {
return '<li>' + escapeHtml(r.ip) + ':' + (r.port || 80) + ' — '
+ escapeHtml(r.reason || '') + (r.head ? ' 「' + escapeHtml(String(r.head).slice(0, 60)) + '」' : '') + '</li>';
}).join('') + '</ul></div>';
}
if (state.skipped.length) {
m += '<div class="lan-block"><b>剔掉 ' + state.skipped.length + ' 个</b><ul>'
+ state.skipped.map(function (s) {
return '<li>' + escapeHtml(String(s[0])) + ' — ' + escapeHtml(String(s[1])) + '</li>';
}).join('') + '</ul></div>';
}
if (state.local) {
m += '<div class="lan-block">本机:' + escapeHtml(state.local.default_src_ip || '?')
+ ' → 段 ' + escapeHtml(state.local.cidr || '(取不到)')
+ ';本机地址 ' + escapeHtml((state.local.local_ips || []).join(', ') || '—') + '</div>';
}
m += '<div class="lan-block lan-hint">两块同款板除了 IP 无法区分(固件不上报 mac/chip_id)。'
+ '认领办法:选中一行看「详情」的 uptime,或按板上复位键后立刻快扫——新上电那台 uptime &lt; 30s。'
+ 'AP 回退的板子自称 192.168.31.1:PC 还连着路由器 WLAN 时它在三层上不可达,'
+ '要真人连热点 ESP32_OTA_RELAY 才探得到。</div>';
if (misc.innerHTML !== m) misc.innerHTML = m;
}
renderNote();
var busy = panelEl.querySelector('[data-act="fast"]');
if (busy) busy.disabled = state.scanning;
var full = panelEl.querySelector('[data-act="full"]');
if (full) full.disabled = state.scanning;
var cancelBtn = panelEl.querySelector('[data-act="cancel"]');
if (cancelBtn) cancelBtn.style.display = state.scanning ? '' : 'none';
}
/* ───────────────────────── 挂载 ───────────────────────── */
var PANEL_HTML =
'<div class="lan-mask" role="dialog" aria-modal="true" aria-label="局域网设备发现">'
+ '<div class="lan-box" tabindex="-1">'
+ '<div class="lan-head"><span>设备发现</span>'
+ '<span class="lan-ep"></span>'
+ '<button class="btn btn-ghost btn-sm" data-act="close">关闭 (Esc)</button>'
+ '</div>'
+ '<div class="lan-body">'
+ '<div class="lan-tools">'
+ '<button class="btn btn-primary btn-sm" data-act="fast">快扫</button>'
+ '<button class="btn btn-outline btn-sm" data-act="full">全段扫描</button>'
+ '<button class="btn btn-ghost btn-sm" data-act="cancel">取消</button>'
+ '<span class="lan-field"><label class="form-label" for="lanCidr">CIDR</label>'
+ '<input class="input lan-cidr" id="lanCidr" type="text" placeholder="留空 = 本机 /24"></span>'
+ '<label class="lan-check"><input class="lan-pub" type="checkbox"> 允许公网段</label>'
+ '</div>'
+ '<div class="lan-progress"></div>'
+ '<ul class="lan-list" role="listbox" aria-label="发现的设备"></ul>'
+ '<div class="lan-note"></div>'
+ '<div class="lan-misc"></div>'
+ '</div>'
+ '</div>'
+ '</div>';
function bindPanel() {
panelEl.addEventListener('click', async function (ev) {
/* 遮罩空白处(就是 .lan-mask 自己)= 收起;行内按钮先被 data-act 接走。 */
if (ev.target && ev.target.classList && ev.target.classList.contains('lan-mask')) {
state.open = false;
render();
return;
}
var t = ev.target.closest ? ev.target.closest('[data-act],[data-ip]') : null;
if (!t) return;
var act = t.getAttribute('data-act');
var ip = t.getAttribute('data-ip') || '';
if (act === 'close') { state.open = false; render(); return; }
if (act === 'fast') { runScan('fast'); return; }
if (act === 'full') {
var c = panelEl.querySelector('.lan-cidr');
state.cidr = c && c.value ? c.value.trim() : '';
runScan('full');
return;
}
if (act === 'cancel') {
state.cancel = true;
say('warn', '取消中:不再发下一批;在途那批(≤' + BATCH_IPS + ' 个地址)仍会跑完');
render();
return;
}
if (act === 'detail') {
say('info', '取 ' + ip + ' 的完整 status…');
var r = await directProbe(ip, '/system/status', 3000);
if (r.ok && r.json) {
state.detail[ip] = r.json;
state.note = null;
} else {
state.note = { kind: 'warn', text: ip + ' 完整 status 取不到:'
+ (r.name || ('HTTP ' + r.http)) + ' ' + (r.msg || ''), at: Date.now() };
}
render();
return;
}
if (act === 'direct') {
var d = await directProbe(ip, PROBE_PATH, 2500);
state.direct[ip] = d;
render();
return;
}
/* 行本体 = 选择。唯一的写手是 deviceLink.selectDevice,门也在它里面。 */
if (ip) {
var rec = { ip: ip, source: 'lan' };
var det = state.detail[ip];
if (det && det.version) { rec.version = det.version; rec.git_hash = det.git_hash; }
var res = deviceLink.selectDevice(rec);
if (!res.ok) { say('error', '未切换:' + (res.reason || '未知原因')); return; }
say('info', '已选 ' + ip + ' —— 页面能不能连它,看顶栏灯(Owner 下一次 1Hz 轮询即出,≤1s)');
lastListHtml = null;
render();
}
});
var cidrIn = panelEl.querySelector('.lan-cidr');
if (cidrIn) {
cidrIn.addEventListener('change', function () { state.cidr = cidrIn.value.trim(); });
}
var pub = panelEl.querySelector('.lan-pub');
if (pub) {
pub.addEventListener('change', function () { state.allowPublic = pub.checked; });
}
}
function mountHost(anchor) {
panelEl = document.createElement('div');
panelEl.innerHTML = PANEL_HTML;
(anchor || document.body).appendChild(panelEl);
bindPanel();
/* 弹出式:遮罩铺满 + 周围高斯模糊;点遮罩空白处 / Esc / 关闭按钮都收起。 */
function openPanel() {
state.open = true;
render();
var box = panelEl.querySelector('.lan-box');
if (box && box.focus) { try { box.focus({ preventScroll: true }); } catch (_) { box.focus(); } }
if (!state.scan && !state.scanning) runScan('fast');
}
function closePanel() { state.open = false; render(); }
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && state.open) { state.cancel = true; closePanel(); }
});
pillEl = document.getElementById('lanPill');
pillTxtEl = document.getElementById('lanPillTxt');
var btn = document.getElementById('lanScanBtn');
if (btn) {
btn.addEventListener('click', function () {
if (state.open) closePanel(); else openPanel();
});
}
/* 外壳是扫描的宿主:iframe 里的入口只发消息叫它开面板。 */
window.addEventListener('message', function (ev) {
var d = ev && ev.data;
if (!d || d.__dcuLan !== 'open') return;
openPanel();
});
/* Owner 判离线后的一次性提候选:只扫种子,绝不自动改端点。 */
deviceLink.onAutoScan(function () {
if (state.scanning) return;
var now = Date.now();
if (now - state.autoScanned < 60000) return;
state.autoScanned = now;
say('warn', '当前端点连续拉不到 status,自动补一轮快扫(只列候选,不自动换设备)');
runScan('fast');
});
deviceLink.subscribe(function () { renderPill(); });
render();
setInterval(function () {
if (state.open) render(); else renderPill();
}, 2000);
}
function mountGuest() {
var btn = document.getElementById('lanScanBtnPage') || document.getElementById('lanScanBtn');
if (!btn) return;
btn.addEventListener('click', function () {
try {
window.parent.postMessage({ __dcuLan: 'open' }, window.location.origin);
} catch (e) {
say('error', '叫不开外壳面板:' + (e && e.message ? e.message : e));
}
});
}
function boot() {
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
/* file:// 下 localStorage 与 http://127.0.0.1:2625 是两份,flashBusyNow()
* 会静默失效、门成空的。不挂载,直接说清楚怎么打开。 */
var body = document.body;
if (body) {
var w = document.createElement('div');
w.className = 'lan-note lan-note-error';
w.textContent = '设备发现未挂载:页面必须经本地服务打开(双击 esp32_app.vbs,或 http://127.0.0.1:2625/tabs.html)。';
body.insertBefore(w, body.firstChild);
}
return;
}
if (window.self === window.top) {
mountHost(document.getElementById('lanMount'));
} else {
mountGuest();
}
}
boot();
+2 -1
View File
@@ -430,7 +430,8 @@ setTimeout(function () {
ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
/* 不在这里说"IP 已修改":写入可能已被 deviceLink.setIp 的校验/刷写门拒掉,
那句准确的成功/失败日志由 js/app.js 的同一个 change 事件负责,别重复播报。 */
if (!statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
scheduleStatusPoll(0);
+33
View File
@@ -105,3 +105,36 @@ export function setQuietMode(quiet) {
document.documentElement.dataset.dcuCalm = v;
}
}
/**
* @description 更新数据格右上角来源角标(WiFi / ECAT / BLE)。
* 双通路无感切换:哪条链路**最后写入**本格,角标就标哪条(last-writer-wins)。
* 同格若两路都在写,后到的会盖掉先到的来源——切换瞬间角标跟着变是预期行为。
* @param {string|Element} cell 带 data-cell 的格子,或格子内任意元素
* @param {'wifi'|'ecat'|'ble'|'off'} src 来源
*/
export function setCellSource(cell, src) {
var root = cell;
if (typeof cell === 'string') {
root = document.querySelector('[data-cell="' + cell + '"]');
} else if (cell && cell.nodeType === 1 && !cell.classList.contains('src-badge')) {
root = typeof cell.closest === 'function' ? cell.closest('[data-cell]') : null;
}
if (!root || !root.querySelector) return;
/* 不用 :scope —— 部分环境对它支持不稳,角标是格子内唯一直属 .src-badge */
var badges = root.getElementsByClassName('src-badge');
var badge = badges && badges[0];
if (!badge) return;
/* dual = WiFi 与 ECAT 同时在线(双通路) */
var map = {
wifi: 'WiFi', ecat: 'ECAT', ble: 'BLE', off: '--',
dual: 'WiFi·ECAT',
};
var label = map[src] || '--';
var kind = map[src] ? src : 'off';
/* dual 用 wifi 同色系加粗边,避免第三套色 */
var cls = 'src-badge src-' + (kind === 'dual' ? 'dual' : kind);
if (badge.className !== cls) badge.className = cls;
if (badge.textContent !== label) badge.textContent = label;
if (root.dataset.src !== kind) root.dataset.src = kind;
}
+33 -12
View File
@@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js";
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
import {
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
} from "./transport.js?v=20260923i";
} from "./transport.js?v=20261011b";
import { deviceLink } from "../common/device-link.js";
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
@@ -52,7 +52,7 @@ import {
} from "./refresh-rate.js";
import { createScopeSys } from "./scope.js?v=20260924f";
import { createSeqSys } from "./seq.js?v=20260924f";
import { createOtaSys } from "./ota.js?v=20260924g";
import { createOtaSys } from "./ota.js?v=20261011c";
/* ─── 控制模式定义 ─── */
@@ -780,7 +780,9 @@ function bytesToHexStr(bytes) {
const GD32_ERR_DESC = {
0xE0: "payload 过短", 0xE1: "通道号非法",
0xE2: "BUS_OFF(总线离线)", 0xE3: "TEC 过高",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xEE: "SPI 帧解析失败",
0xE4: "邮箱满(对端不 ACK)", 0xE5: "参数无效", 0xE6: "只听不发(该通道收发器已 Silent,接收仍正常)",
0xE7: "该通道正被电机 OTA 独占(OTA 结束自动恢复;不是总线故障,重试也没用)",
0xEE: "SPI 帧解析失败",
0xFF: "无有效 SPI 响应(陈旧/空帧,非总线故障)",
};
/* -3 仅表示“GD32 未回 ACK”;是否真为总线故障看 gd32_err:0xE2/0xE3 才是总线,0xFF 是陈旧 SPI 帧 */
@@ -829,6 +831,14 @@ async function sendBatch(frames, opts) {
await sleep(backoff);
return sendBatch(frames, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):确定性拒发,不是总线故障。
* 不冷却也不重试——冷却治不了,要人把静默切回去;只停周期与序列。 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -906,6 +916,13 @@ async function sendBurst(actions, opts) {
await sleep(backoff);
return sendBurst(actions, Object.assign({}, opts, { retried: true }));
}
if (gerr === 0xE6) {
/* 只听不发(收发器 Silent):同 sendBatch,确定性拒发,不冷却不重试 */
stopRepeat(true);
stopSeq("该通道处于只听不发,命令序列已停止", true);
toast("发送被拒 — " + GD32_ERR_DESC[gerr] + ",序列已停止(可在 CAN 监控页切回发送)", "warn");
return { ok: false, results: results, txSilent: gerr, desc: GD32_ERR_DESC[gerr] };
}
if (gerr === 0xE2 || gerr === 0xE3) {
/* BUS_OFF / TEC 高:停止发送,3s 冷却 */
state.busCooldownUntil = nowMs() + 3000;
@@ -1117,16 +1134,16 @@ function applyTransportUi() {
if (show) ad.value = tp.getAdapter();
}
/* OTA 只有 WiFi 链路有(ESP32 固件上传 + GD32 本地 relay)。
* 不支持时禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清"换回 WiFi 链路即可用"。 */
/* 电机 OTA 由传输层给整套钩子(WiFi = ESP32 上传 + SPI 中继;EtherCAT = CoE 邮箱
* 灌进 GD32 暂存区)。哪条链路缺钩子就禁用页签而不是隐藏:隐藏会让人以为功能没了,
* 禁用 + title 能说清缺在哪。 */
const otaBtn = document.querySelector('.tab-btn[data-tab="ota"]');
if (otaBtn) {
const off = !tp.supportsOta;
const off = !tp.supportsOta || typeof tp.otaUploadAttempt !== "function";
otaBtn.disabled = off;
otaBtn.classList.toggle("tab-disabled", off);
otaBtn.title = off
? "电机 OTA 仅 WiFi 链路支持(需 ESP32 固件上传 + GD32 relay)——切回 WiFi 链路后可用"
? "当前链路没有电机 OTA 通路(需要固件进料 + GD32 relay 两套钩子)"
: "";
/* 当前正停在 OTA 页而链路不支持 → 退回配置页,避免面对一个点不动的界面 */
if (off && otaBtn.classList.contains("active")) {
@@ -2282,9 +2299,11 @@ function busStateText(bs) {
if (bs === 0) return "ACTIVE";
const parts = [];
if (bs & 1) parts.push("INIT");
if (bs & 2) parts.push("SLEEP");
if (bs & 2) parts.push("控制器SLEEP");
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("只听不发");
return parts.length ? parts.join("|") : ("0x" + bs.toString(16));
}
@@ -2344,9 +2363,11 @@ function onFeedback(data) {
busEl.textContent = bsText;
const isBusOff = valid && ch.bus_state != null && (ch.bus_state & 8);
const isPassive = valid && ch.bus_state != null && (ch.bus_state & 4);
busEl.style.color = isBusOff ? "var(--danger)" : isPassive ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const isSilent = valid && ch.bus_state != null && (ch.bus_state & 32);
busEl.style.color = isBusOff ? "var(--danger)" : (isPassive || isSilent) ? "var(--warn)" : (valid ? "var(--ok)" : "var(--fg-muted)");
const sbLed = $("#sbLed");
sbLed.className = "led " + (isBusOff ? "led-err" : (valid ? "led-on" : ""));
/* 静默=能收不能发,灯不能继续给"全绿":与右侧文字同源判定 */
sbLed.className = "led " + (isBusOff ? "led-err" : isSilent ? "led-warn" : (valid ? "led-on" : ""));
}
/* ═══════════════════════ 控制模式联动 ═══════════════════════ */
@@ -3069,7 +3090,7 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
* 实现见 js/motor/ota.js(P1 重做:机械外提)。 */
const otaSys = createOtaSys({
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
});
+160 -101
View File
@@ -7,7 +7,7 @@
export function createOtaSys(ctx) {
const {
state, $, toast, showModal, escapeHtml, sleep, canId, hexId, nodeHex, bytesToHexStr,
sendBatch, sendOneFrameWhenFree, getBaseUrl, stopSeq, saveUiState,
sendBatch, sendOneFrameWhenFree, stopSeq, stopRepeat, saveUiState,
ID_GET_VERSION_REQ, parseVersionPayload, setVer,
stopDataPolling, startPolling, fetchMotorVersionNode, waitMotorVersionOnTick,
} = ctx;
@@ -223,13 +223,14 @@ function resetOtaUi() {
* 后端 motor_ota_state_t 不含 upload/boot 阶段(纯前端驱动),后端状态从 handshake_req 起。
* 顺序:上传固件(stage0) → 进BOOT(stage1) → 触发转发 → GD32 握手(stage2)/擦除(stage3)/传输(stage4)/完成(stage5)。 */
const OTA_STATE_STAGE = {
idle: -1, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finished: 5, error: -1,
idle: -1, staging: 0, pre_cancel: 2, handshake_req: 2, handshake_nonce: 2, handshake_confirm: 2,
erase: 3, transfer: 4, finish_ota: 5, wait_app_run: 5, finishing: 5, finished: 5, error: -1,
};
const OTA_STATE_ZH = {
idle: "空闲", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
idle: "空闲", staging: "灌入从站暂存区…", pre_cancel: "准备中…", handshake_req: "握手中…", handshake_nonce: "密钥协商…",
handshake_confirm: "会话确认…", erase: "擦除 Flash…", transfer: "数据传输",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…", finished: "完成", error: "错误",
finish_ota: "等待跳转…", wait_app_run: "等新 APP 运行确认(0AAC)…",
finishing: "已刷完,正在恢复过程数据通路…", finished: "完成", error: "错误",
};
$("#otaFileBtn").addEventListener("click", async () => {
@@ -289,24 +290,51 @@ $("#otaFileInput").addEventListener("change", async (e) => {
toast(`固件已加载: ${file.name}`, "ok");
});
$("#otaStartBtn").addEventListener("click", startOta);
$("#otaStartBtn").addEventListener("click", () => {
/* startOta 是 async:任何逃逸出来的异常都会把 ota.running 永久留在 true,
* 之后每次点击都在入口 return —— 完全静默。这里兜最后一道:异常必须落日志,
* 并且走 otaDone() 复位状态。 */
Promise.resolve().then(startOta).catch((e) => {
otaLog(`<span class="log-err">[异常]</span> OTA 流程抛出未捕获异常:` +
escapeHtml(e && e.message ? e.message : String(e)) + `,状态已复位,可重新点击`);
toast("OTA 异常,详见 OTA 日志", "err");
otaDone();
});
});
$("#otaCancelBtn").addEventListener("click", cancelOta);
async function startOta() {
if (ota.running) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
/* 电机 OTA 依赖 ESP32 固件的 /motor/ota* 上传 + GD32 本地 relay,
* EtherCAT 链路上没有这条通路(过程数据只跑 CAN 帧)。这里必须硬拦:
* 否则会去打设备 IP 的 HTTP,在只连了网线的场合表现为莫名超时。 */
if (!state.tp.supportsOta) {
toast("当前链路(" + state.tp.label + ")不支持电机 OTA —— 请切回 WiFi 链路", "err");
/* 每个拒绝启动的出口都往 OTA 日志区写一行原因。toast 只显示 3 秒,
* 而 ota.running 卡死时原先是完全静默的 return —— 表现就是"按钮能点、
* 日志一个字都不加",无从定位。 */
if (ota.running) {
otaLog('<span class="log-err">[未启动]</span> 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' +
'点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。');
return;
}
if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; }
if (!state.connected) {
otaLog('<span class="log-err">[未启动]</span> 设备未连接(state.connected=false),本次点击已忽略。');
toast("设备未连接", "err"); return;
}
/* 电机 OTA 的进料通路按链路不同,但收尾都是同一个 GD32 relay 引擎:
* WiFi → POST ESP32 /motor/ota 上传 + SPI 0xD0/0xD1 喂数
* EtherCAT → CoE 0x2511 邮箱灌进 GD32 暂存区(只插网线也能刷)
* 缺钩子的链路在这里硬拦,不要去打一个必然超时的端点。 */
if (!state.tp.supportsOta || typeof state.tp.otaUploadAttempt !== "function") {
otaLog('<span class="log-err">[未启动]</span> 当前链路 ' + escapeHtml(String(state.tp.label || state.tpId)) +
' 没有电机 OTA 通路。');
toast("当前链路(" + state.tp.label + ")没有电机 OTA 通路", "err");
return;
}
if (!ota.file) {
otaLog('<span class="log-err">[未启动]</span> 未选择固件文件。');
toast("请先选择固件 (.bin)", "warn"); return;
}
/* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。
* node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */
const node = state.selectedNode;
if (node == null || node < 1) {
otaLog('<span class="log-err">[未启动]</span> 未选中目标节点(当前 selectedNode=' + escapeHtml(String(node)) + ')。');
toast("请先在左侧节点列表选中目标电机(node ≥ 1)", "warn");
return;
}
@@ -337,10 +365,18 @@ async function startOta() {
return;
}
/* 弹窗文案按链路口径如实写:WiFi 是「ESP32 回读 flash 算 SHA-256 比对」,
* EtherCAT 是「从站在电机进 BOOT 之前核整镜像 CRC32」。
* 把另一条的校验说法套过来就是假话,用户会按假话决定要不要断电。 */
const verifyText = state.tp.otaIntegrity === "sha256-esp32-flash"
? `先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到 ESP32,设备从 flash 读回算 SHA-256 与本地比对`
: `先把固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 经 EtherCAT 邮箱灌进 GD32 暂存区,逐块 CRC32 + 整镜像 CRC32 由从站核对`;
const bootText = state.tp.otaHostSendsBootFrame
? `再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader`
: `核对通过后由 GD32 在已独占的通道上发 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader`;
const ok = await showModal("开始电机 OTA",
`将先上传固件 ${ota.file.name} (${ota.file.size.toLocaleString()} B) 到设备并校验 SHA-256,` +
`再发送 CANFD_BOOT (0x000${nodeHex(node)}0000) 让节点 ${node} 进入 Bootloader,` +
`由 GD32 本地完成 AES 握手与 CAN 传输。OTA 期间请勿操作电机。`);
`${verifyText},${bootText},由 GD32 本地完成 AES 握手与 CAN 停等传输。` +
`OTA 期间本链路的过程数据与轮询会暂停(其他电机也收不到运动指令),请勿断电。`);
if (!ok) return;
stopRepeat(true);
@@ -370,91 +406,88 @@ async function startOta() {
* OTA 完成后 otaDone→startPolling 恢复。 */
stopDataPolling();
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)── */
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
const fwShaHex = Array.from(new Uint8Array(fwSha256))
.map(b => b.toString(16).padStart(2, "0")).join("");
/* ── 步骤 1:前端先算本地固件 SHA-256(只算一次,重传不变)──
* ★ 必须在 try 内。这句原先落在 try/catch 之外,而 ota.running 已在上面置 true:
* 一旦抛异常(最常见是非安全上下文里 crypto.subtle 为 undefined),本函数以
* 未处理的 rejection 结束,otaDone() 不跑 → ota.running 永久停在 true,
* 之后每次点击都在入口静默 return,界面表现就是"点了没反应"。 */
let fwShaHex = "";
try {
if (!window.crypto || !window.crypto.subtle || !window.crypto.subtle.digest) {
throw new Error("本浏览器上下文没有 WebCrypto(crypto.subtle 不可用):当前源是 " +
location.origin + ",非安全上下文。请用 http://127.0.0.1:2625/tabs.html 打开本页" +
"(桌面 APP 的入口),或改用 https 访问设备。");
}
const fwSha256 = await crypto.subtle.digest("SHA-256", ota.bytes);
fwShaHex = Array.from(new Uint8Array(fwSha256))
.map(b => b.toString(16).padStart(2, "0")).join("");
} catch (e) {
otaLog(`<span class="log-err">[校验]</span> 本地固件 SHA-256 计算失败:${escapeHtml(e.message || e)},OTA 未启动`);
toast("固件校验计算失败,见 OTA 日志", "err");
otaDone();
return;
}
otaLog(`<span class="log-info">[校验]</span> 本地固件 SHA-256: ${fwShaHex}`);
try {
/* ── 步骤 2:上传 + SHA 比对,不通过重传(最多 3 次尝试 = 首发 + 重发 2 次)──
* POST /motor/ota 只收固件写 flash(不触发 SPI),ESP32 收尾从 flash 读回算 SHA 随响应返回;
* 前端比对不一致 = flash 写坏 → 重传(每次上传都全盘擦除重写);
* 连续 3 次不过才中断——此时电机仍在 APP 正常运行,重试/中断都不影响电机。 */
/* ── 步骤 2:进料 + 完整性核对(按链路口径,最多 3 次尝试)──
* 通路交给传输钩子,本文件不再假设「一定能打到设备 IP」:
* WiFi POST /motor/ota → ESP32 写 flash 后读回算 SHA-256,浏览器比对;
* EtherCAT CoE 0x2511 灌进 GD32 暂存区,整镜像 CRC32 由**从站**在电机进 BOOT
* 之前核对(这一步只报「主站是否完整持有镜像」,不冒充比过 SHA)。
* 两种失败都发生在电机仍跑 APP 的阶段:重试与中断都不影响电机。 */
const kUploadMaxAttempts = 3;
let uploadOk = false;
for (let attempt = 1; attempt <= kUploadMaxAttempts && ota.running; attempt++) {
otaLog(`<span class="log-info">[上传]</span> 上传固件到 ESP32 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
let upJson = {};
try {
const upResp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: ota.bytes,
signal: AbortSignal.timeout(15000),
});
upJson = await upResp.json().catch(() => ({}));
if (!upResp.ok) throw new Error(upJson.error || `HTTP ${upResp.status}`);
} catch (e) {
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`);
if (attempt < kUploadMaxAttempts) await sleep(300);
continue;
}
otaLog(`<span class="log-ok">[上传]</span> 固件已存储 (${escapeHtml(String(upJson.size || ota.bytes.length))} B)`);
/* 比对:本地 SHA vs ESP32 从 flash 读回算的 SHA(upJson.sha256) */
if (!upJson.sha256 || upJson.sha256.length !== 64) {
/* 旧 ESP32 固件无 sha256 字段:无法本地比对,且旧固件 relay 还要求 body(新前端已不发),
* 需刷新 ESP32 固件;继续尝试,若 relay 被拒会报错中止。 */
otaLog(`<span class="log-err">[校验]</span> 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`);
otaLog(`<span class="log-info">[上传]</span> ${escapeHtml(String(state.tp.label || state.tpId))} 通路进料 (CH${ch}),第 ${attempt}/${kUploadMaxAttempts} 次…`);
const up = await state.tp.otaUploadAttempt(ota.bytes, fwShaHex, { ch, node });
if (up && up.ok) {
otaLog(`<span class="log-ok">[上传]</span> ${escapeHtml(String(up.note || "固件已就位"))}` +
(up.size ? ` (${Number(up.size).toLocaleString()} B)` : ""));
uploadOk = true;
break;
}
if (upJson.sha256.toLowerCase() === fwShaHex) {
otaLog(`<span class="log-ok">[校验]</span> flash SHA 比对一致,允许进 BOOT`);
uploadOk = true;
break;
}
otaLog(`<span class="log-err">[校验]</span> 第 ${attempt} 次 flash SHA 不一致(device=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`);
otaLog(`<span class="log-err">[上传]</span> 第 ${attempt} 次未通过:` +
escapeHtml(String((up && up.note) || "未知原因")) +
(attempt < kUploadMaxAttempts ? ",重试…" : ""));
if (attempt < kUploadMaxAttempts) await sleep(300);
}
if (!uploadOk) {
throw new Error(`上传/SHA 校验连续 ${kUploadMaxAttempts} 次失败,中断(电机未进 BOOT)`);
throw new Error(`进料/完整性核对连续 ${kUploadMaxAttempts} 次未通过,中断(电机未进 BOOT)`);
}
/* ── 步骤 3:发 0x000N0000 进 BOOT 帧(DLC=0,fire-and-forget)──
* 固件已收完且 flash SHA 比对通过,此刻才让电机离开 APP。
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
setOtaStage(1); /* 进入 BOOT 阶段 */
const bootId = canId(node, 0x0000); /* 0x000N0000 */
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
if (state.tp.otaHostSendsBootFrame) {
/* ── 步骤 3(WiFi 链路的既有顺序):上位机发 0x000N0000,再触发中继 ──
* APP 侧 handle_boot_message() 只看 ID 的 bits[19:16] == node_id 即跳 BOOT。
* 电机收到后立即 jumpToBOOT()(can_deinit + 复位),来不及回 CAN ACK,
* 因此「GD32 未确认发送 / 总线无应答」是预期现象,绝不能当作进 BOOT 失败——
* 之前正因把「未 ACK」当失败而中止,导致「电机其实已进 BOOT 却被判为失败」。
* 是否真进 BOOT 交给后续 GD32 relay 握手(REQ→00AC)判定,握手超时才算失败。 */
const bootId = canId(node, 0x0000); /* 0x000N0000 */
otaLog(`<span class="log-info">[进BOOT]</span> 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`);
try {
const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true });
if (r.ok) {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发送并确认`);
} else {
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
}
} catch (e) {
otaLog(`<span class="log-info">[进BOOT]</span> 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`);
} else {
/* EtherCAT:进 BOOT 帧由 GD32 自己在**已加锁**的通道上发。这条链路的周期运动帧
* 与它挤同一批 CAN 邮箱;先解锁让上位机发帧,等于把窗口留给运动帧(单发模式
* 不重传,固件包一旦被 abort 就是白等 100ms 重发,握手期更会被打成 0x05)。 */
otaLog(`<span class="log-info">[进BOOT]</span> 交 GD32 在已独占的 CH${ch} 上发 CANFD_BOOT 0x000${nodeHex(node)}0000(运动帧已被通道锁挡住)`);
}
otaLog(`<span class="log-ok">[进BOOT]</span> 命令已发出,立即触发转发…`);
/* ── 步骤 4:POST /motor/ota/relay 触发 SPI START + 喂数据(纯启动扳机,无 body)──
* SHA 校验已在步骤 2 闭环(比对+重传),relay 不再传/比 SHA。
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。
* SPI 0xC0(进 BOOT) 与 0xD0(START) 经 SPI worker 串行,顺序有保证。 */
const rResp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${ch}`, {
method: "POST",
signal: AbortSignal.timeout(8000),
});
const rJson = await rResp.json().catch(() => ({}));
if (!rResp.ok) throw new Error(rJson.error || `HTTP ${rResp.status}`);
otaLog(`<span class="log-ok">[转发]</span> GD32 开始握手…`);
/* ── 步骤 4:触发转发/灌写(纯启动扳机,不带镜像内容)──
* 完整性核对已在上一步与从站启动前闭环,这里只下发起止指令。
* 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 */
await state.tp.otaStartRelay(ch, node);
otaLog(`<span class="log-ok">[转发]</span> 已触发:从站核对→锁通道→进 BOOT→起握手…`);
startOtaPoll();
} catch (e) {
otaLog(`<span class="log-err">[失败]</span> ${escapeHtml(e.message || e)}`);
@@ -469,10 +502,10 @@ function startOtaPoll() {
let lastLoggedPkt = -1;
ota.pollTimer = setInterval(async () => {
try {
const resp = await fetch(getBaseUrl() + "/motor/ota/status", {
signal: AbortSignal.timeout(2000), cache: "no-store",
});
const st = await resp.json();
/* 进度按链路取:WiFi = GET /motor/ota/status(ESP32 缓存 GD32 的 0xD2 推送);
* EtherCAT = native 发布的会话读数(不碰总线),状态已由传输层翻译成
* 同一套字符串,本文件不需要知道料是怎么进去的。 */
const st = await state.tp.otaStatus();
updateOtaUi(st, startTime);
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
if (st.state && st.state !== lastState) {
@@ -497,11 +530,28 @@ function startOtaPoll() {
if (st.state === "finished" || st.state === "error" || st.state === "idle") {
clearInterval(ota.pollTimer);
ota.pollTimer = null;
if (st.state === "finished") {
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${Number(st.packets_acked) || 0} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
const pkTotal = Number(st.total_packets) || 0;
const pkAcked = Number(st.packets_acked) || 0;
/* finished 必须有真实传输撑着:包数 > 0 且全部 ACK。
* 只看 state 会放行「一帧都没上线却回了 finished」这类假成功——
* 2026-10-11 实测就是这样:从站 OOM 拒收,前端把 HTTP 信封的 ok 当成刷写结果,
* 0 包也报「OTA 成功」,因为查版本必然答得上(电机根本没被动过)。 */
if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) {
otaLog(`<span class="log-ok">[完成]</span> 传输完成 · ${pkAcked}/${pkTotal} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`);
} else if (st.state === "finished") {
otaLog(`<span class="log-err">[可疑]</span> 回执 finished 但没有真实传输(acked=${pkAcked}/total=${pkTotal}),不判成功` +
`<span class="log-info"> · </span>看 [状态] 行与 native 读数:料没进去或启动被挡`
+ (st.note ? ` · ${escapeHtml(String(st.note))}` : ""));
toast("OTA 未发生真实传输,不能算成功", "err");
} else if (st.state === "error") {
const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : "";
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${errHint}`);
/* 两种病分开报:低字节是电机 BOOT 回来的协议码(查电机与 CAN);
* session_error 高字节是 DCU 本端把料挡了(0x8x,查链路与参数)。
* 混成一句「OTA 失败 0xXX」历史上会把人支去查一台没接的电机。 */
const sess = Number(st.session_error) || 0;
const sessHint = sess ? " · 本端会话码 0x" + sess.toString(16).toUpperCase() + "(料被 DCU 挡下,不是电机报错)" : "";
const note = st.note ? " · " + escapeHtml(String(st.note)) : "";
otaLog(`<span class="log-err">[错误]</span> 代码 0x${Number(st.error || 0).toString(16).toUpperCase()}${sessHint}${errHint}${note}`);
toast("电机 OTA 失败 (0x" + Number(st.error || 0).toString(16) + ")", "err");
}
/* ★ 正常完成(finished)不发 cancel:电机 APP 已跳转启动,此时发 cancel
@@ -509,10 +559,19 @@ function startOtaPoll() {
* CANFD_BOOT_ID(base_id=0x0000) → enter_silent_mode 10s → APP 看似卡死。
* 只有 error/idle/异常退出才发 cancel 兜底清理设备端残留状态。 */
otaDone(st.state !== "finished");
if (st.state === "idle") {
/* 会话回到空闲却没给出 finished/error 结论:这条出口原先是静默的,
* 表现就是"进度条停住、日志不再动"。至少把「没有结论」这件事说出来,
* 别让人去猜是刷完了还是根本没开始。 */
const idleNote = st.note ? " · " + escapeHtml(String(st.note)) : "";
otaLog("<span class='log-err'>[已停]</span> 会话回到空闲且未给出结果(多为启动指令未生效或已被清理),可重新点开始" + idleNote);
}
/* ★ finished = GD32 已收到电机新 APP 上电自主发的 [0A AC] 运行确认
* (kWaitAppRun → kFinished,无确认 3s 超时报 0x25)——APP 已确认在跑。
* verifyNewApp 只做最后一步:查版本号核对并回填 UI。 */
if (st.state === "finished") verifyNewApp(ota.node);
/* 只有确认过真实传输才去核对版本:否则查到的就是电机现在跑的旧版本,
* 那会让「没刷」看起来像「刷成功了」——自证判据。 */
if (st.state === "finished" && pkTotal > 0 && pkAcked >= pkTotal) verifyNewApp(ota.node);
}
} catch (e) { /* 轮询瞬时失败静默 */ }
}, OTA_POLL_MS);
@@ -599,12 +658,13 @@ async function cancelOta() {
* 导致后续 ESP32/GD32 OTA 全被"电机 OTA 进行中"拒绝。 */
let ok = false;
for (let i = 1; i <= 3 && !ok; i++) {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(5000) });
ok = r.ok;
} catch (e) {
otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(e.message || e)}`);
}
/* 走传输钩子:WiFi 是 POST /motor/ota/cancel,EtherCAT 是请 native worker 下一拍
* 写 0x2510:01=3(从站发 CAN 取消帧并释放通道锁)。单次失败不能静默吞掉,
* 否则设备端残留(ESP32 feedTask / GD32 会话与通道锁),后续 OTA 全被拒。 */
let r = null;
try { r = await state.tp.otaCancel(); } catch (e) { r = { ok: false, note: e.message || String(e) }; }
ok = !!(r && r.ok);
if (!ok) otaLog(`<span class="log-err">[取消]</span> 第 ${i} 次失败:${escapeHtml(String((r && r.note) || "未确认"))}`);
if (!ok && i < 3) await sleep(500);
}
if (!ok) otaLog(`<span class="log-err">[取消]</span> 3 次均失败,设备端 feedTask 可能残留,建议刷新页面或等待自动超时`);
@@ -637,9 +697,8 @@ function otaDone(sendCancel = true) {
/* 仅在需要时发 cancel 兜底清理设备端 motorOtaIsActive。
* 正常完成(finished)时 feedTask 已自行退出 + GD32 已进 kFinished,
* 不需要 cancel;发了反而干扰刚跳转的电机 APP(见上注释)。 */
if (sendCancel && state.connected) {
fetch(getBaseUrl() + "/motor/ota/cancel", { method: "POST", signal: AbortSignal.timeout(3000) })
.catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
if (sendCancel && state.connected && typeof state.tp.otaCancel === "function") {
state.tp.otaCancel().catch(() => { /* 兜底请求失败静默,不阻塞 UI 恢复 */ });
}
}
+146 -13
View File
@@ -55,6 +55,31 @@ import {
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
const MOT_SLOTS_PER_PDO = 9;
/** GD32 `MotorOtaRelay::State` 的数字状态 → WiFi 链路那套字符串(ota.js 的阶段条与
* 日志只认字符串,两条链路必须翻成同一个口径,否则 UI 要为两种传输各写一遍判据)。
* 序号改了要同步 DCU_APP sys/motor_ota_relay.h 的枚举 —— 那边是唯一真源。 */
const RELAY_STATE_NAMES = [
"idle", "handshake_req", "handshake_nonce", "handshake_confirm", "erase",
"transfer", "finish_ota", "finished", "error", "pre_cancel", "wait_app_run",
];
/** 字节 → 小写 hex(native 的 hex_decode 两种大小写都吃,这里与 WiFi 上传的字节流口径无关)。 */
function bytesToHexLower(u8) {
let s = "";
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0");
return s;
}
/** 电机 OTA(EtherCAT 灌写)正在占用 exclusive 总线 —— 外壳 Owner 与本页的
* motor/start 自愈都读同一个标志,判据只在这一处。
* 写成 typeof 守卫是因为 scripts/test_motor_transport.mjs 在 Node 里直接 import
* 本模块,那里没有 window,裸读会抛 ReferenceError 把整轮契约自测打断。 */
function motorOtaEcatBusy() {
return (typeof window !== "undefined") && !!window.motorOtaEcatBusy;
}
function setMotorOtaEcatBusy(v) {
if (typeof window !== "undefined") window.motorOtaEcatBusy = !!v;
}
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
const MOTOR_SLOT_COUNT = 27;
@@ -367,6 +392,13 @@ export function createEcatTransport(ctx) {
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
async function ensureStarted(reqPeriodMs) {
/* 电机 OTA 灌写中:native worker 正独占总线(g_bus_lock)并停着 PDO 循环。
* 这时候再打 motor/start 只会在锁上排队,排到时把刚恢复的循环又停一遍,
* 表现是刷写结束瞬间链路「假掉线」。判据只在这一处挡,外壳 Owner 那道门
* (device-link 的 bootstrapEcatBus)读同一个 window.motorOtaEcatBusy。 */
if (motorOtaEcatBusy() && !reqPeriodMs) {
return { ok: true, period_ms: periodMs, spin: spinOn, motor_ota_busy: true };
}
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
if (!reqPeriodMs) {
@@ -394,20 +426,19 @@ export function createEcatTransport(ctx) {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD));
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
if (v > 0 && v !== 1) body.period_ms = v;
/* 用户显式设过且落在合法档位内才用;否则回落默认 0.25ms */
if (v > 0 && v >= 0.125 && v <= 4) body.period_ms = v;
else body.period_ms = 0.25;
} catch (_) { body.period_ms = 0.25; }
}
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
/* PDO 周期与 DC SYNC0 同值:周期可随时热改(0.125–4ms),
* 不一致时 GD32 processOutput 消费会拖慢,所以启动时强制对齐。 */
{
let per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
if (per < 0.125) per = 0.25;
if (per > 4) per = 4;
body.period_ms = per;
body.dc_cycle_us = Math.round(per * 1000);
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
body.dc_shift_us = 0;
}
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
@@ -571,8 +602,15 @@ export function createEcatTransport(ctx) {
return {
id: "ecat",
label: "EtherCAT",
/** EtherCAT 侧没有固件上传 + GD32 relay 链路,OTA 页签需禁用 */
supportsOta: false,
/** 电机 OTA:走 CoE 邮箱把固件灌进 GD32 暂存区,中继引擎与 WiFi 链路同一个
* (见下 ota* 四个钩子与 DCU_APP comm/ecat_motor_ota.cpp)。 */
supportsOta: true,
/** 进 BOOT 帧(0x000N0000)由 GD32 在**已加锁**的通道上自己发,上位机不要再发一遍:
* 这条链路的运动帧和它挤同一批 CAN 邮箱,先解锁让上位机发帧等于把窗口留给运动帧。 */
otaHostSendsBootFrame: false,
/** 完整性口径(如实告诉 UI,不冒充 SHA-256 那条):
* crc32-verify-in-slave = 分块 CRC32 + 整镜像 CRC32 都在从站核,核过才让电机擦除。 */
otaIntegrity: "crc32-verify-in-slave",
/** PDO 周期由后台循环独立维持(看门狗要求),不跟界面刷新率联动 */
supportsPeriodSync: false,
@@ -740,7 +778,7 @@ export function createEcatTransport(ctx) {
err: linkOk ? b.drop : null,
tec: null, // 过程数据里没有
rec: null, // 过程数据里没有
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : 0) : null,
bus_state: linkOk ? (b.tx_result === 0xE2 ? 8 : (b.tx_result === 0xE6 ? 32 : 0)) : null,
});
}
return { channels };
@@ -891,14 +929,14 @@ export function createEcatTransport(ctx) {
},
/**
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
* 热换 PDO 周期(毫秒)。合法区间 0.125–4ms(与 UI 档位一致)。
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
*/
async setPdoPeriod(ms) {
let v = Number(ms);
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
if (v < 0.0625) v = 0.25;
if (v < 0.125) v = 0.125;
if (v > 4) v = 4;
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
if (typeof r.period_ms === "number") periodMs = r.period_ms;
else periodMs = v;
@@ -907,6 +945,101 @@ export function createEcatTransport(ctx) {
return periodMs;
},
/* ── 电机 OTA(EtherCAT 邮箱进料)────────────────────────────────
* 通路:这里 → /ecat/api/motor/ota(或桌面壳 pywebview 桥)→ ecat_native 的
* worker → CoE SDO 0x2511 数据窗 / 0x2510 会话 → GD32 48KB SRAM 暂存
* → **从站先核整镜像 CRC32** → 通道锁 + GD32 自己发 0x000N0000 进 BOOT
* → 既有 MotorOtaRelay 停等(AES、单发、重发、0AAC 运行确认一个都没换)。
* 换的只是「明文怎么进 GD32」:WiFi 那条是 ESP32 经 SPI 0xD1 喂,
* 这条是主站经邮箱喂,所以纯网线(ESP32 HTTP 不可达)也能刷电机。
*
* 刷写期间 native worker 独占总线并停掉 PDO 循环 ⇒ 各通道运动帧一起暂停,
* 与 WiFi 模式「OTA 期间请勿操作电机」等效。进度读的是 worker 发布的 volatile
* 全局(op=status 不碰总线),所以轮询频率再高也不会干扰灌写。
*/
/** 一次「上传尝试」:把整份镜像推到主站缓冲。
* WiFi 那条在这步做 ESP32 flash 回读 SHA-256 比对;这条没有对端可回读,
* shaHex 参数用不上(刻意不假装比过),真校验在从站的启动前一步。
* @returns {Promise<{ok:boolean, note:string}>} */
async otaUploadAttempt(bytes, shaHex, opts) {
const ch = (opts && opts.ch) | 0;
const node = (opts && opts.node) | 0;
const size = bytes.length;
unwrap(await api("motor/ota", { op: "open", size, ch, node }, 8000), "开电机灌写会话");
/* push 受 native 请求体 65535B 限制:16KB 二进制 = 32768 hex 字符,余量充足 */
const STEP = 16384;
for (let off = 0; off < size; off += STEP) {
const part = bytes.subarray(off, Math.min(off + STEP, size));
unwrap(await api("motor/ota", { op: "push", hex: bytesToHexLower(part) }, 15000),
"推送镜像分片");
}
const st = unwrap(await api("motor/ota", { op: "status" }, 5000), "读灌写状态");
const recv = Number(st.srv_recv) || 0;
return {
ok: recv === size,
note: recv === size
? `主站已持有 ${size} B(分块 CRC32 已逐块核对,整镜像 CRC 由从站在电机进 BOOT 前核)`
: `主站只收到 ${recv}/${size} B`,
};
},
/** 起会话:worker 后台跑(灌块 → 从站核 CRC → 锁通道 → 进 BOOT → 停等传输)。
* 这一步只负责「派活」,成败由 otaStatus 的终态给结论。 */
async otaStartRelay() {
const r = unwrap(await api("motor/ota", { op: "run" }, 8000), "启动电机灌写");
setMotorOtaEcatBusy(true); /* 挡住外壳 Owner 与本页的 motor/start 重拉 */
return { ok: !!r.ok };
},
/** 进度:把 native 的 stage + 从站 relay 数字状态翻译成 WiFi 那套字符串,
* 好让 ota.js 的阶段条/日志只用一套判据。 */
async otaStatus() {
const st = unwrap(await api("motor/ota", { op: "status" }, 4000), "读电机刷写进度");
const stage = Number(st.stage) || 0;
const relay = Number(st.state) || 0;
const errWord = Number(st.err) || 0;
const sessErr = (errWord >> 8) & 0xFF;
const protoErr = errWord & 0xFF;
let state;
if (stage === 0) state = "idle";
else if (stage === 1) state = "staging"; /* 还在灌从站暂存区,引擎尚未启动 */
else if (stage === 2 && relay === 7) state = "finishing";
/* relay 已 kFinished 但 worker 还没把过程数据循环与 OP 补回来。
* 此刻就报 finished 的话,上层的版本查询会打在「主站在发、从站不在 OP」
* 的空档里,白丢一次应答(实测会表现成「OTA 完成但查不到版本」)。 */
else if (stage === 2) state = RELAY_STATE_NAMES[relay] || "pre_cancel";
else state = st.flash_ok ? "finished" : "error";
if (stage === 3) setMotorOtaEcatBusy(false);
const reason = String(st.reason || "");
return {
state,
error: state === "error" ? (protoErr || sessErr || 0x01) : protoErr,
/* 会话码 0x8x 与电机协议码 0x0x/0x3x 是两种病,必须分开摆出来,
* 否则「从站把料挡了」会被读成「电机报错」。 */
session_error: sessErr,
packets_acked: Number(st.acked) || 0,
total_packets: Number(st.pk_total) || 0,
fw_size: Number(st.claimed) || 0,
stage,
note: reason,
};
},
/** 取消:请 worker 下一拍写 0x2510:01=3,从站发 CAN 取消帧并释放通道锁。
* 等 native 报 running=false 才放开 motorOtaEcatBusy,避免外壳 Owner 在
* 会话还挂着的时候抢去 motor/start。 */
async otaCancel() {
const r = await api("motor/ota", { op: "cancel" }, 5000).catch(() => null);
for (let i = 0; i < 6; i++) {
const st = await api("motor/ota", { op: "status" }, 3000).catch(() => null);
if (!st || !st.running) break;
await new Promise((res) => setTimeout(res, 400));
}
setMotorOtaEcatBusy(false);
return { ok: !!(r && r.ok) };
},
/** 诊断用(控制台 motorDbg.transport)。 */
debug() {
return {
+75 -1
View File
@@ -134,8 +134,12 @@ export function createWifiTransport(ctx) {
return {
id: "wifi",
label: "WiFi / HTTP",
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay),EtherCAT 侧没有这条链路 */
/** OTA 走 ESP32 的 /motor/ota*(固件上传 + GD32 relay) */
supportsOta: true,
/** 进 BOOT 帧由上位机发(既有顺序:先帧后 relay),锁还没上,通道此刻是空的 */
otaHostSendsBootFrame: true,
/** 完整性口径:ESP32 从 flash 读回算 SHA-256,与浏览器算的比 */
otaIntegrity: "sha256-esp32-flash",
/** 0xC5 推送周期可与前端刷新率对齐 */
supportsPeriodSync: true,
@@ -229,6 +233,76 @@ export function createWifiTransport(ctx) {
return data.nodes.find((n) => n.node === node) || null;
},
/* ── 电机 OTA(ESP32 上传 + SPI 中继)────────────────────────────
* 还是原来那三条 HTTP:POST /motor/ota(写 ESP32 flash 并回读算 SHA-256)、
* POST /motor/ota/relay(SPI 0xD0 START + feedTask 喂数)、GET /motor/ota/status。
* 收进传输契约只有一个原因:ota.js 原先硬写 getBaseUrl(),等于假设「设备 IP
* 一定打得通」——纯 EtherCAT 场合那条前提不成立。行为与外提前完全一致。 */
/** 一次上传尝试:POST 整份镜像,拿设备回读的 flash SHA-256 与浏览器算的比。
* 不抛错——失败是这里的正常出口(ota.js 据此决定重传,最多 3 次)。 */
async otaUploadAttempt(bytes, shaHex, opts) {
const ch = (opts && opts.ch) | 0;
let json = {};
try {
const resp = await fetch(getBaseUrl() + `/motor/ota?ch=${ch}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes,
signal: AbortSignal.timeout(15000),
});
json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
const size = Number(json.size) || bytes.length;
if (!json.sha256 || String(json.sha256).length !== 64) {
/* 旧 ESP32 固件不回 SHA:没有对端读数可比,如实说明后放行(沿用既有行为),
* 后续 relay 若被拒会由那条报错兜住。 */
return { ok: true, size, note: "设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对" };
}
const dev = String(json.sha256).toLowerCase();
return {
ok: dev === String(shaHex).toLowerCase(),
size,
note: dev === String(shaHex).toLowerCase()
? "flash 回读 SHA-256 一致"
: `flash SHA 不一致(设备 ${dev.slice(0, 16)}…)`,
};
},
/** 触发中继:GD32 开始握手,ESP32 起 feedTask 喂 SPI。失败抛错(上层按启动失败处理)。 */
async otaStartRelay(ch) {
const resp = await fetch(getBaseUrl() + `/motor/ota/relay?ch=${(ch | 0)}`, {
method: "POST",
signal: AbortSignal.timeout(8000),
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(json.error || `HTTP ${resp.status}`);
return { ok: true };
},
/** 进度:ESP32 已经给字符串状态,直接透传;session_error/note 是 ECAT 才有的字段,
* 这里补空值,让上层的显示逻辑不用分链路写两遍。 */
async otaStatus() {
const st = await getJson("/motor/ota/status", 2000);
return Object.assign({ session_error: 0, note: "" }, st);
},
/** 兜底清理:让 ESP32 退出 feedTask + GD32 resetToIdle。 */
async otaCancel() {
try {
const r = await fetch(getBaseUrl() + "/motor/ota/cancel", {
method: "POST",
signal: AbortSignal.timeout(5000),
});
return { ok: r.ok };
} catch (e) {
return { ok: false, note: (e && e.message) || String(e) };
}
},
/** 把前端刷新周期同步给 GD32(0xC5 主动推送周期),失败静默。 */
async setPeriod(ms) {
const v = Math.max(20, Math.min(65535, Math.round(ms)));
+15 -2
View File
@@ -17,6 +17,8 @@
* reportedIp(data) -> string|null 设备回报 IP(ECAT 恒 null)
* fetchMotorState() -> {have_data, age_ms, active_count, boot_active, motors[]}
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
* bus_state 位图:bit0=init bit1=控制器睡眠 bit2=error_passive bit3=bus_off
* bit4=error_warning bit5=只听不发(收发器 Silent,本端 S 脚)
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
* sendBurst(frames, {suffix,dlc}) -> results[]
* fetchVersionNode(node) -> {node,seq,version,at?}|null
@@ -25,13 +27,24 @@
* teardown() -> void 切走时收尾
* 可选:takeNodeIdReport(node)、debug()
*
* 电机 OTA(supportsOta=true 时必须整套实现;流程只在 ota.js 里写一遍):
* otaHostSendsBootFrame: bool 进 BOOT 帧由上位机发(WiFi)还是 GD32 在已加锁
* 的通道上自己发(EtherCAT)
* otaIntegrity: string 完整性口径,UI 文案照它写,不许互相冒充:
* 'sha256-esp32-flash' | 'crc32-verify-in-slave'
* otaUploadAttempt(bytes, shaHex, {ch, node}) -> {ok, note, size?}
* otaStartRelay(ch, node) -> {ok} 抛错=没起来
* otaStatus() -> {state, error, session_error, packets_acked, total_packets, note}
* state 用 WiFi 那套字符串(ECAT 由传输层翻译)
* otaCancel() -> {ok, note?}
*
* motors[] 每项的字段契约(ECAT 实现负责补平差异):
* {node, ch, online, dlc, rx_count, seq, data(64hex), config_data(48hex|"")}
*/
"use strict";
import { createWifiTransport } from "./transport-wifi.js";
import { createEcatTransport } from "./transport-ecat.js?v=20260923i";
import { createWifiTransport } from "./transport-wifi.js?v=20261011b";
import { createEcatTransport } from "./transport-ecat.js?v=20261011b";
/** localStorage key:记住上次选的传输,刷新页面后保持 */
export const LS_TRANSPORT = "dcu_motor_transport";
+177 -35
View File
@@ -33,7 +33,7 @@
* native 的两级邮箱自愈,最坏 7s+。**R1 后 _ecat_proxy 不对非幂等路径自动重发**
* (假超时/服务刚拉起时返回 503,需用户/脚本显式重试);run 超时给 300s、其余 60s。
*/
import { ECAT_DEFAULT_ADAPTER } from "../common/ecat-bus.js";
import { ECAT_DEFAULT_ADAPTER, runEcatBootstrap } from "../common/ecat-bus.js";
var BOOT_API = '/ecat/api/ota/ecat';
var PUSH_BYTES = 24576; /* 转 HEX 后 49152 字符,留在 native 65535 的 body 上限内 */
@@ -49,6 +49,25 @@ function el(id) { return document.getElementById(id); }
function setTxt(id, t) { var n = el(id); if (n && n.textContent !== t) n.textContent = t; }
function bytes(n) { return (n >>> 0).toLocaleString() + ' B'; }
/**
* @description 写 bootEcatBusy / bootEcatIsBoot 的唯一出口,只为留一条带时刻的读数。
* 为什么收在一处:这两个标志就是外壳 Owner 要不要重拉总线的门
* (js/common/device-link.js 里 `if (window.bootEcatBusy || ...) return;` 与
* `if (window.bootEcatIsBoot === true) return;`)。2026-10-12「交接后 EtherCAT 连不上、
* 要重启才行」要看的就是「这扇门被谁在什么时候按住、按了多久」——散在八处各打一条
* 日志必然漂,所以判据只留一个执行点。
* 只在值真的翻转时打印:反复赋同一个值不是事件,刷屏反而盖掉真读数。
*/
function setEcatFlag(name, val) {
var next = !!val;
if (window[name] === next) return;
window[name] = next;
if (window.addLog) {
window.addLog('I', 'ECAT刷写',
'[门] ' + name + '=' + (next ? 'true' : 'false') + '(t=+' + Math.round(performance.now()) + 'ms)');
}
}
function hexOf(u8, start, end) {
var s = '';
for (var k = start; k < end; k++) s += HEX2[u8[k]];
@@ -98,6 +117,12 @@ async function post(path, body, timeoutMs) {
* /api/open 内部会先 loop_stop 再 ec_init + 重枚举,所以它一定掐掉正在跑 PDO 的电机循环;
* 但刷写期间这不构成互相打架:BOOT 钉在 PREOP,电机页那套 2s 自愈探测器打 /motor/start
* 也进不了 OP,循环起不来 —— 结构上就让位了,不需要额外互斥标志。
* @note 必须带重试(最多 3 次,间隔 2s):commit 后新 APP 起板看到 warm 残值会自己补
* 一发 LAN9252 RST# 对齐 POR(esc_lan9252.cpp:441-446),芯片+PHY 整颗复位 → I210 链路
* 重协商 1~2s,第一拍 open 大概率落在抖动窗口里。而 /api/open 是非幂等路径,代理到点
* 只回 503「请重试一次」、绝不自动重发(ecat_client.py R1/P0)—— 这句"请重试"只能在
* 这里执行;不重试的话后面 6 轮 probe 全打在没重建的会话上,读数必然为空,却会被
* 当成"从站 EtherCAT 没恢复"报给用户(2026-10-09 21:49 就是这么误判的)。
*/
async function ensureBusOpen(force) {
if (!force) {
@@ -112,9 +137,23 @@ async function ensureBusOpen(force) {
var s = localStorage.getItem('dcu_ecat_adapter');
if (s && s.trim()) substr = s.trim();
} catch (_) {}
var r = await post('open', { substr: substr }, 25000);
showLog(r);
return !!(r && r.ok === true);
for (var i = 0; i < 3; i++) {
if (i > 0) {
window.addLog('I', 'ECAT刷写', '重开总线 第 ' + (i + 1) + '/3 次(等链路重协商)…');
await new Promise(function (res) { setTimeout(res, 2000); });
}
var r = null;
try {
/* 35s:必须比代理的 30s 长,否则浏览器先断 —— 那样连后端那句结构化的
* 「请重试一次」都拿不到,只剩一条没信息量的超时。 */
r = await post('open', { substr: substr }, 35000);
} catch (e) {
r = { ok: false, log: ['重开请求异常: ' + e] };
}
showLog(r);
if (r && r.ok === true) return true;
}
return false;
}
/** @description 把 native 回的多行 log 逐条落到控制台日志(与 WiFi/BLE 那条同一个日志池)。 */
@@ -200,14 +239,15 @@ export async function bootEcatProbe(verbose) {
setTxt('bootEcatSw', ver);
setTxt('bootEcatArm', r.armed ? '允许' : '不允许');
setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0));
window.bootEcatIsBoot = !!r.boot;
setEcatFlag('bootEcatIsBoot', !!r.boot);
refreshBootEcatUi();
if (verbose) {
window.addLog('I', 'ECAT刷写',
'0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' +
(r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
: (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
: '邮箱没回应,多半是老 BOOT 或栈没起来')));
: '读不到 0x1008(reason=' + (r.reason || '旧 native 无此字段') +
(r.detail ? ',' + r.detail : '') + ')')));
}
return r;
}
@@ -261,16 +301,53 @@ function setEcatMsg(msg) {
/**
* @description 交接之后等 BOOT 真的回到总线,把身份读回来。
* 这里刻意**不**再打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时全量
* 重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的,实测只多付
* ~180ms;而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。
* 这里刻意**不**再无条件打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时
* 全量重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的;
* 而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。
* @note 旧注释写的"probe 实测只多付 ~180ms"只在**从站健康**时成立(2026-10-13 正极
* 实测 15-69ms)。从站推不进 PREOP 时它最坏要 28.5s,比前端 20s 超时还长 —— 那才是
* 页面卡死、要重启主站的成因,native 侧已按墙钟预算封顶(最坏 ≈10.6s)。
* @return {object|null} 最后一次 probe 读数;拿到 boot:true 就提前返回
*/
async function waitBootOnBus(maxTries, gapMs) {
var last = null;
var t0 = performance.now();
for (var k = 0; k < maxTries; k++) {
var rp = performance.now();
last = await bootEcatProbe(false);
/* 每轮都留一行,别只留最后一句总结:native 的 reason 分型(no_slave / no_preop /
* reopen_fail / sdo_timeout / app)是"链路没恢复"和"probe 没重试"唯一的区分读数,
* 以前 verbose=false 把它整轮丢弃,2026-10-12 那轮就是因此定不下断点。
* 耗时也一起打:2026-10-13 实测「从站能枚举到、但 AL 推不进 PREOP」时单轮 probe
* 要 28.5s(三级 dl_pull_preop × 4 次 2s statecheck),比前端 API_TMO.probe=20s
* 还长 —— 浏览器先 abort、native 仍在锁内跑完它,于是等锁实测到 54s。native 现已
* 按「必须短于 20s」配了墙钟预算(最坏 ≈10.6s)。没有这一列就看不出窗口被谁吃掉。 */
if (window.addLog) {
var s = (last && last.stage_ms) || {};
window.addLog('I', 'ECAT刷写',
'[盯BOOT] #' + (k + 1) + '/' + maxTries +
' t=+' + Math.round(performance.now() - t0) + 'ms' +
' 本轮=' + Math.round(performance.now() - rp) + 'ms' +
' boot=' + (last && last.boot ? 'Y' : 'N') +
' reason=' + ((last && last.reason) || '无响应') +
' name="' + ((last && last.name) || '') + '"' +
(s.total_ms != null
? ' 分段[preop=' + s.preop_ms + '/第' + s.preop_try +
' enum=' + s.enum_ms + '/第' + s.enum_try +
' reopen=' + s.reopen_ms +
' sdo=' + s.sdo_name_ms + ']'
: ''));
}
if (last && last.boot) return last;
/* 主站会话被它自己的全量重连兜底关掉了(ec_close 后 do_open 失败 → g_bus_open 留在
* 0),之后每条请求都只会回 bus_closed,角色格永远停在「未探测」—— 2026-10-13 实测
* t=+15s 就是这一格。这里立刻按上电那条路重开一次(复用 ensureBusOpen,不另写一套
* 恢复逻辑),否则剩下几轮全打在死会话上,白烧 BOOT 的窗口。 */
if (last && last.bus_open === 0) {
window.addLog('W', 'ECAT刷写',
'[盯BOOT] 主站会话已被兜底重连关掉(bus_open=0),按上电路径重开总线…');
await ensureBusOpen(true);
}
await new Promise(function (res) { setTimeout(res, gapMs); });
}
return last;
@@ -284,7 +361,7 @@ async function waitBootOnBus(maxTries, gapMs) {
*/
export async function bootEcatReboot() {
if (window.bootEcatBusy) return;
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
refreshBootEcatUi();
var btn = el('bootEcatRebootBtn');
var isBoot = window.bootEcatIsBoot === true;
@@ -310,11 +387,11 @@ export async function bootEcatReboot() {
}
if (r.already) {
if (op === 'jump_app') {
window.bootEcatIsBoot = false;
setEcatFlag('bootEcatIsBoot', false);
setEcatStatus('ok', '对面本来就是应用');
setEcatMsg('从站已在 APP,无需跳回');
} else {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '对面本来就是引导程序');
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
}
@@ -324,11 +401,15 @@ export async function bootEcatReboot() {
}
if (op === 'jump_app') {
setEcatMsg('跳回已受理,等 APP 上总线…');
var a = await waitAppOnBus(10, 800);
var a = await waitAppOnBus(12, 300);
if (a && a.name && !a.boot) {
window.bootEcatIsBoot = false;
setEcatStatus('ok', '已回到应用');
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + ')');
setEcatFlag('bootEcatIsBoot', false);
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + '),恢复连接…');
var restored = await restoreEcatLikePowerOn();
setEcatStatus(restored ? 'ok' : 'warn', restored ? '已回到应用' : 'APP 已起,连接未完全恢复');
setEcatMsg(restored
? 'APP 已回到总线,连接已按上电流程恢复(0x100A = ' + (a.sw || '—') + ')'
: 'APP 已应答但 open/motor/start 未全部成功 —— 见日志,可再点『探测』或手动连接');
} else {
setEcatStatus('warn', '跳回后没等到 APP');
setEcatMsg('写了 0x2503 但没等到应用 —— 看串口,或 A 区 CRC 不过');
@@ -341,7 +422,7 @@ export async function bootEcatReboot() {
setEcatMsg('交接已受理,等 BOOT 上总线…');
var b = await waitBootOnBus(10, 800);
if (b && b.boot) {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '已进引导程序');
setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写');
} else {
@@ -359,24 +440,53 @@ export async function bootEcatReboot() {
setEcatMsg('交接/跳回请求异常');
window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
} finally {
window.bootEcatBusy = false;
setEcatFlag('bootEcatBusy', false);
refreshBootEcatUi();
}
}
/**
* @description 跳回之后等 APP 真的回到总线(与 waitBootOnBus 对称)。
* 首轮前先短等 300ms(覆盖 BOOT 80ms 静默 + APP init),gap 收紧到 300ms:
* 旧 800ms 会让「其实已经好了」多愣一拍。
*/
async function waitAppOnBus(maxTries, gapMs) {
await new Promise(function (res) { setTimeout(res, 300); });
var last = null;
for (var k = 0; k < maxTries; k++) {
last = await bootEcatProbe(false);
if (last && last.name && !last.boot) return last;
await new Promise(function (res) { setTimeout(res, gapMs); });
await new Promise(function (res) { setTimeout(res, gapMs || 300); });
}
return last;
}
/**
* @description APP 回总线后,按「刚上电」同一条链拉起连接:
* health → open 网卡 → motor/start(PREOP→SAFEOP→OP + PDO 循环)。
* 与 device-link Owner 的 bootstrapEcatBus / runEcatBootstrap 同一实现,
* 不在本文件另写一套恢复逻辑。
*/
async function restoreEcatLikePowerOn() {
setEcatMsg('按上电流程恢复连接(open + motor/start)…');
try {
var r = await runEcatBootstrap({
ecatPost: post,
fromLocalStorage: true,
reason: 'user_probe',
});
if (r && r.ok) {
window.addLog('I', 'ECAT刷写', r.reused
? 'PDO 循环本已在跑,复用(与上电幂等语义一致)'
: '已按上电流程恢复:open + motor/start(OP + PDO)');
return true;
}
} catch (e) {
window.addLog('W', 'ECAT刷写', '按上电流程恢复连接失败:' + (e && e.message ? e.message : e));
}
return false;
}
/** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */
export async function bootEcatAbort() {
if (window.bootEcatBusy) return;
@@ -480,7 +590,7 @@ export async function bootEcatFlash() {
}
clearArm();
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
@@ -667,9 +777,16 @@ export async function bootEcatFlash() {
var up = !!(after && after.name && !after.boot);
if (up) {
setEcatStage(6, 'done', '已复位');
setEcatStage(7, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
setEcatStage(7, 'live', '恢复连接');
setEcatStatus('active', '按上电流程恢复连接…');
setEcatPct(100, '新 APP 已应答,恢复 OP/PDO…');
var restoredAfterFlash = await restoreEcatLikePowerOn();
setEcatStage(7, restoredAfterFlash ? 'done' : 'warn', restoredAfterFlash ? '完成' : '部分恢复');
setEcatStatus(restoredAfterFlash ? 'ok' : 'warn',
restoredAfterFlash ? '刷写完成' : '刷写完成,连接未完全恢复');
setEcatPct(100, restoredAfterFlash
? ('新 APP 已在总线 · 0x100A = ' + (after.sw || '—') + ' · 连接已按上电流程恢复')
: ('新 APP 已应答 0x100A = ' + (after.sw || '—') + ',但 open/motor/start 未全部成功'));
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
if (pre && pre.sw && after.sw && after.sw === pre.sw) {
@@ -679,17 +796,42 @@ export async function bootEcatFlash() {
window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning');
}
} else if (okRun) {
/* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复,
* 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档,
* 阶段 6/7 全部警告态,文案直接说清副作用。 */
setEcatStage(6, 'warn', '已复位');
setEcatStage(7, 'err', 'EtherCAT 未恢复');
setEcatStatus('err', '刷写已提交,但 EtherCAT 不可用');
setEcatPct(100, '镜像已写入,但总线邮箱读不到 APP —— EtherCAT 当前不可用');
window.addLog('E', 'ECAT刷写',
'commit OK,但 0x1008 读不到 APP 名 = EtherCAT 未恢复(HBI/邮箱路径死)。' +
'板上若仍是 1.6.39,warm jump 后 IDX 残值会让旧探针假阴性 —— 需刷 1.6.40;' +
'若已是 1.6.40 仍这样,看串口 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready');
/* commit 成功只证明 A 区写进去了;这一支里三种病必须分开说,且只有
* reason=sdo_timeout(AL 已到位、0x1008 不答)才允许指向从站:
* - 有 name 且 boot=true → 新 APP 没跑起来,还在引导程序(A 区/向量表的事)
* - bus_closed/no_slave/no_preop/reopen_fail → 主站压根没重连上,读数不自证
* - sdo_timeout → 邮箱路径真没应答,才轮到查固件
* 以前这里不分 reason 一律写"HBI/邮箱路径死",2026-10-09 21:49 那次就是
* 主站 6s 假超时后 6 轮 probe 全打在没重建的会话上,被误判成固件没恢复。 */
var rsn = (after && after.reason) || 'bus_closed';
var isBootNow = !!(after && after.name && after.boot);
var slaveBlamed = (rsn === 'sdo_timeout');
setEcatStage(6, isBootNow ? 'err' : 'warn', isBootNow ? '仍在引导程序' : '已复位');
setEcatStage(7, 'err', slaveBlamed ? '邮箱无回应'
: (isBootNow ? '没跳进 APP' : '主站未重连'));
setEcatStatus('err', slaveBlamed ? '刷写已提交,但从站邮箱无回应'
: (isBootNow ? '刷写已提交,但从站仍在引导程序'
: '刷写已提交,但主站没能重连上总线'));
setEcatPct(100, slaveBlamed
? '镜像已写入,AL 到位但 0x1008 不答 —— 从站邮箱当前不可用'
: (isBootNow ? '镜像已写入,对面仍是 BOOT —— 新 APP 没接棒'
: '镜像已写入,主站这侧会话没重建(不是从站的读数)'));
if (isBootNow) {
window.addLog('E', 'ECAT刷写',
'commit OK,但 0x1008 仍是 "' + after.name + '"(引导程序)—— ' +
'新 APP 没跳起来:先点「探测」看 BOOT 侧 stay_in_ota/校验位,再看串口 [CRC]/[J5] 有没有走到跳转');
} else if (slaveBlamed) {
window.addLog('E', 'ECAT刷写',
'commit OK,AL 已推进到 PREOP 但 0x1008 读不到(reason=sdo_timeout)= 从站邮箱无回应。' +
'串口看 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready' +
(after.detail ? ';native 侧原文:' + after.detail : ''));
} else {
window.addLog('E', 'ECAT刷写',
'commit OK,但主站没重连上总线(reason=' + rsn + (after && after.detail ? ',' + after.detail : '') +
')—— 这个读数只说明主站这侧没人,**不能**据此说从站 EtherCAT 死了。' +
'等链路重协商(新 APP 起板会自己补一发 LAN9252 RST#)后点「探测」复核;' +
'同时看 ESP32 侧 SPI 的 INIT_STATUS state/mod/fault_codes 定从站好坏');
}
} else {
setEcatStage(6, 'err', '未确认');
setEcatStage(7, 'err', '未完成');
@@ -710,7 +852,7 @@ export async function bootEcatFlash() {
setEcatPct(0, '刷写请求异常:' + (e && e.name === 'TimeoutError' ? '超时' : e));
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
} finally {
window.bootEcatBusy = false;
setEcatFlag('bootEcatBusy', false);
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
if (runBtn) {
+5
View File
@@ -36,6 +36,7 @@
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
*/
import { deviceLink } from '../common/device-link.js';
import { setCellSource } from '../common/utils.js';
import {
proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp,
} from '../common/ecat-bus.js';
@@ -79,6 +80,7 @@ function render(d) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
setCellSource('mGd32Load', 'off');
return false;
}
var perm = Number(d.load_perm);
@@ -86,6 +88,7 @@ function render(d) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
setCellSource('mGd32Load', 'off');
return false;
}
var pct = perm / 10;
@@ -93,6 +96,8 @@ function render(d) {
/* ≥85% 变琥珀提示余量吃紧;其余用正常强调色 */
setCls(node, pct >= 85 ? 'v warn' : 'v ok');
setTitle(node, TIP_BASE + ';窗口序号 ' + d.seq + ',' + d.age_ms + 'ms 前更新');
/* /api/app/load 走 EtherCAT → 标 ECAT 来源 */
setCellSource('mGd32Load', 'ecat');
return true;
}
+18 -2
View File
@@ -20,8 +20,8 @@
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
import { formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { formatUptime, setCellSource } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, setWifiPillOverride } from '../common/connection.js';
import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.js';
@@ -154,9 +154,17 @@ export function clearLiveMetricsOnDisconnect() {
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data);
var connected = !!wifi.connected;
/* 正常状态轮询:清掉「重启中/上传中/设备忙」覆盖,WiFi 文案回到 updateLinkPills 唯一生成 */
try {
var busy = (typeof window.otaSessionActive === 'function' && window.otaSessionActive())
|| window.isEsp32Rebooting;
if (!busy) setWifiPillOverride(null);
} catch (_) {}
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(data.health, data.free_heap);
/* 连接状态角标由 updateConnectionUi 按双通路在线情况标注,这里只标健康(WiFi /system/status) */
setCellSource('mHealth', 'wifi');
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
@@ -168,6 +176,7 @@ function applyDashboardUi(data, ota, wifi) {
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
setCellSource('mGd32App', 'wifi');
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
@@ -177,6 +186,7 @@ function applyDashboardUi(data, ota, wifi) {
}
}
if (mGd32Boot) {
setCellSource('mGd32Boot', 'wifi');
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
@@ -193,6 +203,7 @@ function applyDashboardUi(data, ota, wifi) {
* 1.5.36+ fault_codes[] 给出每模块具体原因,直接显示无需悬停。 */
var mGd32InitEl = document.getElementById('mGd32Init');
if (mGd32InitEl) {
setCellSource('mGd32Init', 'wifi');
var gi = data.gd32_init || null;
/* 与 GD32 sys/initial.h AppInitializer::ModuleBit 位序一致 */
var kModNames = ['nvic','systick','esp32_hold','usart0','adc','canfd',
@@ -276,6 +287,7 @@ function applyDashboardUi(data, ota, wifi) {
var mTempEl = document.getElementById('mTemp');
if (mTempEl) {
setCellSource('mTemp', 'wifi');
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
@@ -300,6 +312,10 @@ function applyDashboardUi(data, ota, wifi) {
var mGd32TempEl = document.getElementById('mGd32Temp');
var mVccEl = document.getElementById('mVcc');
var mVddaEl = document.getElementById('mVdda');
/* gd32_adc 经 /system/status(ESP32 HTTP)→ WiFi */
setCellSource('mGd32Temp', 'wifi');
setCellSource('mVcc', 'wifi');
setCellSource('mVdda', 'wifi');
function setMetricUi(el, text, cls, title) {
if (!el) return;
+20 -8
View File
@@ -471,11 +471,11 @@
<button id="btnPeriod">热改周期</button>
<span id="runPill" class="pill"><span class="indicator"></span>空闲</span>
</div>
<div class="hint">
<div class="hint">
热改周期走 <code>/api/motor/period</code>(循环每拍重读的变量,全程喂狗不断流,随时可调、可反复调)。
<b>低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核</b>;从站固件修好前别低于 1ms —— 实测
0.25 / 0.125ms 档 GD32 顶到 99.5%,CAN 到帧率 λ 从 4000/s 塌到 149/s(瓶颈在从站不在主站,
只看主站达成率会整条看漏)。<br>
合法档位 <b>0.125–4 ms</b>,DC SYNC0 与 period 始终同值;选完立刻生效,回读以状态栏为准。
低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核;极快档(0.25/0.125)对 GD32 SSC 压力大,
极慢档(≥1ms)发送出口延迟会变长 —— 都是可接受的代价,不是被钉死。<br>
<b>观测默认 10Hz</b>。逐拍环 / 原始窗口 / 每拍重建都在各自模块卡片里开关(默认关)。标签页切到后台会自动暂停。
</div>
</div>
@@ -2267,20 +2267,32 @@ $("#btnPeriod").onclick = async () => {
try {
const r = await eapi("motor/period", { period_ms: v }, 8000);
if (r.ok !== true) throw new Error((r.log || []).join(" / ") || "被拒");
const apiEff = (typeof r.period_ms === "number") ? r.period_ms : null;
/* 热调是异步落到循环线程的,必须回读确认,不能拿下发值当生效值 */
let eff = null;
for (let i = 0; i < 8; i++) {
await wsleep(250);
eff = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(eff) && Math.abs(eff - v) < 1e-6) break;
if (isNum(eff) && apiEff != null && Math.abs(eff - apiEff) < 1e-6) break;
}
const ok = isNum(eff) && Math.abs(eff - v) < 1e-6;
if (ok) {
const hitReq = isNum(eff) && Math.abs(eff - v) < 1e-6;
const hitApi = isNum(eff) && apiEff != null && Math.abs(eff - apiEff) < 1e-6;
if (hitReq) {
writePeriodShared(eff);
saveUiOpts();
runBusy("ok", "已生效 " + eff + "ms(" + f0(1000 / eff) + "Hz 档)");
} else if (hitApi && apiEff != null && Math.abs(apiEff - v) >= 1e-6) {
/* 后端钳到合法档并回了生效值 —— 不是别人劫持 */
writePeriodShared(apiEff);
saveUiOpts();
runBusy("warn", "下发 " + v + "ms,后端钳制为 " + apiEff + "ms"
+ ((r.log && r.log.length) ? ":" + r.log.join(" / ") : ""));
} else {
runBusy("bad", "下发 " + v + "ms 但回读 " + eff
+ (apiEff != null ? "ms(API 回 " + apiEff + "ms)" : "ms")
+ " —— 生效值与请求不一致(他端热改或后端未接受)");
}
runBusy(ok ? "ok" : "bad", ok ? ("已生效 " + eff + "ms(" + f0(1000 / eff) + "Hz 档)")
: ("下发 " + v + "ms 但回读 " + eff + "ms —— 有他端在设周期(localStorage 劫持)"));
} catch (e) { runBusy("bad", "失败:" + e.message); }
};
+405
View File
@@ -0,0 +1,405 @@
"""lan_probe.py — 局域网设备探测原语(无状态:一次请求跑一批,跑完就返回)。
职责切分:候选表、分批、进度、取消、去重、渲染、结果缓存全在页面
(js/common/lan-picker.js);本模块只做两件事——
probe_batch(ips, ports, path, timeout_ms) 逐 IP 一次 HTTP GET + 判决
local_net() 本机默认路由源 IP / 派生 /24
为什么不用 urllib/requests:Windows 上它们的 connect 超时和 read 超时共用一个
预算,冷 ARP + WiFi 省电唤醒能让一台活板子整批假死;同仓实测记录见
ecat_client.py:319-335。这里用单个 selectors 循环驱动全部非阻塞 socket,
chunk 内所有目标共用一个绝对截止,最坏耗时 = ceil(n/concurrency) * timeout。
判决三态(不许有未定义分支,兜底一律 unknown):
board — 实现了 /system/status?lite=1 且 wifi/gd32_features 形状对得上
rejected — 有**反证**(非 200 / 非 JSON / 无 wifi 对象 / STA 自报 IP 与探测地址不符)
unknown — 连不上、读超时、或形状合但落不进上面两类
把"没测出来"塞进 rejected 就是自证判据;本模块宁可留灰也不猜。
"""
import json
import selectors
import socket
import time
# 探测路径固定 lite:256 B 栈缓冲、不抢 httpJsonWorkMutex、不发 SPI 事务
# (ESP32_COMM/main/server/http_handlers.cpp:1769-1806)。
PROBE_PATH = "/system/status?lite=1"
# 白名单而不是随便传:/system/probe 会发起真实 SPI 事务,/logs 会排空固件的
# 日志环形缓冲——扫 254 台时这些都会污染正在调试的东西。要加路径先去固件仓确认副作用。
ALLOWED_PATHS = (PROBE_PATH, "/system/status")
BATCH_MAX_IPS = 64 # 一次请求最多探多少个 IP(页面按此分批)
# 并发是"同时探多少个不同地址",不是对同一个地址开多条连接——所以一块板子
# 在整场扫描里最多只会被我们占 1 个 socket(它 httpd 只有 6 个且开 lru_purge)。
# 整段耗时由"不存在的地址等满截止"主导:254 个地址在 8 并发下要 24s,48 并发约 4s。
CONCURRENCY = 48
TIMEOUT_MS_MAX = 3000 # 单目标硬截止上限
BUDGET_MS_MAX = 8000 # 一批的最长总耗时(超出部分回 pending,不谎报探过)
HEAD_BYTES = 96 # rejected 里带回的响应片段长度(给人念出来用)
def valid_ipv4(s):
"""严格 IPv4 点分十进制校验:四段 0-255、纯数字、不允许前导多余字符。"""
parts = str(s or "").strip().split(".")
if len(parts) != 4:
return False
for p in parts:
if not p.isdigit() or len(p) > 3 or not 0 <= int(p) <= 255:
return False
return True
def local_net():
"""默认路由源 IP + 派生 /24 + 本机全部 IPv4(供页面过滤自己的地址)。
UDP connect 只做内核路由查询,不发一个包。取不到默认路由就返回 ok=False,
页面必须要求人填 CIDR —— 不回退成"那就打 .127 试一把",那是配置自证。
"""
for dst in (("8.8.8.8", 53), ("223.5.5.5", 53), ("1.1.1.1", 53)):
ip = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(dst)
ip = s.getsockname()[0]
finally:
s.close()
except OSError:
continue
if not ip or ip.startswith("169.254.") or ip.startswith("127."):
continue
return {
"ok": True,
"default_src_ip": ip,
"cidr": ".".join(ip.split(".")[:3]) + ".0/24",
"source": "udp-route",
"local_ips": _all_local_ipv4(),
}
return {"ok": False, "err": "no-default-route", "local_ips": _all_local_ipv4()}
def _all_local_ipv4():
out = []
try:
for ai in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = ai[4][0]
if ip not in out:
out.append(ip)
except OSError:
pass
return out
def classify(ip, status, ctype, body):
"""纯判决函数:给一次 HTTP 响应,返回该地址是不是我们的板子。
单独拎出来是为了能被离线单测打(scripts/test_lan_probe.py),不碰 socket。
"""
if status is None:
return {"verdict": "unknown", "reason": "no-response"}
if status != 200:
return {"verdict": "rejected", "reason": "http-%d" % status}
if "json" not in (ctype or "").lower():
# 路由器/NAS 登录页落这里
return {"verdict": "rejected", "reason": "not-json"}
try:
j = json.loads(body)
except (ValueError, TypeError):
return {"verdict": "rejected", "reason": "bad-json"}
if not isinstance(j, dict):
return {"verdict": "rejected", "reason": "not-object"}
wifi = j.get("wifi")
if not isinstance(wifi, dict):
return {"verdict": "rejected", "reason": "no-wifi-object"}
mode = wifi.get("mode")
if mode not in ("STA", "AP"):
# 形状不对但不是反证:固件字段变了/别的 JSON 服务都落这里
return {"verdict": "unknown", "reason": "bad-mode"}
gf = j.get("gd32_features")
schema = "core-only"
if isinstance(gf, dict) and isinstance(gf.get("known"), bool) \
and isinstance(gf.get("flags"), int):
schema = "full"
self_ip = wifi.get("ip")
connected = wifi.get("connected")
if mode == "AP" and connected is not True:
# 关键坑:lite 在 NOT(STA && connected) 时一律报 mode:"AP"
# (http_handlers.cpp:1776-1789)。所以 AP+connected:false 多半是
# "STA 掉租约的板子",既不能当在线 AP 板收下,也不能当反证扔掉。
return {"verdict": "unknown", "reason": "ap-not-connected",
"schema": schema, "mode": mode, "self_ip": self_ip}
if mode == "STA" and self_ip not in (ip, None, ""):
# 反证:它自称在别处。NAT/端口转发/捕获门户落这里。
return {"verdict": "rejected", "reason": "ip-mismatch:%s" % self_ip,
"schema": schema}
return {
"verdict": "board",
"kind": "ap" if mode == "AP" else "sta",
"schema": schema,
"mode": mode,
"connected": bool(connected),
"self_ip": self_ip,
}
def _parse_http(buf):
"""从原始字节里取 (status, content_type, body)。不引 http.client:只要这三样。"""
sep = buf.find(b"\r\n\r\n")
head = buf[:sep if sep >= 0 else len(buf)]
body = buf[sep + 4:] if sep >= 0 else b""
lines = head.split(b"\r\n")
status = None
if lines and lines[0]:
bits = lines[0].split()
if len(bits) >= 2 and bits[1].isdigit():
status = int(bits[1])
ctype = ""
for ln in lines[1:]:
k, _, v = ln.partition(b":")
if k.lower() == b"content-type":
ctype = v.decode("latin-1", "replace").strip()
break
return status, ctype, body.decode("utf-8", "replace")
def _content_length(head_bytes_buf):
i = head_bytes_buf.find(b"\r\n\r\n")
if i < 0:
return None
for ln in head_bytes_buf[:i].split(b"\r\n")[1:]:
k, _, v = ln.partition(b":")
if k.lower() == b"content-length":
try:
return int(v.strip())
except ValueError:
return None
return None
def _probe_chunk(targets, path, deadline, errors):
"""并发探完一小批(≤CONCURRENCY)。返回与 targets 同序的结果列表。
每个 socket 都登记在 active 里,退出路径只有一条(close + pop),所以句柄
不会随"页面中途关标签、这批没人收"而泄漏。
"""
sel = selectors.DefaultSelector()
states = []
active = {} # sock -> state
def retire(sock, st):
if sock in active:
try:
sel.unregister(sock)
except (KeyError, ValueError):
pass
active.pop(sock, None)
try:
sock.close()
except OSError:
pass
if "verdict" not in st:
_finish(st)
try:
for ip, port in targets:
st = {"ip": ip, "port": port, "tcp": False, "status": None,
"ctype": "", "body": "", "err": None, "got": b"",
"req": ("GET %s HTTP/1.0\r\nHost: %s\r\n"
"Accept: application/json\r\nConnection: close\r\n\r\n"
% (path, ip)).encode("ascii")}
states.append(st)
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
sock.connect((ip, port)) # 非阻塞:要么立刻 0,要么 BlockingIOError
st["tcp"] = True
except BlockingIOError:
pass # 在途:可写时看 SO_ERROR 才算数
except OSError as e: # 本机立刻就知道连不上(无路由等)
st["tcp"] = False
st["err"] = e.winerror or e.errno or type(e).__name__
if sock is not None:
try:
sock.close()
except OSError:
pass
sock = None
_finish(st)
continue
sel.register(sock, selectors.EVENT_WRITE, st)
active[sock] = st
while active:
left = deadline - time.monotonic()
if left <= 0:
break
try:
ready = sel.select(left)
except OSError as e:
errors.append("select:%s" % type(e).__name__)
break
for key, events in ready:
st = key.data
sock = key.fileobj
if st.get("done"):
retire(sock, st)
continue
try:
if events & selectors.EVENT_WRITE and not st.get("sent"):
if sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR):
st["tcp"] = False
st["err"] = "connect-refused"
retire(sock, st)
continue
sock.sendall(st["req"])
st["tcp"] = True # 可写且 SO_ERROR==0:这次真连上了
st["sent"] = True
sel.modify(sock, selectors.EVENT_READ, st)
continue
chunk = sock.recv(4096)
if not chunk:
retire(sock, st) # EOF:对方关连接,够判了
continue
st["got"] += chunk
st["tcp"] = True
# 按 Content-Length 收完就走:路由器多半 keep-alive 不关连接,
# 死等 EOF 会把每个活非板子都拖满截止,整批白跑。
head_end = st["got"].find(b"\r\n\r\n")
if head_end >= 0:
cl = _content_length(st["got"])
have = len(st["got"]) - head_end - 4
if (cl is not None and have >= cl) or have > 65536:
retire(sock, st)
except OSError as e:
if (e.winerror or e.errno) in (10035, 11): # WSAEWOULDBLOCK/EAGAIN
continue
st["err"] = e.winerror or e.errno or type(e).__name__
retire(sock, st)
finally:
for st in states:
if "verdict" not in st:
_finish(st)
for sock in list(active):
try:
sel.unregister(sock)
except (KeyError, ValueError):
pass
try:
sock.close()
except OSError:
pass
active.clear()
sel.close()
return states
def _finish(st):
"""把已收字节解析成判决,并把大缓冲释放掉(结果要回给页面,不能揣 64 KB)。"""
status, ctype, body = _parse_http(st.get("got") or b"")
st["status"], st["ctype"] = status, ctype
st["head"] = body[:HEAD_BYTES].replace("\r", "").replace("\n", " ")
if st.get("tcp"):
v = classify(st["ip"], status, ctype, body)
else:
v = {"verdict": "unknown",
"reason": "tcp-failed:%s" % (st.get("err") or "no-connection")}
st.update(v)
st.pop("req", None)
st.pop("got", None)
st["done"] = True
def probe_batch(ips, ports=None, path=PROBE_PATH, timeout_ms=750):
"""逐 IP 探测。返回 {ok, results, scan, errors};异常只记账,绝不写 stderr。"""
t0 = time.monotonic()
errors = []
scan = {"candidates": 0, "tcpOpen": 0, "httpProbed": 0,
"board": 0, "rejected": 0, "unknown": 0,
"skipped": [], "durationMs": 0, "timeoutMs": 0, "concurrency": CONCURRENCY}
if path not in ALLOWED_PATHS:
errors.append("path-not-allowed:%s" % path)
path = PROBE_PATH
timeout_ms = max(80, min(int(timeout_ms or 750), TIMEOUT_MS_MAX))
scan["timeoutMs"] = timeout_ms
use_ports = [int(p) for p in (ports or [80]) if str(p).strip()]
ips_in = [str(x or "").strip() for x in (ips or [])]
targets = []
seen = set()
# 批大小由服务侧兜死:页面漏分批时也不会让一个 handler 线程在
# 254 个 socket 上挂 24 s。多出来的原样回 pending,由页面下一批接着探。
if len(ips_in) > BATCH_MAX_IPS:
pending_overflow = ["%s:overflow" % ip for ip in ips_in[BATCH_MAX_IPS:]]
errors.append("ips-truncated:%d" % len(pending_overflow))
else:
pending_overflow = []
for ip in ips_in[:BATCH_MAX_IPS]:
if not valid_ipv4(ip):
scan["skipped"].append([ip or "(empty)", "not-ipv4"])
continue
for port in use_ports:
key = (ip, port)
if key in seen:
continue
seen.add(key)
targets.append(key)
scan["candidates"] = len(targets)
scan["requested"] = len(ips_in)
raw = []
pending = list(pending_overflow)
# 一批的总预算 = 分批数 × 单目标截止 + 300ms 余量。页面自己控制批大小(≤24),
# 所以这里只是兜底:超出预算的尾巴回 pending,让页面继续下一批,绝不静默截断。
n_chunks = (len(targets) + CONCURRENCY - 1) // CONCURRENCY
budget_ms = min(max(timeout_ms * n_chunks + 300, timeout_ms), BUDGET_MS_MAX)
batch_deadline = t0 + budget_ms / 1000.0
scan["budgetMs"] = budget_ms
for i in range(0, len(targets), CONCURRENCY):
chunk = targets[i:i + CONCURRENCY]
now = time.monotonic()
if now >= batch_deadline:
# 批次预算用尽:剩下的一律记账交回页面,绝不伪装成"探过了"。
pending += ["%s:%d" % t for t in chunk]
continue
# 每个 chunk 各有自己的硬截止,但整批再套一个总预算(分批是限流,
# 不是把预算乘 N)。
chunk_deadline = min(batch_deadline, now + timeout_ms / 1000.0)
try:
raw += _probe_chunk(chunk, path, chunk_deadline, errors)
except Exception as e: # noqa: BLE001 - 兜住一切,不外泄
errors.append("chunk:%s:%s" % (type(e).__name__, e))
for ip, port in chunk:
raw.append({"ip": ip, "port": port, "tcp": False, "status": None,
"verdict": "unknown", "reason": "probe-error"})
results = []
for r in raw:
item = {"ip": r.get("ip"), "port": r.get("port"), "tcp": bool(r.get("tcp")),
"verdict": r.get("verdict") or "unknown",
"reason": r.get("reason") or "",
"kind": r.get("kind"), "schema": r.get("schema"),
"mode": r.get("mode"), "connected": r.get("connected"),
"self_ip": r.get("self_ip"), "status": r.get("status"),
"head": r.get("head") or ""}
results.append({k: v for k, v in item.items() if v is not None})
for st in results:
if st.get("tcp"):
scan["tcpOpen"] += 1
if st.get("status") is not None:
scan["httpProbed"] += 1
v = st["verdict"]
scan[v] = scan.get(v, 0) + 1
scan["pending"] = pending
scan["truncated"] = bool(pending)
scan["durationMs"] = int((time.monotonic() - t0) * 1000)
return {"ok": True, "path": path, "results": results,
"scan": scan, "errors": errors}
+15 -6
View File
@@ -7,9 +7,9 @@
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=20260924a">
<link rel="stylesheet" href="../css/layout.css?v=20260926a">
<link rel="stylesheet" href="../css/components.css?v=20260924a">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/can.css?v=20260926a">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
</head>
<body class="can-page">
@@ -30,9 +30,9 @@
</div>
</div>
<div class="header-status">
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
<span class="pill" id="canIpPill">IP --</span>
<span class="pill" id="canRssiPill">RSSI --</span>
<span class="pill offline link-pill" id="canNetPill" title="ESP32 WiFi(/system/status)">
<span class="indicator"></span><span id="canNetTxt">未连接</span>
</span>
<span class="pill" id="canUptimePill">运行 --</span>
<span class="pill" id="canVersionPill">v--</span>
<span class="pill" id="canChannelPill">CH0</span>
@@ -216,6 +216,15 @@
<button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button>
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button>
</div>
<div class="status-section-title">发送静默(只听不发)</div>
<div class="input-group" style="margin-bottom:12px;flex-wrap:wrap;">
<button class="btn btn-outline btn-sm" id="btnSilent0" type="button" title="切换 CH0 只听不发">CH0 只听不发</button>
<button class="btn btn-outline btn-sm" id="btnSilent1" type="button" title="切换 CH1 只听不发">CH1 只听不发</button>
<button class="btn btn-outline btn-sm" id="btnSilent2" type="button" title="切换 CH2 只听不发">CH2 只听不发</button>
</div>
<div class="helper-note" style="margin-bottom:14px;">
只听不发=把该通道收发器的驱动器关掉(GD32 拉高 S 脚),<b>接收与上报照常</b>;按钮高亮表示当前正处于只听不发,状态来自 1Hz 反馈的 <code>bus_state</code> bit5,不是本地猜测。静默期间该通道发送会被拒绝(错误码 0xE6),上电后默认不静默。
</div>
<div class="helper-note" id="monHelper" style="margin-bottom:14px;">
地址与顶栏一致;1Hz <code>/can/feedback</code>。悬停抽屉保持展开。
</div>
@@ -329,6 +338,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="../js/can.js?v=20260919a"></script>
<script type="module" src="../js/can.js?v=20261010a"></script>
</body>
</html>
+27 -6
View File
@@ -7,9 +7,9 @@
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<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/layout.css?v=20260926a">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/can.css?v=20260926a">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
</head>
<body class="can-page">
@@ -17,11 +17,24 @@
<header class="header-bar can-header">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 CAN 接收监控</strong>
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>ESP32 CAN 接收监控</strong>
<span class="brand-sub">DCU · Robot_v2 · CAN FD</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="monConnPill"><span class="indicator"></span><span id="monConnTxt">未连接</span></span>
<span class="pill offline link-pill" id="canNetPill" title="ESP32 WiFi(/system/status)">
<span class="indicator"></span><span id="canNetTxt">未连接</span>
</span>
<span class="pill" id="canUptimePill">运行 --</span>
<span class="pill" id="canVersionPill">v--</span>
<span class="pill" id="fbModePill">模式: --</span>
<span class="pill" id="fbPollStatus">轮询: 待连接</span>
</div>
@@ -51,6 +64,14 @@
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button>
</div>
</div>
<div class="can-toolbar-block">
<label class="form-label">发送静默(只听不发)</label>
<div class="input-group">
<button class="btn btn-outline btn-sm" id="btnSilent0" type="button" title="切换 CH0 只听不发">CH0 只听不发</button>
<button class="btn btn-outline btn-sm" id="btnSilent1" type="button" title="切换 CH1 只听不发">CH1 只听不发</button>
<button class="btn btn-outline btn-sm" id="btnSilent2" type="button" title="切换 CH2 只听不发">CH2 只听不发</button>
</div>
</div>
</div>
<div class="can-toolbar-foot">
<div class="conn-indicator err" id="monConnIndicator">
@@ -184,6 +205,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="../js/can_monitor.js?v=20260919a"></script>
<script type="module" src="../js/can_monitor.js?v=20261010a"></script>
</body>
</html>
+621 -584
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -286,8 +286,8 @@
<button id="otaLogClearBtn" title="清空日志">清空</button>
</div>
<div class="ota-log" id="otaLog" aria-live="polite">
<div class="log-info">[就绪] 电机 Bootloader OTA · 先上传固件到 ESP32 → 发 0x000N0000 进 BOOT → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)</div>
<div>[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限</div>
<div class="log-info">[就绪] 电机 Bootloader OTA · 固件先灌进 GD32 → 发 0x000N0000 进 BOOT → GD32 本地 relay(AES-128 + CAN stop-and-wait)· 每包 48 B · 48 KB 上限</div>
<div>[信息] 进料与完整性核对按链路不同:<b>WiFi</b> = 上传到 ESP32,设备从 flash 读回算 SHA-256 与本地比对,再经 SPI 喂给 GD32;进 BOOT 帧由本页发。<b>EtherCAT</b> = 经 CoE 邮箱 0x2511 灌进 GD32 的 48KB 暂存区,分块 CRC32 + 整镜像 CRC32 由<b>从站在电机进 BOOT 之前</b>核对;进 BOOT 帧由 GD32 在已独占的通道上自己发(运动帧被通道锁挡住)。只插网线也能刷。两条链路共用同一个 GD32 引擎;刷写期间本链路的过程数据与轮询会暂停,其他电机也收不到运动指令。</div>
</div>
</div>
<!-- 命令序列 TAB -->
@@ -430,7 +430,7 @@
</div>
</div>
<script type="module" src="../js/motor/main.js?v=20260924g"></script>
<script type="module" src="../js/motor/main.js?v=20261011b"></script>
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
<script src="../js/common/app-lease.js"></script>
+2 -9
View File
@@ -26,20 +26,13 @@ import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from guard_probe import Blocked, bridge_of, guard_of, open_link, preflight, verdict # noqa: E402
from guard_probe import (Blocked, ask, bridge_of, guard_of, open_link, # noqa: E402
preflight, verdict)
NATIVE_LOG = os.path.abspath(os.path.join(HERE, os.pardir, os.pardir, "ecat",
"build_native", "native_stdout.log"))
def ask(prompt):
if not sys.stdin or not hasattr(sys.stdin, "isatty") or not sys.stdin.isatty():
raise Blocked("这一步要人在场,但 stdin 不是终端:%s" % prompt)
raw = input(prompt + "(回车继续 / 输入 s 放弃本测试): ").strip().lower()
if raw == "s":
raise Blocked("用户放弃")
def ck(fails, cond, msg):
print((" OK " if cond else " FAIL ") + msg)
if not cond:
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check_lan_discovery.py — 设备发现的真机门槛(PASS / FAIL / BLOCKED / NEEDS-HUMAN)。
与 scripts/test_lan_probe.py 的分工:那个在离线用假板服务把**判据**钉死;
这个在真网段上量**实测**,前置不满足一律 BLOCKED(exit 2),绝不当 PASS。
用法:
python scripts/check_lan_discovery.py --print-net
python scripts/check_lan_discovery.py --expect 1 # 一块板在线
python scripts/check_lan_discovery.py --expect none --server-port 2625
退出码: 0=PASS 1=FAIL 2=BLOCKED(前置缺失,不代表通过)
判据设计上有三条"必须有灵敏度"的硬要求:
A) RFC 5737 TEST-NET 段(路由不到任何机器)必须回 board=0 且全部 unknown;
没有这条,"0 台设备"可能只是实现根本没发包。
B) 反向对照:--expect none 时板子必须**断电**;此时 .1 要出现在 rejected/unknown
并打印它到底回了什么(路由器回 HTML 还是 JSON 只有人能判断)。
C) 干扰是实测不是外推:同一段轮询耗时,基线与"扫描期间"各采一遍,两张数一起报。
"""
import argparse
import io
import json
import os
import re
import socket
import sys
import threading
import time
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import lan_probe as lp # noqa: E402
FW_VERSION_H = os.path.normpath(os.path.join(
ROOT, "..", "..", "ESP32_COMM", "main", "include", "version.h"))
RESULTS = [] # (状态, 名称, 详情)
def add(status, name, detail=""):
RESULTS.append((status, name, detail))
print("%-9s %s%s" % (status, name, (" :: " + str(detail)) if detail else ""))
def _opener():
# 本机 HTTP_PROXY=mihomo 7897 会把局域网请求吃掉(NO_PROXY 只白名单了几个地址),
# 一律显式禁代理,否则测出来的"设备不响应"是代理不是设备。
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
def parse_cidr(cidr):
"""CIDR → 主机地址列表(网络位/广播位不生成)。只支持 mask<=24,够本脚本用。"""
bits = str(cidr).strip().split("/")
q = bits[0].split(".")
if len(q) != 4 or not all(x.isdigit() and 0 <= int(x) <= 255 for x in q):
return None
mask = int(bits[1]) if len(bits) > 1 else 24
if mask > 30 or mask < 8:
return None
hostbits = 32 - mask
base_int = (int(q[0]) << 24) | (int(q[1]) << 16) | (int(q[2]) << 8) | int(q[3])
base_int &= ~((1 << hostbits) - 1) # 抹平主机位,取网络地址
total = (1 << hostbits) - 2 # 去掉网络地址与广播地址
return [str_ip(base_int + i) for i in range(1, max(1, min(total, 1024)) + 1)]
def str_ip(n):
return "%d.%d.%d.%d" % ((n >> 24) & 255, (n >> 16) & 255, (n >> 8) & 255, n & 255)
def run_batch(ips, server_port=None, timeout_ms=750):
"""优先走服务的 /api/lan/probe(顺带把路由也验了);服务没起就进程内直调。"""
if server_port:
try:
req = urllib.request.Request(
"http://127.0.0.1:%d/api/lan/probe" % server_port,
data=json.dumps({"ips": ips, "ports": [80], "timeout_ms": timeout_ms,
"local": True}).encode("utf-8"),
headers={"Content-Type": "application/json"})
with _opener().open(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8")), "http"
except Exception as e:
add("WARN", "服务侧路由不可用,改进程内直调", "%s: %s" % (type(e).__name__, e))
return lp.probe_batch(ips, ports=[80], timeout_ms=timeout_ms), "inproc"
def run_all(ips, server_port=None, timeout_ms=750):
"""按服务的批上限切头发(和网页做的一样),再把各批结果并起来。
一次性把 254 个地址丢给一个请求是不行的:服务侧一批最多
lp.BATCH_MAX_IPS 个,多出来的会进 pending —— 那正是"截断了却假装探完"的坑。
"""
merged = {"results": [], "scan": {}, "errors": [], "local": None}
via = None
for i in range(0, len(ips), lp.BATCH_MAX_IPS):
r, v = run_batch(ips[i:i + lp.BATCH_MAX_IPS], server_port, timeout_ms)
via = v
merged["results"] += r.get("results", [])
s = r.get("scan", {})
for k in ("candidates", "tcpOpen", "httpProbed", "board",
"rejected", "unknown", "durationMs"):
merged["scan"][k] = merged["scan"].get(k, 0) + (s.get(k) or 0)
if s.get("pending"):
merged["scan"]["pending"] = merged["scan"].get("pending", []) + list(s["pending"])
merged["errors"] += r.get("errors", [])
if r.get("local"):
merged["local"] = r["local"]
merged["scan"]["truncated"] = bool(merged["scan"].get("pending"))
return merged, via
def fw_expected():
if not os.path.isfile(FW_VERSION_H):
return None, "找不到 " + FW_VERSION_H
txt = io.open(FW_VERSION_H, encoding="utf-8", errors="replace").read()
def num(name):
m = re.search(r"^#define\s+" + name + r"\s+(\d+)", txt, re.M)
return int(m.group(1)) if m else None
triplet = None
ma, mi, pa = num("FW_VERSION_MAJOR"), num("FW_VERSION_MINOR"), num("FW_VERSION_PATCH")
if None not in (ma, mi, pa):
triplet = "%d.%d.%d" % (ma, mi, pa)
m = re.search(r'^#define\s+FW_VERSION_STRING\s+"([^"]+)"', txt, re.M)
return triplet, (m.group(1) if m else None)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--expect", default="", help="none|1|2 —— 本轮局域网上应有的板子数;不填则相关步骤 BLOCKED")
ap.add_argument("--cidr", default="", help="要扫的段,缺省用本机默认路由派生的 /24")
ap.add_argument("--server-port", type=int, default=2625, help="本地服务端口(0=不走 HTTP 路由)")
ap.add_argument("--print-net", action="store_true", help="只打印本机网络判定")
ap.add_argument("--interfere", action="store_true", help="做扫描期干扰 A/B 实测(需一块板在线)")
args = ap.parse_args()
net = lp.local_net()
print("本机: %s" % json.dumps(net, ensure_ascii=False))
if args.print_net:
return 0
if not net.get("ok"):
add("BLOCKED", "取不到本机默认路由", net.get("err"))
return summary()
ip, dev_cidr = net.get("default_src_ip"), net.get("cidr")
if ip and not ip.startswith("192.168.31."):
add("WARN", "本机地址不是 192.168.31.x,确认 PC 与板子同网段?", ip)
# ── A) 灵敏度对照:TEST-NET 段(RFC 5737,路由不到任何机器)──
tn = parse_cidr("192.0.2.0/28")
if not tn:
add("BLOCKED", "TEST-NET 对照段解析失败", "192.0.2.0/28")
return summary()
res, via = run_all(tn, args.server_port or None)
if via == "inproc":
add("WARN", "本轮没打到 /api/lan/probe(本地服务未启动或还是旧版)——"
"路由这条本轮未验证,下面的数字只证明探测逻辑本身")
sc = res.get("scan", {})
boards = [d for d in res.get("results", []) if d.get("verdict") == "board"]
unknowns = [d for d in res.get("results", []) if d.get("verdict") == "unknown"]
if sc.get("candidates") == len(tn) and not boards and len(unknowns) == len(tn):
add("PASS", "A 灵敏度对照:192.0.2.0/28 → board=0 且 %d 条全 unknown(探测真的在跑,也真的没瞎报)"
% len(tn), "via=" + via)
else:
add("FAIL", "A 灵敏度对照:期望 candidates=%d board=0 unknown=%d,实际 %s"
% (len(tn), len(tn), {k: sc.get(k) for k in ("candidates", "board", "unknown")}),
"via=" + via)
# ── B) 真实网段 ──
cidr = args.cidr or dev_cidr
ips = parse_cidr(cidr)
if not ips:
add("BLOCKED", "CIDR 解析失败(只支持 /24 及更小段)", cidr)
return summary()
t0 = time.time()
res2, via2 = run_all(ips, args.server_port or None)
dur = int((time.time() - t0) * 1000)
sc2 = res2.get("scan", {})
rows = res2.get("results", [])
boards2 = [r for r in rows if r.get("verdict") == "board"]
rej2 = [r for r in rows if r.get("verdict") == "rejected"]
unk2 = [r for r in rows if r.get("verdict") == "unknown"]
print("\n扫描 %s (%s):%d 候选 / 端口开 %d / 板 %d / 反证 %d / 不可判定 %d / %dms (via %s)"
% (cidr, ",".join(net.get("local_ips", [])), sc2.get("candidates", 0),
sc2.get("tcpOpen", 0), len(boards2), len(rej2), len(unk2), dur, via2))
for b in boards2:
print(" board %-16s kind=%-4s schema=%s" % (b["ip"], b.get("kind"), b.get("schema")))
for r in rej2[:12]:
print(" reject %-16s %s 首包 %r" % (r["ip"], r.get("reason"), (r.get("head") or "")[:48]))
for u in unk2:
if u.get("tcp"):
print(" unknown(tcp开) %-16s %s" % (u["ip"], u.get("reason")))
exp = args.expect.strip().lower()
if exp in ("none", "0"):
ok = len(boards2) == 0
add("PASS" if ok else "FAIL",
"B 板子断电时 board 必须为 0(实测 %d)" % len(boards2),
"" if ok else "板子没断电?还是判据把非板子说成板子了?")
gw = [r for r in rows if r["ip"].endswith(".1")]
add("NEEDS-HUMAN", "B2 人读一遍 .1 的判决与首包,确认路由器是被**反证**排除而不是碰巧没答",
gw[0] if gw else "本轮 .1 未出现在结果里")
elif exp in ("1", "2"):
want = int(exp)
ok = len(boards2) == want
add("PASS" if ok else "FAIL", "B 期望 %d 块板,实测 %d 台" % (want, len(boards2)))
if boards2:
exp_ver, str_ver = fw_expected()
for b in boards2:
add("NEEDS-HUMAN", "B2 这块板是不是你手上那块?看 uptime:按复位键后重扫,新上电那台 uptime<30s",
b["ip"])
add("INFO", "源码版本 %s / FW_VERSION_STRING %s(板子的 version 要在网页点「详情」才取到,"
"lite 不带版本;不一致只是板子跑旧固件,不算发现功能失败)" % (exp_ver, str_ver))
else:
add("BLOCKED", "B 未给 --expect none|1|2,板子在线数量无法判定(上面数字仅作观测)")
# ── C) 干扰 A/B 实测 ──
if args.interfere:
if not boards2:
add("BLOCKED", "C 干扰实测需要先有一块在线板(--expect 1)")
else:
tgt = boards2[0]["ip"]
op = _opener()
def sample(n=20):
lat, fails = [], 0
for _ in range(n):
t = time.time()
try:
with op.open("http://%s/system/status?lite=1" % tgt, timeout=4) as r:
r.read()
lat.append(int((time.time() - t) * 1000))
except Exception:
fails += 1
lat.sort()
return {"n": n, "fails": fails,
"p50": lat[n // 2] if lat else None,
"max": lat[-1] if lat else None}
base = sample()
stop = threading.Event()
def sweep():
while not stop.is_set():
run_all(parse_cidr(cidr), 0) # 进程内直调,别和采样抢同一个 handler 线程
th = threading.Thread(target=sweep, daemon=True)
th.start()
during = sample()
stop.set()
add("INFO", "C 干扰实测(两张数,不做外推):基线 %s / 扫描期间 %s" % (base, during))
add("PASS" if during["fails"] <= base["fails"] + 1 else "FAIL",
"C 扫描期间对在线板的请求失败数不得明显上升(基线 %d → 期间 %d)"
% (base["fails"], during["fails"]))
add("NEEDS-HUMAN", "D 桌面 APP(esp32_app.vbs)里连开 20 批「全段」并在中途关面板/刷新,"
"确认服务不卡死、顶栏 pill 仍能刷新——句柄泄漏与 stderr 冻结只能这么观察")
add("NEEDS-HUMAN", "E AP 回退路径:真人连 ESP32_OTA_RELAY 后重跑本脚本,期望 kind=ap 的那台;"
"PC 连着路由器 WLAN 时它自称 192.168.31.1 但三层不可达,扫不到是正确行为")
add("NEEDS-HUMAN", "F 「本页直测」按钮:在 APP 窗口里对扫到的候选点一次,"
"确认浏览器/WebView2 没有拦 127.0.0.1→私有子网(拦了会显示 A✓B✗ 并给原因)")
return summary()
def summary():
n = {k: sum(1 for s, _, _ in RESULTS if s == k) for k in
("PASS", "FAIL", "BLOCKED", "NEEDS-HUMAN", "WARN", "INFO")}
print("\n合计 PASS=%(PASS)d FAIL=%(FAIL)d BLOCKED=%(BLOCKED)d NEEDS-HUMAN=%(NEEDS-HUMAN)d" % n)
if n["FAIL"]:
return 1
if n["BLOCKED"] or not n["PASS"]:
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
+43 -1
View File
@@ -144,8 +144,50 @@ def verdict(ok, why=""):
return 1
def ask(prompt, timeout_s=600):
"""物理动作确认:必须等到人做完才继续,绝不自动放行。
终端里就是回车;被自动化/远程跑时 stdin 不是终端,改为等一个 ack 文件
(脚本先删掉它,出现即视为确认并再次删除)。两种路径都是人驱动的,只是
不靠 tty —— 换成"超时自动继续"就失去意义了,所以超时按 BLOCKED 收。
"""
import tempfile
# 显式指定了 ack 文件 = 明确要"文件握手",绝不先碰 stdin:后台任务的 stdin 未必
# 给 EOF,一旦落在 input() 上就是无限期吊着(实测踩过)。
ack = os.environ.get("DCU_GUARD_ACK_FILE")
if not ack:
if sys.stdin and hasattr(sys.stdin, "isatty") and sys.stdin.isatty():
try:
raw = input(prompt + "(回车继续 / 输入 s 放弃本测试): ").strip().lower()
if raw == "s":
raise Blocked("用户放弃")
return
except EOFError:
pass # isatty 为真但无输入 → 落到 ack 文件
ack = os.path.join(tempfile.gettempdir(), "dcu_guard_ack")
try:
os.remove(ack)
except FileNotFoundError:
pass
print(" ⏸ 需要人工:%s" % prompt)
print(" 做完后创建这个文件即可继续:%s" % ack)
print(" 等待上限 %ds,期间脚本不做任何总线动作" % timeout_s)
t0 = time.time()
while time.time() - t0 < timeout_s:
if os.path.exists(ack):
try:
os.remove(ack)
except OSError:
pass
print(" 收到确认(%.0fs)" % (time.time() - t0))
return
time.sleep(0.5)
raise Blocked("等待人工确认超时 %ds:%s" % (timeout_s, prompt))
__all__ = ["Link", "Blocked", "open_link", "guard_of", "preflight", "bridge_of",
"verdict", "GUARD_KEYS", "PAGE_BASE"]
"verdict", "ask", "GUARD_KEYS", "PAGE_BASE"]
if __name__ == "__main__":
+12 -3
View File
@@ -200,12 +200,15 @@ section("[2c] DC 自愈:证明配错才动总线,证明不了/正在恢复
return { delta: after - before, reasons: st.startReasons };
};
const bad = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4 });
check("DC 被证明配错 ⇒ 恰又多一次 motor/start(拉回 250µs)",
check("DC 被证明配错(free-run)⇒ 恰又多一次 motor/start(按 period 对齐 DC)",
bad.delta === 1, `多 ${bad.delta} 次`);
check("DC 自愈带 reason=auto_dc_repair(代理侧据此统一节流)",
bad.reasons.some((r) => r === "auto_dc_repair"), bad.reasons.join(","));
const good = await run({ dc: 1, dc_cycle_us: 250, period_ms: 0.25 });
check("DC 正确 ⇒ 零次 motor/start", good.delta === 0, `多 ${good.delta} 次`);
const goodHot = await run({ dc: 1, dc_cycle_us: 500, period_ms: 0.5 });
check("热改档 0.5ms/500us 同值 ⇒ 零次 motor/start(不再被当成配错)",
goodHot.delta === 0, `多 ${goodHot.delta} 次`);
const busy = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4, recovering: 1 });
check("native 正在全量重连 ⇒ 即使 DC 配错也不插第二刀", busy.delta === 0, `多 ${busy.delta} 次`);
const blind = await run({});
@@ -238,8 +241,14 @@ section("[2d] proveEcatLoop / proveEcatDc 三态矩阵");
ebus.proveEcatDc({ ok: true }) === "unknown");
check("proveEcatDc: dc=1/250/0.25 ⇒ ok",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 0.25 }) === "ok");
check("proveEcatDc: period 掉到 4ms ⇒ misconfigured",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "misconfigured");
check("proveEcatDc: 热改档 dc=1/500/0.5 ⇒ ok(period 与 DC 同值即合法)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 500, period_ms: 0.5 }) === "ok");
check("proveEcatDc: period=4ms 且 DC=4000us ⇒ ok(UI 档位内)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "ok");
check("proveEcatDc: period=4ms 但 DC 仍 250us ⇒ misconfigured(不同值)",
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 4 }) === "misconfigured");
check("proveEcatDc: free-run ⇒ misconfigured",
ebus.proveEcatDc({ ok: true, dc: 0, dc_cycle_us: 0, period_ms: 0.25 }) === "misconfigured");
}
/* ── 2e. OTA 负载页的决策:读数不新鲜 ≠ 循环停了 ── */
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""test_lan_probe.py — 设备发现的离线自测(不需要真板子,可 CI 跑)。
覆盖三件事,缺一不可:
1) 判据本身有灵敏度:同一套代码必须把"板子的 lite 响应"和"路由器的 HTML/
缺字段 JSON"分开,也要把"旧固件(无 gd32_features)"收下而不是当反证扔掉。
2) 真 socket 路径走真代码:假板服务绑在**本机 WLAN 网卡地址的高端口**上
(不是 127.0.0.1 —— 用回环测不到 selectors/截止/refused 的真实语义)。
3) 不自证:起/关假板服务,同一请求的判决必须翻转;种子故意指错地址,
真在那儿的假板仍要被找到。
用法: python scripts/test_lan_probe.py 退出码 0=PASS 1=FAIL
"""
import io
import json
import os
import re
import socket
import sys
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import lan_probe as lp # noqa: E402
FAILS = []
PASSES = []
def check(name, cond, detail=""):
if cond:
PASSES.append(name)
print(" PASS " + name)
else:
FAILS.append(name)
print(" FAIL " + name + (" :: " + str(detail) if detail else ""))
def lite_body(mode="STA", ip="192.168.31.127", connected=True, with_feat=True):
b = {"wifi": {"mode": mode, "ip": ip, "connected": connected}}
if with_feat:
b["gd32_features"] = {"known": True, "flags": 15, "can_ch0": True,
"can_ch1": True, "can_ch2": True, "can_ch_mask": 7}
return json.dumps(b)
# ───────────────────────── 1) 判决表(含反向对照) ─────────────────────────
def test_classify():
print("[1] classify 判决表")
ip = "192.168.31.127"
r = lp.classify(ip, 200, "application/json", lite_body(ip=ip))
check("正对照:真 lite 响应 → board/full",
r["verdict"] == "board" and r["schema"] == "full" and r["kind"] == "sta", r)
# 反向对照 1:路由器登录页
r = lp.classify("192.168.31.1", 200, "text/html; charset=utf-8",
"<!DOCTYPE html><title>miwifi</title>")
check("反对照1:HTML → rejected/not-json",
r["verdict"] == "rejected" and r["reason"] == "not-json", r)
# 反向对照 2:形状不对的 JSON
r = lp.classify("192.168.31.9", 200, "application/json", '{"error":"unauthorized"}')
check("反对照2:无 wifi 对象 → rejected/no-wifi-object",
r["verdict"] == "rejected" and r["reason"] == "no-wifi-object", r)
# 反向对照 3:STA 自称在别处(端口转发/捕获门户)
r = lp.classify(ip, 200, "application/json", lite_body(ip="10.0.0.5"))
check("反对照3:STA 自报 IP 不符 → rejected/ip-mismatch",
r["verdict"] == "rejected" and r["reason"].startswith("ip-mismatch"), r)
# 反向对照 4:HTTP 不是 200
r = lp.classify(ip, 404, "text/plain", "not found")
check("反对照4:404 → rejected/http-404",
r["verdict"] == "rejected" and r["reason"] == "http-404", r)
# 旧固件:有 wifi 无 gd32_features 必须**收下**(标 core-only),不能当反证丢
r = lp.classify(ip, 200, "application/json", lite_body(ip=ip, with_feat=False))
check("旧固件无 gd32_features → board/core-only(不丢弃)",
r["verdict"] == "board" and r["schema"] == "core-only", r)
# 关键坑:lite 在 NOT(STA&&connected) 时一律报 AP,所以 AP+connected:false
# 多半是"STA 掉租约的板子",既不能当在线 AP 板,也不能当反证 → unknown
r = lp.classify(ip, 200, "application/json",
lite_body(mode="AP", ip="0.0.0.0", connected=False))
check("AP+connected:false → unknown/ap-not-connected",
r["verdict"] == "unknown" and r["reason"] == "ap-not-connected", r)
# 真 AP 回退(PC 连了板子热点):connected=true 才认
r = lp.classify("192.168.31.1", 200, "application/json",
lite_body(mode="AP", ip="0.0.0.0", connected=True))
check("AP+connected:true → board/kind=ap",
r["verdict"] == "board" and r["kind"] == "ap", r)
# busy/degraded 桩:完整 status 抢不到 mutex 时只回 version+ota.sys_state
r = lp.classify(ip, 200, "application/json",
'{"version":"0.5.0","ota":{"sys_state":"busy"},"cpu":{}}')
check("busy 桩(无 wifi) → rejected/no-wifi-object( lite 不受此影响)",
r["verdict"] == "rejected", r)
# 兜底不许有未定义分支:mode 值不认识 → unknown
r = lp.classify(ip, 200, "application/json", '{"wifi":{"mode":"NA","ip":null}}')
check("mode 非法值 → unknown(兜底,不落 rejected)",
r["verdict"] == "unknown", r)
# ───────────────────────── 2) IP 校验 ─────────────────────────
def test_ip_validation():
print("[2] IPv4 校验(候选表与端点写入都靠它,判据在服务侧)")
for s in ["192.168.31.127", "10.0.0.1", "1.2.3.4"]:
check("收合法地址 " + s, lp.valid_ipv4(s))
for s in ["not-an-ip", "1.2.3.4.5", "256.1.1.1", "", "192.168.31", "1.2.3.04x"]:
check("拒 " + repr(s), not lp.valid_ipv4(s))
# ───────────────────────── 3) 真 socket + 真路由 ─────────────────────────
class _Fake(BaseHTTPRequestHandler):
BODY = ""
CTYPE = "application/json"
CODE = 200
def do_GET(self):
b = self.BODY.encode("utf-8")
self.send_response(self.CODE)
self.send_header("Content-Type", self.CTYPE)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def log_message(self, *a):
pass
def _serve(ip, body, ctype="application/json", code=200):
cls = type("F", (_Fake,), {"BODY": body, "CTYPE": ctype, "CODE": code})
srv = HTTPServer((ip, 0), cls)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, srv.server_address[1]
def local_ip():
n = lp.local_net()
return n.get("default_src_ip"), n
def _free_port(host_ip):
"""要一个"确实没人监听"的端口:绑 0 拿到号→关掉→再 connect 确认被拒。
别拿 bp+1 猜:Windows 会把 ephemeral 端口连着发,猜中的就是隔壁那个服务。"""
for _ in range(20):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((host_ip, 0))
p = s.getsockname()[1]
s.close()
t = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
t.settimeout(0.3)
try:
refused = t.connect_ex((host_ip, p)) != 0
finally:
t.close()
if refused:
return p
return None
def test_probe_route():
print("[3] 真 socket:/api/lan/probe 走真代码路径")
ip, net = local_ip()
check("本机默认路由取到 WLAN 地址(期望 192.168.31.x)",
bool(ip) and ip.startswith("192.168.31."), net)
if not ip:
return
board_body = lite_body(ip=ip) # 自报 IP 必须等于被探地址
board, bp = _serve(ip, board_body)
router, rp = _serve(ip, "<!DOCTYPE html><p>miwifi</p>", "text/html")
closed = _free_port(ip) # 实测被拒的端口
check("拿到一个确实没人监听的端口", closed is not None, closed)
if closed is None:
return
httpd, _p = __import__("esp32_server")._bind_server(0)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
def call(body, timeout=20):
req = urllib.request.Request(
"http://127.0.0.1:%d/api/lan/probe" % port,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8"))
r = call({"ips": [ip], "ports": [bp, rp, closed], "local": True, "timeout_ms": 900})
by = {(x["ip"], x["port"]): x for x in r["results"]}
check("假板(真 socket,绑本机网卡)→ board",
by[(ip, bp)]["verdict"] == "board", by[(ip, bp)])
check("假路由器 → rejected/not-json",
by[(ip, rp)]["verdict"] == "rejected" and by[(ip, rp)]["reason"] == "not-json",
by[(ip, rp)])
check("没人监听的端口 → unknown(不是 rejected:没有反证,只是没答)",
by[(ip, closed)]["verdict"] == "unknown", by[(ip, closed)])
check("tcpOpen 不把 refused 算成开着",
r["scan"]["tcpOpen"] == 2, r["scan"])
check("local 段随响应返回", bool(r.get("local", {}).get("cidr")), r.get("local"))
# 灵敏度对照:关掉假板,同一请求的判决必须翻
board.shutdown()
r2 = call({"ips": [ip], "ports": [bp], "timeout_ms": 600})
check("灵敏度对照:假板下电后 board → unknown",
r2["results"][0]["verdict"] != "board", r2["results"][0])
# 配置自证对照:种子故意指一个错地址,真在那儿的假路由器地址仍要如实分类
r3 = call({"ips": [ip], "ports": [rp], "timeout_ms": 600})
check("种子指错也不会把非板子说成板子", r3["results"][0]["verdict"] == "rejected",
r3["results"][0])
# 非法 IP / 路径白名单
r4 = call({"ips": ["bogus", "1.2.3.4.5"], "ports": [80]})
check("非法 IP 被剔且带原因", r4["scan"]["candidates"] == 0
and len(r4["scan"]["skipped"]) == 2, r4["scan"])
r5 = call({"ips": [ip], "ports": [rp], "path": "/logs"})
check("危险路径不进白名单(/logs 会排空固件日志环)",
"/system/status?lite=1" == r5["path"] and any(
"path-not-allowed" in e for e in r5["errors"]), r5)
# 连续 10 批不泄漏状态:每批结果数必须齐(3 个相同 IP 去重 × 2 个端口 = 2 条)
ok, detail = True, ""
for _ in range(10):
rr = call({"ips": [ip] * 3, "ports": [rp, closed], "timeout_ms": 400})
if len(rr["results"]) != 2:
ok = False
detail = "期望 2 条,实际 %d 条" % len(rr["results"])
break
check("连续多批结果数稳定(批间无残留状态、去重按 ip:port)", ok, detail)
httpd.shutdown()
router.shutdown()
# ───────────────────────── 4) 结构回归 ─────────────────────────
def test_structure():
print("[4] 结构回归(把已经踩过的坑钉住)")
src = io.open(os.path.join(ROOT, "lan_probe.py"), encoding="utf-8").read()
check("lan_probe.py 不写 stdout/stderr(--window 下子进程 stderr 是父进程管道)",
"print(" not in src and "sys.stderr" not in src)
m = re.search(r"^ALLOWED_PATHS = \((.*?)\)", src, re.M)
allowed = m.group(1) if m else ""
check("探测白名单只有安全路径(/logs 会排空固件日志环,/system/probe 会发真实 SPI 事务)",
m is not None and "/logs" not in allowed and "/system/probe" not in allowed
and "PROBE_PATH" in allowed and '"/system/status"' in allowed, allowed)
srv = io.open(os.path.join(ROOT, "esp32_server.py"), encoding="utf-8").read()
check("路由挂在 do_POST(GET 会被无扩展名当 HTML 的分支吃掉)",
srv.count('"/api/lan/probe"') >= 1
and "_handle_lan_probe" in srv)
check("QuietServer.handle_error 仍静默(异常灌进父进程 4KB 管道会冻住服务)",
re.search(r"def handle_error\(self[^)]*\):\s*\n\s+pass", srv) is not None)
js = {}
for rel in ["js/common/base-url.js", "js/common/device-link.js", "js/can.js",
"js/can/dbc.js", "js/common/lan-picker.js", "js/app.js"]:
js[rel] = io.open(os.path.join(ROOT, rel), encoding="utf-8").read()
writers = [k for k, v in js.items() if "saveDeviceIp(" in v.replace("function saveDeviceIp(", "")]
check("写端点的只有 device-link(+ base-url 自身实现)",
set(writers) <= {"js/common/base-url.js", "js/common/device-link.js"}, writers)
check("can.js 不再把设备自报 IP 回写成端点(0.0.0.0 污染源)",
"setDeviceIp" not in js["js/can.js"] and "setDeviceIp" not in js["js/can/dbc.js"])
check("can.js 改为只提示不符", "与当前端点" in js["js/can.js"])
check("device-link 里端点唯一写手 + 刷写门",
"selectDevice: function" in js["js/common/device-link.js"]
and "flashBusyNow()" in js["js/common/device-link.js"])
check("自动补扫门齐(failStreak + 刷写 + ECAT 环跑 + 冷却)",
"maybeAutoScan" in js["js/common/device-link.js"]
and "snap.links.ecat.loop" in js["js/common/device-link.js"])
check("面板不重算判据(只取服务侧 verdict 字段,不自己解析 gd32_features)",
re.search(r"gd32_features\s*[\[.]", js["js/common/lan-picker.js"]) is None
and ".verdict" in js["js/common/lan-picker.js"])
check("当前端点不进候选表(不重复占设备 httpd 的 socket)",
"当前端点" in js["js/common/lan-picker.js"])
def main():
print("== lan_probe 自测 ==")
test_classify()
test_ip_validation()
test_probe_route()
test_structure()
print("\n合计 PASS=%d FAIL=%d" % (len(PASSES), len(FAILS)))
if FAILS:
print("FAILED: " + "; ".join(FAILS))
return 1
print("ALL PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+21 -1
View File
@@ -70,6 +70,9 @@ const REQUIRED = [
"probe", "fetchStatus", "describeStatus", "reportedIp",
"fetchMotorState", "fetchFeedback", "sendFrames", "sendBurst",
"fetchVersionNode", "setPeriod", "teardown",
/* 电机 OTA 整套:少一个就是"点开始才崩",流程只在 ota.js 写一遍,两条链路都得给 */
"otaHostSendsBootFrame", "otaIntegrity",
"otaUploadAttempt", "otaStartRelay", "otaStatus", "otaCancel",
];
const wifi = createWifiTransport(CTX);
const ecat = createEcatTransport(CTX);
@@ -120,7 +123,17 @@ section("[2] WiFi /motor/state?bin=1 二进制解析");
/* ── 3. 能力位 ── */
section("[3] 链路能力位");
check("WiFi 支持 OTA", wifi.supportsOta === true);
check("EtherCAT 不支持 OTA(没有固件上传+relay 通路)", ecat.supportsOta === false);
/* EtherCAT 的料走 CoE 邮箱进 GD32 暂存区(DCU_APP comm/ecat_motor_ota.cpp),
* 中继引擎与 WiFi 同一个 ⇒ 能力位必须为真。曾经这里是 false,
* 因为那条链路只有 ESP32 上传这一条进料路(纯网线打不通设备 IP)。 */
check("EtherCAT 支持 OTA(邮箱进料 + 同一个 GD32 relay)", ecat.supportsOta === true);
/* 两处真实差异必须被钉住,别被"看着一样"糊过去:
* - 进 BOOT 帧谁发(EtherCAT 必须 GD32 在已加锁通道上发,否则运动帧抢邮箱)
* - 完整性口径(WiFi 回读 SHA-256,EtherCAT 从站核 CRC32,不许互相冒充) */
check("WiFi 由上位机先进 BOOT", wifi.otaHostSendsBootFrame === true);
check("EtherCAT 由 GD32 在已加锁通道上先进 BOOT", ecat.otaHostSendsBootFrame === false);
check("WiFi 完整性口径 = ESP32 flash 回读 SHA-256", wifi.otaIntegrity === "sha256-esp32-flash");
check("EtherCAT 完整性口径 = 从站核 CRC32(进 BOOT 前)", ecat.otaIntegrity === "crc32-verify-in-slave");
check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
@@ -499,6 +512,13 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
JSON.stringify({ periodHits, startHits2 }));
check("(i4) 周期值持久化到 localStorage(服务重启后不用重敲)",
globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.25");
/* 放开热改:0.5ms 档必须原样下发,不得被前端钳回 0.25 */
const got05 = await tp.setPdoPeriod(0.5);
check("(i4) setPdoPeriod(0.5) 不钉死 0.25:原样下发并持久化",
got05 === 0.5 && periodBody.period_ms === 0.5
&& globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.5",
JSON.stringify({ got05, periodBody }));
await tp.setPdoPeriod(0.25); /* 回落到默认档,后续 ensureStarted 用例口径不变 */
await tp.probe();
check("(i4) 循环重启(ensureStarted)自动带上持久化的周期",
startHits2 === 1 && startBody2.period_ms === 0.25, JSON.stringify(startBody2));
+30 -5
View File
@@ -14,7 +14,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 调试控制台 — DCU</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/base.css?v=a7b6c32b">
<!-- 外壳也要 .btn/.pill/.input 这些共享 chrome:只在 base.css 里写弹窗,
控件一律用 layout.css 的既有类,避免顶栏那颗按钮退回浏览器默认样式 -->
<link rel="stylesheet" href="css/layout.css?v=20260925n">
<style>
html, body { height: 100%; margin: 0; }
body {
@@ -34,6 +37,8 @@
border-right: 1px solid var(--border);
font-weight: 600; font-size: .86rem; color: var(--text-dim);
letter-spacing: .02em;
/* 右侧加了设备 pill + 发现按钮后空间变紧:不换行也不被挤扁 */
flex-shrink: 0; white-space: nowrap;
}
.tabbar .brand .dot { width: 9px; height: 9px; border-radius: 50%;
background: var(--esp32); box-shadow: 0 0 8px var(--esp32); }
@@ -42,8 +47,9 @@
color: var(--text-dim); font-size: .84rem; font-family: inherit;
padding: 0 16px; display: flex; align-items: center; gap: 7px;
border-bottom: 2px solid transparent; transition: color .12s, background .12s;
white-space: nowrap;
min-width: 0; /* 窄窗口时由页签让位(标签省略号),设备入口不被挤出 */
}
.tab .lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tab:hover { color: var(--text); background: var(--bg-hover); }
.tab.active { color: var(--text); border-bottom-color: var(--esp32); background: var(--bg-card); }
.tab .ico { font-size: .95rem; opacity: .9; }
@@ -51,7 +57,14 @@
.tab.active .accent { opacity: 1; }
.tabbar .spacer { flex: 1 1 auto; }
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
font-size: .72rem; padding: 0 8px; }
font-size: .72rem; padding: 0 8px; white-space: nowrap; flex-shrink: 0; }
.tabbar .lan-slot { flex-shrink: 0; }
/* 窄窗口(APP 拖窄 / 1024 级屏幕):先让最不值钱的刷新提示让位、页签收紧,
设备入口不能被挤出可视区——body 是 overflow:hidden,挤出去就点不到了。 */
@media (max-width: 1280px) {
.tabbar .hint { display: none; }
.tab { padding: 0 11px; }
}
/* 多 iframe 宿主:占满剩余高度;子帧绝对定位铺满。
隐藏用 visibility 而非 display:none —— 少触发引擎对不可见文档的定时器/绘制压制,
@@ -73,6 +86,14 @@
<div class="brand"><span class="dot"></span>ESP32 调试控制台</div>
<!-- 标签由 TABS 数组渲染 -->
<div class="spacer"></div>
<!-- 设备端点:用各页同款 .pill 组件(online/offline/processing 状态类),
文本由 js/common/lan-picker.js 从 Owner 快照刷,本文件不自己判活 -->
<div class="lan-slot">
<span class="pill offline" id="lanPill" title="当前设备端点">
<span class="indicator"></span><span class="pill-val" id="lanPillTxt">未连接</span>
</span>
<button class="btn btn-outline btn-sm" id="lanScanBtn" title="扫描局域网设备 / 选择连接哪一台">🔍 发现</button>
</div>
<div class="hint">⟳ 刷新当前页 · F5</div>
</nav>
<!-- 外壳标记:注入脚本用 #viewHost 判定「标签外壳」;各功能页是其子 iframe.page-frame -->
@@ -163,7 +184,8 @@
b.className = 'tab'; b.type = 'button';
b.setAttribute('data-page', t.page);
b.innerHTML = '<span class="accent" style="background:'+t.color+'"></span>'
+ '<span class="ico">'+t.ico+'</span>' + t.label;
+ '<span class="ico">'+t.ico+'</span>'
+ '<span class="lbl">' + t.label + '</span>';
b.onclick = function(){ go(t.page); };
bar.insertBefore(b, spacer);
});
@@ -181,6 +203,9 @@
<script src="js/common/app-lease.js"></script>
<!-- DeviceLink Owner:全链路健康轮询只在本外壳跑一次,各 iframe 订阅 -->
<script>window.__dcuDeviceLinkOwner = true;</script>
<script type="module" src="js/common/device-link.js?v=20260922a"></script>
<script type="module" src="js/common/device-link.js?v=20261008b"></script>
<!-- 设备发现面板(外壳是宿主:顶栏 pill + 🔍 + 覆盖层都在这里) -->
<div id="lanMount"></div>
<script type="module" src="js/common/lan-picker.js?v=20261008b"></script>
</body>
</html>