From eac3717f9ac58505a30a5b950108d0b41c8a0d4b Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Tue, 13 Oct 2026 00:48:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B1=80=E5=9F=9F?= =?UTF-8?q?=E7=BD=91=E8=AE=BE=E5=A4=87=E5=8F=91=E7=8E=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E9=93=BE=E8=B7=AF=E8=83=B6=E5=9B=8A=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示 --- README.md | 69 +- css/base.css | 106 +++ css/can.css | 13 + css/components.css | 53 +- css/layout.css | 21 + esp32_server.py | 60 +- js/app.js | 151 +++- js/ble/gatt.js | 10 +- js/ble/state.js | 28 +- js/can.js | 40 +- js/can/dbc.js | 12 +- js/can/send.js | 2 + js/can_monitor.js | 168 ++++- js/common/base-url.js | 90 ++- js/common/connection.js | 146 +++- js/common/device-link.js | 171 ++++- js/common/ecat-bus.js | 85 ++- js/common/lan-picker.js | 703 +++++++++++++++++ js/common/polling.js | 3 +- js/common/utils.js | 33 + js/motor/main.js | 45 +- js/motor/ota.js | 261 ++++--- js/motor/transport-ecat.js | 159 +++- js/motor/transport-wifi.js | 76 +- js/motor/transport.js | 17 +- js/ota/boot_ecat.js | 212 +++++- js/ota/ecat_load.js | 5 + js/ota/stages.js | 20 +- lab/spi_test.html | 28 +- lan_probe.py | 405 ++++++++++ pages/can.html | 21 +- pages/can_monitor.html | 33 +- pages/index.html | 1205 +++++++++++++++--------------- pages/motor.html | 6 +- scripts/check_guard_fires_hw.py | 11 +- scripts/check_lan_discovery.py | 279 +++++++ scripts/guard_probe.py | 44 +- scripts/test_ecat_autostart.mjs | 15 +- scripts/test_lan_probe.py | 300 ++++++++ scripts/test_motor_transport.mjs | 22 +- tabs.html | 35 +- 41 files changed, 4229 insertions(+), 934 deletions(-) create mode 100644 js/common/lan-picker.js create mode 100644 lan_probe.py create mode 100644 scripts/check_lan_discovery.py create mode 100644 scripts/test_lan_probe.py diff --git a/README.md b/README.md index 8b3ff6c..4c11975 100644 --- a/README.md +++ b/README.md @@ -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 数量。 diff --git a/css/base.css b/css/base.css index 28c07c5..48b5868 100644 --- a/css/base.css +++ b/css/base.css @@ -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); } diff --git a/css/can.css b/css/can.css index 3484997..d41a870 100644 --- a/css/can.css +++ b/css/can.css @@ -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 规则 */ diff --git a/css/components.css b/css/components.css index 5f86a1b..7d2564e 100644 --- a/css/components.css +++ b/css/components.css @@ -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,避免异步连接失败导致状态错位。 */ diff --git a/css/layout.css b/css/layout.css index 23ae82d..7fb9e38 100644 --- a/css/layout.css +++ b/css/layout.css @@ -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; diff --git a/esp32_server.py b/esp32_server.py index f111746..1b0babb 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -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: diff --git a/js/app.js b/js/app.js index 6201554..5b6acd6 100644 --- a/js/app.js +++ b/js/app.js @@ -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; + }); } // 初始化按钮状态与提示 diff --git a/js/ble/gatt.js b/js/ble/gatt.js index 25282e9..72e176d 100644 --- a/js/ble/gatt.js +++ b/js/ble/gatt.js @@ -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); diff --git a/js/ble/state.js b/js/ble/state.js index 2e4bfb9..8d2b034 100644 --- a/js/ble/state.js +++ b/js/ble/state.js @@ -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--'; diff --git a/js/can.js b/js/can.js index 52beb77..65df55a 100644 --- a/js/can.js +++ b/js/can.js @@ -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(); diff --git a/js/can/dbc.js b/js/can/dbc.js index 994de84..890d53f 100644 --- a/js/can/dbc.js +++ b/js/can/dbc.js @@ -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://)。 */ export function getBaseUrl() { return 'http://' + getDeviceIp(); diff --git a/js/can/send.js b/js/can/send.js index c131446..165addf 100644 --- a/js/can/send.js +++ b/js/can/send.js @@ -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 时序冲突)' }; diff --git a/js/can_monitor.js b/js/can_monitor.js index 7cdda03..63d8d3b 100644 --- a/js/can_monitor.js +++ b/js/can_monitor.js @@ -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); } diff --git a/js/common/base-url.js b/js/common/base-url.js index a0386b8..2f302c2 100644 --- a/js/common/base-url.js +++ b/js/common/base-url.js @@ -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} + */ +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 (_) {} +} diff --git a/js/common/connection.js b/js/common/connection.js index ee33536..869581d 100644 --- a/js/common/connection.js +++ b/js/common/connection.js @@ -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()); } diff --git a/js/common/device-link.js b/js/common/device-link.js index ec1ffa6..cb897a6 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -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 (_) {} diff --git a/js/common/ecat-bus.js b/js/common/ecat-bus.js index ec99344..a688ca3 100644 --- a/js/common/ecat-bus.js +++ b/js/common/ecat-bus.js @@ -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} 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)。 */ diff --git a/js/common/lan-picker.js b/js/common/lan-picker.js new file mode 100644 index 0000000..6d60234 --- /dev/null +++ b/js/common/lan-picker.js @@ -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 = '
  • '; + h += '' + escapeHtml(b.kind === 'ap' ? 'AP' : 'STA') + ''; + h += '' + escapeHtml(b.ip) + ' ' + + '' + escapeHtml(boardLabel(b)) + ''; + h += '' + + '' + + '' + + ''; + h += '' + escapeHtml(ageText(b.observedAt)) + ''; + if (d) { + h += '' + (d.ok + ? '本页可达 ✓ HTTP ' + d.http + ' · ' + d.ms + 'ms' + : '本页取不到:' + escapeHtml(d.name + ' ' + d.msg) + + '(服务侧扫到了,多半是浏览器对本机访问私有网段的限制)') + + ''; + } + h += '
  • '; + 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 += '
  • ' + + '当前' + + '' + escapeHtml(epIp) + ' ' + + '' + (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' : '') + + '
  • '; + } + html += rows.map(function (b) { return rowHtml(b); }).join(''); + if (state.rows.length > MAX_ROWS) { + html += '
  • 列表截断到 ' + MAX_ROWS + ' 行,实际 ' + state.rows.length + ' 台
  • '; + } + if (!rows.length && !state.scanning) { + html += '
  • 本轮没扫到板子。别急着当成"板子没了",先看下面的计数与反证列表:
  • '; + } + if (lastListHtml !== html) { list.innerHTML = html; lastListHtml = html; } + } + + var misc = panelEl.querySelector('.lan-misc'); + if (misc) { + var m = ''; + if (state.unknowns.length) { + m += '
    不可判定 ' + state.unknowns.length + ' 个' + + '(连不上 / 读超时 / 形状合但落不进判据)——超时不等于"不在",' + + 'ECAT 环跑与刷写期间设备 httpd 本来就可能不响应。
      ' + + state.unknowns.slice(0, MAX_ROWS).map(function (u) { + return '
    • ' + escapeHtml(u.ip) + ' — ' + escapeHtml(u.reason || '') + '
    • '; + }).join('') + '
    '; + } + if (state.rejected.length) { + m += '
    反证排除 ' + state.rejected.length + ' 个(有证据不是板子,不是"没扫到")
      ' + + state.rejected.slice(0, MAX_ROWS).map(function (r) { + return '
    • ' + escapeHtml(r.ip) + ':' + (r.port || 80) + ' — ' + + escapeHtml(r.reason || '') + (r.head ? ' 「' + escapeHtml(String(r.head).slice(0, 60)) + '」' : '') + '
    • '; + }).join('') + '
    '; + } + if (state.skipped.length) { + m += '
    剔掉 ' + state.skipped.length + ' 个
      ' + + state.skipped.map(function (s) { + return '
    • ' + escapeHtml(String(s[0])) + ' — ' + escapeHtml(String(s[1])) + '
    • '; + }).join('') + '
    '; + } + if (state.local) { + m += '
    本机:' + escapeHtml(state.local.default_src_ip || '?') + + ' → 段 ' + escapeHtml(state.local.cidr || '(取不到)') + + ';本机地址 ' + escapeHtml((state.local.local_ips || []).join(', ') || '—') + '
    '; + } + m += '
    两块同款板除了 IP 无法区分(固件不上报 mac/chip_id)。' + + '认领办法:选中一行看「详情」的 uptime,或按板上复位键后立刻快扫——新上电那台 uptime < 30s。' + + 'AP 回退的板子自称 192.168.31.1:PC 还连着路由器 WLAN 时它在三层上不可达,' + + '要真人连热点 ESP32_OTA_RELAY 才探得到。
    '; + 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 = + ''; + +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(); diff --git a/js/common/polling.js b/js/common/polling.js index ec986d2..91f5380 100644 --- a/js/common/polling.js +++ b/js/common/polling.js @@ -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); diff --git a/js/common/utils.js b/js/common/utils.js index a3bd350..1560603 100644 --- a/js/common/utils.js +++ b/js/common/utils.js @@ -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; +} diff --git a/js/motor/main.js b/js/motor/main.js index b6f835d..74e7757 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -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, }); diff --git a/js/motor/ota.js b/js/motor/ota.js index 6681a29..e48c204 100644 --- a/js/motor/ota.js +++ b/js/motor/ota.js @@ -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(`[异常] 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('[未启动] 上一次 OTA 状态未复位(ota.running=true),本次点击已忽略。' + + '点"取消"或刷新页面即可复位;若经常走到这里,看下面 [校验] 行的异常。'); return; } - if (!ota.file) { toast("请先选择固件 (.bin)", "warn"); return; } + if (!state.connected) { + otaLog('[未启动] 设备未连接(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('[未启动] 当前链路 ' + escapeHtml(String(state.tp.label || state.tpId)) + + ' 没有电机 OTA 通路。'); + toast("当前链路(" + state.tp.label + ")没有电机 OTA 通路", "err"); + return; + } + if (!ota.file) { + otaLog('[未启动] 未选择固件文件。'); + toast("请先选择固件 (.bin)", "warn"); return; + } /* 进 BOOT 需要目标电机 node_id(构造 0x000N0000),未选节点则中止。 * node=0 会变成 0x00000000(取消帧/广播),必须拒绝。 */ const node = state.selectedNode; if (node == null || node < 1) { + otaLog('[未启动] 未选中目标节点(当前 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(`[校验] 本地固件 SHA-256 计算失败:${escapeHtml(e.message || e)},OTA 未启动`); + toast("固件校验计算失败,见 OTA 日志", "err"); + otaDone(); + return; + } otaLog(`[校验] 本地固件 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(`[上传] 上传固件到 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(`[上传] 第 ${attempt} 次上传失败:${escapeHtml(e.message || e)}`); - if (attempt < kUploadMaxAttempts) await sleep(300); - continue; - } - otaLog(`[上传] 固件已存储 (${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(`[校验] 设备未返回 flash SHA(旧 ESP32 固件,建议先刷新 ESP32),跳过比对继续`); + otaLog(`[上传] ${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(`[上传] ${escapeHtml(String(up.note || "固件已就位"))}` + + (up.size ? ` (${Number(up.size).toLocaleString()} B)` : "")); uploadOk = true; break; } - if (upJson.sha256.toLowerCase() === fwShaHex) { - otaLog(`[校验] flash SHA 比对一致,允许进 BOOT`); - uploadOk = true; - break; - } - otaLog(`[校验] 第 ${attempt} 次 flash SHA 不一致(device=${escapeHtml(String(upJson.sha256.slice(0, 16)))}…)${attempt < kUploadMaxAttempts ? ",重传…" : ""}`); + otaLog(`[上传] 第 ${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(`[进BOOT] 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`); - try { - const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true }); - if (r.ok) { - otaLog(`[进BOOT] 命令已发送并确认`); - } else { - otaLog(`[进BOOT] 命令已发出(无 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(`[进BOOT] 发送 CANFD_BOOT ${hexId(bootId)} → 节点 ${node} (CH${ch})`); + try { + const r = await sendBatch([{ id: bootId, dlc: 0, data: "" }], { allowOffline: true }); + if (r.ok) { + otaLog(`[进BOOT] 命令已发送并确认`); + } else { + otaLog(`[进BOOT] 命令已发出(无 CAN 应答属正常——电机跳 BOOT 来不及 ACK,交握手确认)`); + } + } catch (e) { + otaLog(`[进BOOT] 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`); } - } catch (e) { - otaLog(`[进BOOT] 发送异常:${escapeHtml(e.message || e)}(继续,由握手确认是否进 BOOT)`); + } else { + /* EtherCAT:进 BOOT 帧由 GD32 自己在**已加锁**的通道上发。这条链路的周期运动帧 + * 与它挤同一批 CAN 邮箱;先解锁让上位机发帧,等于把窗口留给运动帧(单发模式 + * 不重传,固件包一旦被 abort 就是白等 100ms 重发,握手期更会被打成 0x05)。 */ + otaLog(`[进BOOT] 交 GD32 在已独占的 CH${ch} 上发 CANFD_BOOT 0x000${nodeHex(node)}0000(运动帧已被通道锁挡住)`); } - otaLog(`[进BOOT] 命令已发出,立即触发转发…`); - /* ── 步骤 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(`[转发] GD32 开始握手…`); + /* ── 步骤 4:触发转发/灌写(纯启动扳机,不带镜像内容)── + * 完整性核对已在上一步与从站启动前闭环,这里只下发起止指令。 + * 无前端 sleep:电机跳转由 GD32 kPreCancel 等 0x103 心跳窗口吸收(3s 超时报 0x02)。 */ + await state.tp.otaStartRelay(ch, node); + otaLog(`[转发] 已触发:从站核对→锁通道→进 BOOT→起握手…`); startOtaPoll(); } catch (e) { otaLog(`[失败] ${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(`[完成] 传输完成 · ${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(`[完成] 传输完成 · ${pkAcked}/${pkTotal} 包 · 电机新 APP 已发 0AAC 运行确认,核对版本…`); + } else if (st.state === "finished") { + otaLog(`[可疑] 回执 finished 但没有真实传输(acked=${pkAcked}/total=${pkTotal}),不判成功` + + ` · 看 [状态] 行与 native 读数:料没进去或启动被挡` + + (st.note ? ` · ${escapeHtml(String(st.note))}` : "")); + toast("OTA 未发生真实传输,不能算成功", "err"); } else if (st.state === "error") { const errHint = st.error === 0x25 ? "(刷写传完但新 APP 未发 0AAC 运行确认——APP 可能未启动)" : ""; - otaLog(`[错误] 代码 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(`[错误] 代码 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("[已停] 会话回到空闲且未给出结果(多为启动指令未生效或已被清理),可重新点开始" + 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(`[取消] 第 ${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(`[取消] 第 ${i} 次失败:${escapeHtml(String((r && r.note) || "未确认"))}`); if (!ok && i < 3) await sleep(500); } if (!ok) otaLog(`[取消] 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 恢复 */ }); } } diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index fb8599f..a9d716e 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -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 { diff --git a/js/motor/transport-wifi.js b/js/motor/transport-wifi.js index ece252a..0cd9305 100644 --- a/js/motor/transport-wifi.js +++ b/js/motor/transport-wifi.js @@ -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))); diff --git a/js/motor/transport.js b/js/motor/transport.js index 5c4e1d8..d45863d 100644 --- a/js/motor/transport.js +++ b/js/motor/transport.js @@ -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"; diff --git a/js/ota/boot_ecat.js b/js/ota/boot_ecat.js index 62b02ab..4b83219 100644 --- a/js/ota/boot_ecat.js +++ b/js/ota/boot_ecat.js @@ -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) { diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js index f0ee5f2..7dbf7b2 100644 --- a/js/ota/ecat_load.js +++ b/js/ota/ecat_load.js @@ -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; } diff --git a/js/ota/stages.js b/js/ota/stages.js index bd5126d..06fad01 100644 --- a/js/ota/stages.js +++ b/js/ota/stages.js @@ -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; diff --git a/lab/spi_test.html b/lab/spi_test.html index 55d8e33..416ba71 100644 --- a/lab/spi_test.html +++ b/lab/spi_test.html @@ -471,11 +471,11 @@ 空闲 -
    +
    热改周期走 /api/motor/period(循环每拍重读的变量,全程喂狗不断流,随时可调、可反复调)。 - 低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核;从站固件修好前别低于 1ms —— 实测 - 0.25 / 0.125ms 档 GD32 顶到 99.5%,CAN 到帧率 λ 从 4000/s 塌到 149/s(瓶颈在从站不在主站, - 只看主站达成率会整条看漏)。
    + 合法档位 0.125–4 ms,DC SYNC0 与 period 始终同值;选完立刻生效,回读以状态栏为准。 + 低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核;极快档(0.25/0.125)对 GD32 SSC 压力大, + 极慢档(≥1ms)发送出口延迟会变长 —— 都是可接受的代价,不是被钉死。
    观测默认 10Hz。逐拍环 / 原始窗口 / 每拍重建都在各自模块卡片里开关(默认关)。标签页切到后台会自动暂停。
    @@ -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); } }; diff --git a/lan_probe.py b/lan_probe.py new file mode 100644 index 0000000..9626447 --- /dev/null +++ b/lan_probe.py @@ -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} diff --git a/pages/can.html b/pages/can.html index ccc17fd..55f12a1 100644 --- a/pages/can.html +++ b/pages/can.html @@ -7,9 +7,9 @@ - + - + @@ -30,9 +30,9 @@
    - 未连接 - IP -- - RSSI -- + + 未连接 + 运行 -- v-- CH0 @@ -216,6 +216,15 @@
    +
    发送静默(只听不发)
    +
    + + + +
    +
    + 只听不发=把该通道收发器的驱动器关掉(GD32 拉高 S 脚),接收与上报照常;按钮高亮表示当前正处于只听不发,状态来自 1Hz 反馈的 bus_state bit5,不是本地猜测。静默期间该通道发送会被拒绝(错误码 0xE6),上电后默认不静默。 +
    地址与顶栏一致;1Hz /can/feedback。悬停抽屉保持展开。
    @@ -329,6 +338,6 @@ 操作成功 - + diff --git a/pages/can_monitor.html b/pages/can_monitor.html index 6271769..04a6f59 100644 --- a/pages/can_monitor.html +++ b/pages/can_monitor.html @@ -7,9 +7,9 @@ - + - + @@ -17,11 +17,24 @@
    - - ESP32 CAN 接收监控 + +
    + ESP32 CAN 接收监控 + DCU · Robot_v2 · CAN FD +
    - 未连接 + + 未连接 + + 运行 -- + v-- 模式: -- 轮询: 待连接
    @@ -51,6 +64,14 @@ +
    + +
    + + + +
    +
    @@ -184,6 +205,6 @@ 操作成功
    - + \ No newline at end of file diff --git a/pages/index.html b/pages/index.html index bc7a374..98c3153 100644 --- a/pages/index.html +++ b/pages/index.html @@ -1,584 +1,621 @@ - - - - - - -ESP32 OTA 中继控制台 - - - - - - - - - -
    - - -
    -
    - -
    - ESP32 OTA 中继控制台 - DCU · Robot_v2 · Relay -
    -
    -
    - 未连接 - v-- - -- - -- - 运行 -- -
    -
    - 设备名: - - 设备ID: - -
    -
    - - -
    - - -
    - - -
    -
    🔗 设备连接
    -
    -
    - - - -
    -
    -
    - -
    - - -
    -
    -
    - - - - - -
    -
    - - -
    -
    🎯 升级目标、固件文件与执行操作
    -
    -
    - - -
    -
    -
    -
    -
    📤
    -

    点击或拖拽 ESP32 固件

    -
    - -
    -
    - 📄 - - -
    -
    -
    类型-
    -
    大小-
    -
    -
    - SHA-256 - - -
    -
    -
    - - - - -
    -
    - -
    - - - - -
    -
    -
    - - -
    -
    ⚡ 刷写进度与流程阶段
    -
    - -
    -
    📦ESP32 OTA
    -
    📤GD32 OTA
    -
    ⚡EtherCAT
    -
    - -
    -
    -
    - 等待操作... - 0% -
    -
    -
    - - - - - -
    -
    1
    认证握手
    -
    2
    传输协商
    -
    3
    数据转发
    -
    4
    收尾验证
    -
    5
    APP 启动
    -
    6
    OTA完成
    -
    - - -
    - - 待机 -
    - -
    - -
    -
    -
    - -
    - - -
    - - -
    -
    📡 设备状态
    -
    -
    -
    -
    离线
    -
    连接状态
    -
    - -
    -
    offline
    -
    BLE 连接
    -
    -
    -
    未就绪
    -
    GATT 状态
    -
    -
    - - -
    -
    - - -
    -
    📊 运行指标
    -
    -
    -
    ESP32 温度
    --
    -
    GD32 温度
    --
    -
    GD32 负载
    --
    -
    输入 VCC
    --
    -
    VDDA
    --
    -
    GD32 APP
    --
    -
    GD32 BOOT
    --
    -
    GD32 自检
    --
    -
    -
    -
    - - - - -
    -
    - 🖥️ 调试日志 - UDP :6666 -
    -
    -
    -
    - - - - -
    - -
    -
    -
    [--:--:--]I[系统]等待上位机连接...
    -
    -
    - 共 0 条 - -
    -
    -
    - -
    -
    -
    - - - - - -
    - ✓ - 操作成功 -
    - - - - - - - - - - + + + + + + +ESP32 OTA 中继控制台 + + + + + + + + + +
    + + +
    +
    + +
    + ESP32 OTA 中继控制台 + DCU · Robot_v2 · Relay +
    +
    +
    + + WiFi-- + + + ECAT-- + + + BLE未连接 + + v-- + 运行 -- +
    +
    + + +
    + + +
    + + +
    +
    + 🔗 设备连接 + +
    + WiFi + +
    +
    +
    +
    + + + +
    +
    +
    + +
    + + + + +
    +
    +
    + + + + + +
    +
    + + +
    +
    🎯 升级目标、固件文件与执行操作
    +
    +
    + + +
    +
    +
    +
    +
    📤
    +

    点击或拖拽 ESP32 固件

    +
    + +
    +
    + 📄 - + +
    +
    +
    类型-
    +
    大小-
    +
    +
    + SHA-256 + - +
    +
    +
    + + + + +
    +
    + +
    + + + + +
    +
    +
    + + +
    +
    ⚡ 刷写进度与流程阶段
    +
    + +
    +
    📦ESP32 OTA
    +
    📤GD32 OTA
    +
    ⚡EtherCAT
    +
    + +
    +
    +
    + 等待操作... + 0% +
    +
    +
    + + + + + +
    +
    1
    认证握手
    +
    2
    传输协商
    +
    3
    数据转发
    +
    4
    收尾验证
    +
    5
    APP 启动
    +
    6
    OTA完成
    +
    + + +
    + + 待机 +
    + +
    + +
    +
    +
    + +
    + + +
    + + +
    +
    📡 设备状态
    +
    +
    +
    + WiFi +
    离线
    +
    连接状态
    +
    + +
    + BLE +
    offline
    +
    BLE 连接
    +
    +
    + BLE +
    未就绪
    +
    GATT 状态
    +
    +
    + + +
    +
    + + +
    +
    📊 运行指标
    +
    +
    +
    + WiFi +
    ESP32 温度
    --
    +
    + WiFi +
    GD32 温度
    --
    +
    + ECAT +
    GD32 负载
    --
    +
    + WiFi +
    输入 VCC
    --
    +
    + WiFi +
    VDDA
    --
    +
    + WiFi +
    GD32 APP
    --
    +
    + WiFi +
    GD32 BOOT
    --
    +
    + WiFi +
    GD32 自检
    --
    +
    +
    +
    + + + + +
    +
    + 🖥️ 调试日志 + UDP :6666 +
    +
    +
    +
    + + + + +
    + +
    +
    +
    [--:--:--]I[系统]等待上位机连接...
    +
    +
    + 共 0 条 + +
    +
    +
    + +
    +
    +
    + + + + + +
    + ✓ + 操作成功 +
    + + + + + + + + + + + + diff --git a/pages/motor.html b/pages/motor.html index acafa72..474181c 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -286,8 +286,8 @@
    -
    [就绪] 电机 Bootloader OTA · 先上传固件到 ESP32 → 发 0x000N0000 进 BOOT → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)
    -
    [信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限
    +
    [就绪] 电机 Bootloader OTA · 固件先灌进 GD32 → 发 0x000N0000 进 BOOT → GD32 本地 relay(AES-128 + CAN stop-and-wait)· 每包 48 B · 48 KB 上限
    +
    [信息] 进料与完整性核对按链路不同:WiFi = 上传到 ESP32,设备从 flash 读回算 SHA-256 与本地比对,再经 SPI 喂给 GD32;进 BOOT 帧由本页发。EtherCAT = 经 CoE 邮箱 0x2511 灌进 GD32 的 48KB 暂存区,分块 CRC32 + 整镜像 CRC32 由从站在电机进 BOOT 之前核对;进 BOOT 帧由 GD32 在已独占的通道上自己发(运动帧被通道锁挡住)。只插网线也能刷。两条链路共用同一个 GD32 引擎;刷写期间本链路的过程数据与轮询会暂停,其他电机也收不到运动指令。
    @@ -430,7 +430,7 @@ - + diff --git a/scripts/check_guard_fires_hw.py b/scripts/check_guard_fires_hw.py index 3567c51..40f68f3 100644 --- a/scripts/check_guard_fires_hw.py +++ b/scripts/check_guard_fires_hw.py @@ -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: diff --git a/scripts/check_lan_discovery.py b/scripts/check_lan_discovery.py new file mode 100644 index 0000000..878fdf5 --- /dev/null +++ b/scripts/check_lan_discovery.py @@ -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()) diff --git a/scripts/guard_probe.py b/scripts/guard_probe.py index 63ed692..7650dd5 100644 --- a/scripts/guard_probe.py +++ b/scripts/guard_probe.py @@ -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__": diff --git a/scripts/test_ecat_autostart.mjs b/scripts/test_ecat_autostart.mjs index 239d192..8a9128e 100644 --- a/scripts/test_ecat_autostart.mjs +++ b/scripts/test_ecat_autostart.mjs @@ -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 负载页的决策:读数不新鲜 ≠ 循环停了 ── */ diff --git a/scripts/test_lan_probe.py b/scripts/test_lan_probe.py new file mode 100644 index 0000000..56c6221 --- /dev/null +++ b/scripts/test_lan_probe.py @@ -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", + "miwifi") + 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, "

    miwifi

    ", "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()) diff --git a/scripts/test_motor_transport.mjs b/scripts/test_motor_transport.mjs index 062c679..f719c73 100644 --- a/scripts/test_motor_transport.mjs +++ b/scripts/test_motor_transport.mjs @@ -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)); diff --git a/tabs.html b/tabs.html index d3b0d73..2bd87a2 100644 --- a/tabs.html +++ b/tabs.html @@ -14,7 +14,10 @@ ESP32 调试控制台 — DCU - + + +