Compare commits

..
8 Commits
Author SHA1 Message Date
cbb 4e018af168 feat(motor): 序列每步新增绿色「单发」按钮,支持仅发送该步动作一次 2026-09-07 22:18:50 +08:00
cbb 3b156547bc chore(gitignore): 为已入库的通用 ECAT 采样探针加白名单例外 2026-09-07 21:56:34 +08:00
cbb 03caa4acd4 chore: 采样/自测产物与一次性探针脚本移出版本库
- 删除已入库的 ecat_after*.csv 与 scripts/ecat_torn_hits.jsonl
- .gitignore 增加 *.csv / *.jsonl / scripts/_*_out.txt 与
  scripts/probe_*.py / selftest_*.py 规则
- 保留 README 引用的回归自测 test_*.py|mjs 与 browser_check_*.mjs
2026-09-07 21:55:42 +08:00
cbb ef9472334a docs: 新增 .qoder Repowiki 知识库文档 2026-09-07 12:19:26 +08:00
cbb 43d42ad891 docs: 新增 CHANGELOG, 记录 2026-09-05 三项变更
app 窗口租约(关窗自动停从站)、首页 GD32 CPU 负载率指标、PDO 周期
缓存迁移与测试对齐; 面向使用者的视角, 实现细节指向 README 与提交。
2026-09-05 19:58:50 +08:00
cbb 12879aa81a fix(motor): 清掉存量 4ms PDO 周期缓存, 测试对齐 pcore_mask
- transport-ecat: localStorage 的 dcu_ecat_period_ms 是 Python 时代默认
  4ms 存进去的(browser_check_period.mjs 收尾也写 "4"), native 后端默认
  已改小, 存量 "4" 会在每次 ensureStarted 把总线压回旧默认。加一次性
  迁移标记 dcu_ecat_period_mig, 只删一次; 之后显式设 4 视为用户选择。
  "恢复默认"从 setPdoPeriod(4) 改为删键跟随后端默认(固件修好前别低于
  1ms)。
- test_motor_transport: 自旋钉核显示早已改用快照字段 pcore_mask(P 核),
  测试还停在 ecore_mask 旧字段旧措辞, 本次对齐(含跨 32 位掩码、不连续
  核号区间用例)。

验证: node scripts/test_motor_transport.mjs 66/66 通过。
2026-09-05 19:58:45 +08:00
cbb 9cbde690c6 feat(ota): 首页运行指标卡新增 GD32 CPU 负载率(EtherCAT 通道)
DCU_APP 固件 >=1.5.61 把"中断+非延迟处理段"占用率(1.5.63 收窄定义)
写进 EtherCAT 输入 PDO 段 5 诊断区(1s 滚动窗口, 0.1% 分辨率), native
经 /api/app/load 提供纯缓存读; 前端 js/ota/ecat_load.js 经同源代理
/ecat/api/app/* 轮询并渲染到 #mGd32Load。

- 与 /system/status 轮询完全独立: ESP32 离线互不影响, 失败不计入
  ESP32 连接失败计数
- 失败静默退避 1s→5s→30s, 不用 EtherCAT 的用户不按 1Hz 触发 esp32_server
  的自动拉起逻辑
- 无效显示 --: valid:false(总线未开/循环未跑/固件过旧)或 age_ms>3s
  (PDO 循环已停, 值不再滚动); >=85% 琥珀色提示余量吃紧
- 页面上无 #mGd32Load 时自动空转(其他页共用 app.js 不受影响)
2026-09-05 19:58:36 +08:00
cbb 99fb9bcb4a feat(server): app 窗口租约, 全部窗口关闭自动停 EtherCAT 循环
PDO 循环起来后 native 侧一直跑, 其 --parent-pid 自清理只覆盖"本服务拉起
的实例死掉"; 浏览器模式关窗后本服务还在、窗口模式复用现成 ecat_native
(parent 是别人)这两种常见离开方式都够不着 —— 从站卡在 OP、自旋节拍白占
一个核。现由每个 app 页面与页面服务器的 WebSocket 租约兜底:

- esp32_server.py: 新增 GET /ws/app-lease(手写 RFC6455 握手 + 帧泵)。
  连接计数归零后隔 3s GRACE 调后端 /api/op/stop 停循环退回 INIT; 断开
  感知靠 TCP FIN / Close 帧回显而非心跳(后台标签页节流不误停); 代理
  透传时 _note_ecat_loop 记账"循环是否本服务跑起来的", 只停自己确认过
  的; /api/shutdown 与 serve_forever 退出路径同步补停(定时器随进程死亡)。
- js/common/app-lease.js: 租约客户端, 3~30s 退避重连, file:// 静默跳过。
- 接入 tabs.html(外壳持有, 内部切标签不断连)与 pages/motor.html(单独
  打开时页面自持)。
- 测试: scripts/test_app_lease.py(裸 TCP 全路径, 假 _ecat_post) +
  scripts/test_app_lease_e2e.mjs(真 Edge headless + 假后端 8091, 不碰
  真总线)。README 补设计动机与要点。
2026-09-05 19:58:25 +08:00
111 changed files with 3296 additions and 237 deletions
+12
View File
@@ -15,6 +15,18 @@ env/
*.map *.map
*.log *.log
# 采样 / 自测产物(现场跑出来的原始数据,动辄几十 MB,勿入库)
*.csv
*.jsonl
scripts/_*_out.txt
# 一次性排障探针(跟着某次现场问题写的,问题闭环后即失效)
scripts/probe_*.py
scripts/selftest_*.py
# 例外:这两个已入库,是可复用的通用采样器
!scripts/probe_ecat_slot_atomicity.py
!scripts/probe_ecat_vs_wifi.py
# 编辑器 / 系统 # 编辑器 / 系统
.vscode/ .vscode/
.idea/ .idea/
@@ -0,0 +1,28 @@
schema_version: 1
module_path: scripts_and_tests
title: ECAT 调试、固件刷写与端到端测试脚本集
scope:
- scripts/
source_files: []
depends_on: []
related_to:
- path: python_server
reason: 脚本 test_app_lease_e2e.mjs 等连接本地页面服务器的 /ws/app-lease 端点做端到端断言(端口探测与 POST 触发)。
kk_module_id: adba262f-05b7-4a9b-ba4f-3edc007d80fb
kk_ordinal: 3
kk_cards:
architecture_design:
id: 51246f25-7864-40cd-812b-3af7b95b1e02
title: ECAT 调试、固件刷写与端到端测试脚本集
coding_conventions:
id: 86785324-6a18-4cc3-8952-47c808437f14
title: ECAT 调试、固件刷写与端到端测试脚本集
overview:
id: 13d0dd8e-2f0f-4d11-89de-5f07971aed07
title: ECAT 调试、固件刷写与端到端测试脚本集
tech_stack:
id: 70c89e3a-602b-425b-8570-86d077473604
title: ECAT 调试、固件刷写与端到端测试脚本集
unique_setup_and_commands:
id: f9104fb6-b2a4-4696-9135-8ed0b2ead005
title: ECAT 调试、固件刷写与端到端测试脚本集
@@ -0,0 +1 @@
纯 Python + Node.js + PowerShell + Batch;通过 urllib/socket/raw HTTP 轮询 ESP32 服务的 `/ota/*`、`/api/motor/state`、`/motor/state?bin=1` 以及 WebSocket 接口;ECAT 撕裂检测采用“同 seq 多份 payload”不变量 + 字段跳变 p99 尺度的双判据。
@@ -0,0 +1,5 @@
本模块为独立的辅助脚本集,按功能分为四层:
- 固件与设备操作层:`flash_gd32.py` 通过 HTTP 把 Intel HEX 展平 BIN 并经由 ESP32 OTA 中继写入 GD32,轮询 `/system/status` 确认 `GD32_COMPLETE` 后写台账 `_last_gd32_sha.txt`;`build_mem.bat` / `cpu_sample.ps1` / `wv2_roles.ps1` 分别负责 IDF 构建与 Windows 进程采样。
- ECAT 数据采集与诊断层:`rec_ecat_slots.py` 以固定频率轮询服务 `/api/motor/state`,去重落盘到 CSV(M/F/H 三类行),作为后续分析的唯一输入;`probe_ecat_slot_atomicity.py` 在此基础上做同一 seq 重复读的撕裂判定;`probe_ecat_vs_wifi.py` 将 ECAT 帧与 WiFi 二进制 `/motor/state?bin=1` 对照,逐字节验证一致性;`anal_ecat_jumps.py` 基于 p99 尺度+孤立尖峰形态检测撕裂,并提供 `--selftest` 注入混合帧的灵敏度自检。
- 服务端行为验证层:`test_app_lease.py` 通过 `sys.path.insert` 直接 import `esp32_server`,用假 `_ecat_post/_ecat_find_port` 启动真实 HTTP 服务,模拟 WS 租约建立/Grace 重连/进程退出等场景断言 EtherCAT 循环启停;Python 版的浏览器 E2E 用例由对应 `.mjs` 借助 CDP (`ws://127.0.0.1:9333`) 操控 WebView 页面执行断言。
- 依赖方向单向向外:所有脚本只读本地文件与服务 HTTP API,不反向修改业务代码;采集与分析脚本之间通过固定 CSV 格式(`ecat_slots_raw.csv`)解耦。
@@ -0,0 +1 @@
面向 DCU/ESP32 平台的运行期工具与测试集合:含 GD32 固件刷写、ECAT 撕裂检测与分析、内存批化仿真、浏览器 E2E 用例及 Windows 性能采样。
@@ -0,0 +1,5 @@
- 刷写 GD32:`python scripts/flash_gd32.py [期望版本]`,需先 `idf.py build` 产出 `DCU_APP/out/GD32E508_DCU_APP/GD32E508VE/Release/GD32E508_DCU_APP.hex`,并通过 `DCU_ESP_IP` 环境变量指定目标 IP
- ECAT 撕裂检测:先跑 `python scripts/rec_ecat_slots.py [秒数] [端口] [输出csv]` 生成原始流,再 `python scripts/probe_ecat_slot_atomicity.py [端口] [秒数]` 或 `python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8] [--selftest]`
- ECAT vs WiFi 对比:`python scripts/probe_ecat_vs_wifi.py [秒数]`,要求本机同时提供 ECAT 状态 JSON 与 ESP32 WiFi 二进制接口
- 浏览器 E2E:先起 `node --inspect-brk` + Chrome DevTools,再以 CDP 端口 9333 连接后执行各 `browser_check_*.mjs`;`test_app_lease.py` 可直接 `python scripts/test_app_lease.py`
- Windows 性能:`scripts/cpu_sample.ps1` 统计 `msedgewebview2|ecat_native|pythonw|python` 进程 CPU/WorkingSet
@@ -0,0 +1,6 @@
- 脚本入口统一为 `if __name__ == "__main__": main()` 模式,命令行参数通过 `sys.argv` 顺序解析(支持可选的端口、时长等)。
- HTTP 客户端封装为顶层函数(如 `post/get` 或 `once()`),统一处理超时、`HTTPError` 与 UTF-8 解码替换,避免重复样板码。
- ECAT 数据采集类脚本使用 TCP socket 直接拼接 HTTP 请求头并发出 POST,收到空字节流后再 `partition(b"\r\n\r\n")` 解析 JSON 体。
- 长耗时运行通过 `while time.time() - t0 < DUR_S` 形式的时间窗口控制退出,错误路径一律 `except Exception: continue` 容错而非中断。
- 大量去重/缓存场景采用 dict/set 并配合定期截断(如 `len(seen_m) > 400000` 时取尾部 200000 项),控制内存增长上限。
- 断言式测试使用 `assert ... raise AssertionError(...)` 配合 PASS/INFO 日志串联用例,失败即非零退出码,便于 CI 集成。
@@ -0,0 +1,26 @@
schema_version: 1
module_path: js_frontend/ble
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
scope:
- js/ble/
source_files: []
depends_on: []
related_to: []
kk_module_id: e695976e-f72c-4d3a-867b-fb80827337d8
kk_ordinal: 2
kk_cards:
architecture_design:
id: e5ac84aa-1a14-4238-840f-63cc430d2c4c
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
coding_conventions:
id: a5fa8c1f-0636-432f-a5f4-9b973378b212
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
overview:
id: 4a0792d4-88cf-45fa-86ab-ddce531e07e2
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
tech_stack:
id: b1c611b2-296d-4545-adf6-acf77eeaa116
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
unique_setup_and_commands:
id: 5d391cf3-8e98-4ea3-90bb-b48da99b4259
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
@@ -0,0 +1 @@
Web Bluetooth API(navigator.bluetooth.requestDevice / GATT Server + Characteristic + notify),配合 File System Access API(showOpenFilePicker)支持热重载;通过 0xA1 ACK + 64 窗口 ACK_WINDOW 实现流控。
@@ -0,0 +1 @@
模块按职责划分为八个 ES 模块文件,依赖方向单向:`constants.js` 集中定义 GATT UUID、OTA 协议码(ACK/FINISH/SHA256/CTRL)及 `TARGETS.{esp32,gd32}` 配置,作为所有其他文件的常量来源;`state.js` 维护全局状态(device/server/characteristic 引用、上传标志、ackWaiter、会话标识等)并提供 DOM 元素缓存与通用工具(bleLog/toHex/sleep 等);`gatt.js` 实现 BLE 生命周期(connectBle/disconnectBle/readVersion/onDisconnected)以及 GATT notify 分发 `handleNotify`,将协议事件路由至 transfer/session/verify;`transfer.js` 实现数据面:buildPacket 打包 seq+totalSize+payload+CRC、ACK 等待 Promise 封装 waitForAck、进度刷新 updateProgressFromAck;`session.js` 管理多轮 OTA 结果等待(waitForOtaResult/nextOtaSessionId/finishBelongsToWaiter)与 abort 流程;`stages.js` 提供 1~6 阶段状态栏的 UI 操作(setBleStage/setBleStageError/updateBleStageLabels),被 gatt/transfer/file 共同消费驱动动画;`file.js` 统一文件入口,处理 HEX→BIN 转换、GD32 包身份检测、SHA-256 缓存,并在 upload 中屏蔽重复选择;`verify.js` 仅暴露 show/hide 校验信息面板的方法。跨通道(BLE/WiFi)通过 `connectionMode` 变量与共享 DOM id(progressFill/logScroll 等)协作,确保界面一致性。
@@ -0,0 +1 @@
基于 Web Bluetooth API 实现 ESP32/GD32 双目标设备的 BLE 固件在线升级,负责 GATT 连接、分包传输、ACK 窗口与 SHA-256 校验。
@@ -0,0 +1 @@
BLE 连接必须在用户手势内触发(click handler 中使用 `e.preventDefault()` 保证 navigator.bluetooth.requestDevice 合法);需浏览器启用 `web-bluetooth` 权限并过滤 namePrefix 为 'ESP32-OTA'。
@@ -0,0 +1,6 @@
- 协议常量集中声明于 constants.js,并以命名空间式导出(如 BLE_OTA_NOTIFY_ACK、BLE_OTA_FINISH_ERR_*、TARGETS.*),所有业务模块通过 import 使用而非魔法数字硬编码。
- DOM 节点在 state.js 启动时一次性缓存到 els 对象,高频路径直接读取属性,避免重复 getElementById;stages.js 对共用状态栏也采用首读缓存 + isConnected 失效回退模式。
- 阶段 UI 通过 stages.js 的 setBleStage(stageNum, detail, barState) 统一推进,错误路径使用 setBleStageError(detail) 将当前 active 节点标记 err,禁止各模块自行修改 className。
- 异步长耗时操作采用“waiter”模式:用 state.ackWaiter / state.otaResultWaiter 存储 resolve/reject + timer,超时或中止时主动清除并 reject,防止悬挂 Promise。
- 日志输出统一经 state.bleLog(tag, message, level) 封装后注入 common/log.js 的 addLog,标签前缀固定为 'BLE-*',便于侧边栏筛选。
- 跨通道状态判断使用 isBleUploading()(state.uploading || state.autoFlashing)与 window.isUploading 组合判定,避免单个通道上传时另一通道 UI 被意外重置。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: js_frontend/can
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
scope:
- js/can/
- js/can_monitor.js
source_files: []
depends_on: []
related_to: []
kk_module_id: b5c93572-2980-4d8b-abcb-49299366edc6
kk_ordinal: 4
kk_cards:
architecture_design:
id: 8c1ea8cb-f95b-48ee-856a-50f8336e3eea
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
coding_conventions:
id: cee80565-5287-44a1-881b-e9087ff9c0ed
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
overview:
id: ee9c08d3-0aed-4e95-8a7f-f446a2debab4
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
tech_stack:
id: 7d94fd27-c122-4f4b-a175-b9c7ac60a3ad
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
unique_setup_and_commands:
id: 7b2bd04a-c5d6-4bce-954b-193adc49892a
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
@@ -0,0 +1 @@
纯浏览器端 JavaScript(ES Module),使用 TextDecoder('utf-8' | 'gb18030') 解析 DBC 文件、localStorage 会话存储、AbortController+setTimeout 双超时控制 fetch、Clipboard API 降级 execCommand 回退。
@@ -0,0 +1,8 @@
模块按职责拆分为五个 ES Module,依赖方向单向收敛于 `dbc.js`:
- `dbc.js` 为基础设施层,导出共享 state/el、IP 工具、字节格式化工具,以及 DBC 文本读取(UTF-8 / GB18030/BOM 自动回退)和 DBC 解析器(BO_/SG_/CM_/VAL_ 行 → { messages, nodeNames, messageMap }),不依赖其他 can 子模块。
- `messages.js` 负责报文列表渲染、关键字与发送节点筛选、节点过滤面板 UI 及批量选择。
- `signals.js` 基于 DBC 信号定义生成内联信号表格,维护 signalValues bag,处理 Motorola/Intel 位序、有符号/无符号、枚举、因子/偏移编解码,并维护当前选中报文的编码预览。
- `send.js` 作为 HTTP 出口,统一通过 `fetch(getBaseUrl() + '/can/send_batch')` 发原始帧或构造后的批量帧,封装错误码映射(CAN_SEND_ERR_DESC / GD32_ERR_DESC),维护发送历史与剪贴板复制。
- `persist.js` 将多 DBC 原文、signalValues、checkedKeys/expandedKeys、transmitterFilter、history、表单状态序列化到 localStorage(键 `dcu_can_session_v1`),并以防抖 `scheduleCanPersist()` + 失败时逐步砍历史/信号值的方式保证写入安全;恢复时逆向重建 DBC。
- `can_monitor.js` 是独立的 1Hz 轮询入口,调用 `/can/feedback` 获取 3 路 CAN 通道 RX/TX/Drop/Err/TEC/REC/bus_state/entries,渲染三卡片及 ID snap 表,可被 `can.html` 右侧抽屉复用。
模块间通过 ES Module live binding 共享 `state` 与 `el`,避免重复缓存 DOM 与状态副本。
@@ -0,0 +1 @@
实现浏览器端 CAN 调试工具的 DBC 文件解析、报文过滤/信号编辑、批量帧发送与会话持久化,并提供独立页的 CAN 通道反馈轮询监控。
@@ -0,0 +1 @@
无需构建步骤;直接由网页加载 ES Module。`readDbcText` 支持 UTF-8 优先、GB18030 回退并去除 BOM,用于本地拖入的 .dbc 文件;`parseDbc` 自行解析 BO_/SG_/CM_/VAL_ 标准 DBC 片段。
@@ -0,0 +1,6 @@
- 跨子模块共享可变状态与 DOM 引用集中放在 `dbc.js` 导出的 `state`/`el` 对象上,各子模块通过 import 获取同一引用,利用 ES Module live binding 保证同步。
- 对外部不可信文本一律使用 `escapeHtml`/`sanitizeHtml` 后再插入 innerHTML,所有数据属性赋值前先经 `escapeHtml`,禁止拼接 HTML 字符串直接使用用户输入。
- 网络请求统一使用 `new AbortController()` + `setTimeout` 双保险取消,防止在连接 reset 等边缘场景下 await 永久挂起。
- 高频本地持久化采用「收集事件 → scheduleCanPersist → 防抖定时器 → saveCanSessionNow」模式,并在 writePayload 失败时逐级减少 payload(压缩历史→清 signalValues)以容忍 localStorage 配额不足。
- DLC/字节数组输出统一走 `padHex(value, width)` 与 `bytesToHex(bytes)`,确保十六进制展示宽度一致、全大写。
- 错误信息分层展示:完整描述走 buildSendResponseText,简短文案走 buildSendResponseShort 去掉冗余前缀,使历史记录空间有限时仍保留核心原因。
@@ -0,0 +1,23 @@
schema_version: 1
module_path: js_frontend
title: ESP32 DCU 前端 Web 控制台集合
scope:
- js/
source_files: []
depends_on: []
related_to: []
kk_module_id: 34c369aa-9944-449c-bc32-3fb459da6f08
kk_ordinal: 2
kk_cards:
architecture_design:
id: 0226951f-1a73-43f5-adb5-ded1b74dbe63
title: ESP32 DCU 前端 Web 控制台集合
coding_conventions:
id: 66764ffe-f832-4992-a6b4-064aab166812
title: ESP32 DCU 前端 Web 控制台集合
overview:
id: 8298ce61-963b-4a66-b17b-8293a4472e54
title: ESP32 DCU 前端 Web 控制台集合
tech_stack:
id: 22e24b2a-6f8d-4d54-b181-e2ec9ff24243
title: ESP32 DCU 前端 Web 控制台集合
@@ -0,0 +1,28 @@
schema_version: 1
module_path: js_frontend/ota
title: 前端 OTA 刷写会话与进度管理
scope:
- js/ota/
source_files: []
depends_on: []
related_to:
- path: js_frontend/ble
reason: 两者均为浏览器端固件升级方案:js/ota 走 WiFi ESP32 HTTP(分块上传+SHA-256),js/ble 走 Web Bluetooth GATT(分包 ACK+窗口+SHA-256),同一业务目标的不同实现。
kk_module_id: 697d6afd-9c08-42a6-900d-2b51dd09c507
kk_ordinal: 1
kk_cards:
architecture_design:
id: 7e17f40a-f0ea-4d4d-bba0-99559868e617
title: 前端 OTA 刷写会话与进度管理
coding_conventions:
id: 79bc6166-c07c-4910-b704-f898340b0b69
title: 前端 OTA 刷写会话与进度管理
overview:
id: 95c1a351-1033-42a9-a77b-086cd3d5186c
title: 前端 OTA 刷写会话与进度管理
tech_stack:
id: b16589eb-b0da-4e30-960e-4a0d3b051ed3
title: 前端 OTA 刷写会话与进度管理
unique_setup_and_commands:
id: 93240d0d-1e8c-4a7d-9f5f-9e26a332988e
title: 前端 OTA 刷写会话与进度管理
@@ -0,0 +1 @@
纯浏览器端 JavaScript (ES Module),使用 IndexedDB 持久化 FileSystemFileHandle 实现刷新后热重载恢复;XHR+fetch 混合进行文件分块上传;通过 ES import 引入 ../common/*(log、toast、utils、connection)。
@@ -0,0 +1,5 @@
模块以**集中式会话状态机**(session.js)为核心,定义 IDLE→UPLOADING/VERIFYING/RELAYING/REBOOTING→DONE/FAILED 的状态流转,所有 UI、上传、轮询通过 window.* 全局总线共享 otaSessionState / otaSessionTarget / activeUploadChip 等标志,避免分散布尔位导致的“网络错误后轮询覆盖”问题。
- 入口编排:console-mode.js 接管 switchStageFlow,提供 WiFi/BLE/ESP32/GD32 多通道目标切换与共享固件文件同步;upload-esp32.js 作为统一入口 startUpload(chip) 处理互斥探测、热重载重读、XHR 分块上传、SHA-256 校验与设备重启。
- 后端协议:upload-gd32.js 仅负责 POST /ota/relay 触发 GD32 中继,并通过 emitRelayProgress/markGd32OtaUiComplete/startRelayProgressWatch 与 stages.js 中的 status 轮询路径协同;ECT 负载显示由独立的 ecat_load.js 基于 /ecat/api/app/load 退避轮询。
- UI 层:progress.js 维护折叠面板、阶段节点 es1..es6/s1..s6 与进度条;stages.js 把 relay_state/sys_state_code 映射到 GD32 阶段节点并调用 progress 更新仪表盘;file-handle.js 封装 HEX→BIN、IndexedDB FSAPI handle 持久化、localStorage 偏好(flashTarget、文件元信息)。
- 依赖方向单向:console-mode → upload-* → session + progress + file-handle,stages/ecat_load 消费 session/progress,无任何反向引用。
@@ -0,0 +1 @@
为 ESP32/GD32/DPU 提供浏览器侧的固件选择、分块上传、校验、中继触发与会话状态机,并渲染阶段节点与进度条。
@@ -0,0 +1 @@
无构建脚本——模块由 HTML 页面直接按模块加载;EtherCAT 负载显示需主站 esp32_server 打开 ECAT native 循环并提供 /api/app/load,本模块仅做 100ms 退避轮询(失败→5s→30s),不强制拉起后端。
@@ -0,0 +1,6 @@
- 跨模块共享可变状态统一挂载在 window.* 上(如 isUploading、activeXhr、otaSessionState、activeUploadChip),各模块通过导入函数访问,而非显式传参。
- 所有会话生命周期变更必须经由 session.js 的 otaSessionTransition() 驱动,禁止直接修改窗口标志,保证是Uploading/Verify/Reboot/Done/Fail 等终态时自动清理按钮、阶段与定时器。
- 失败路径统一调用 progress.cleanupAfterOtaFailure(reason) 进入 FAILED 态并清除残留 DOM/定时器/进程句柄,使后续 status 轮询不会覆盖失败 UI。
- DOM 写入采用「先比较后写入」模式(setNodeText/setNodeClass/setTitle)或缓存 lastDisplayedRelayState,避免每秒轮询时无条件赋值导致样式失效重排。
- 长耗时用户操作(选文件、热重载、请求)均包裹 try/catch 并以 addLog('W'/'E','OTA'/'文件',...) 记录,同时用 showToast 给出用户可见反馈,区分日志与提示。
- 轮询器以幂等的 start*/stop* 成对暴露(stopRelayProgressWatch、startUploadWaitHeartbeat、startEcatLoadPoller),内部持 timerId 并在页面无对应元素或已运行时直接 return,避免重复调度。
@@ -0,0 +1,22 @@
schema_version: 1
module_path: js_frontend/common
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
scope:
- js/common/
source_files: []
depends_on: []
related_to: []
kk_module_id: 83fe5acc-8cc9-4cca-8aa5-93ffb96585bf
kk_cards:
architecture_design:
id: 985b41f4-d6f1-4ff0-b9c0-e6ccc216fdf4
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
coding_conventions:
id: 3d9852ce-9b31-4829-af73-d79d122dc321
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
overview:
id: f6c3b043-050f-4761-8bb6-2fac2945de55
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
tech_stack:
id: 5488e585-4aad-4812-ae92-aafb6c8ac269
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
@@ -0,0 +1 @@
ES Module (`export/import`);HTTP 轮询使用原生 `fetch` + `AbortController` 实现超时取消;WebSocket 长连接由 `app-lease.js` 自持;设备事件持久化走 IndexedDB (`IDBFactory.open('esp_dream_heap_low')`,版本 1);DOM 高频更新采用 `requestAnimationFrame` 合并写入与字符串对比去变更。
@@ -0,0 +1,9 @@
模块按职责拆分为独立 ES 模块,各文件通过 `import` 组合,不相互强耦合:
- `connection.js`:封装 DOM 渲染缓存(`elCache`、`set*IfChanged` 写前比较)和 `/system/status` UI 更新(连接胶囊、健康态、启动自检面板),是上层业务页渲染的核心。
- `polling.js`:基于递归 `setTimeout` 的轮询调度器,维护 `/system/status` 与 `/logs` 双通道 in-flight 锁、5s `AbortController` 超时、上传期暂停、Page Visibility 感知,并协调 OTA 状态机;导出 `startPolling / scheduleStatusPoll / pausePollingForUpload` 等生命周期 API。
- `log.js`:带去重 + 自动滚动 + 环形缓冲(`MAX_LOGS=1000`)+ rAF 批量滚动的控制台。
- `toast.js`:支持 success/error/warning 三类弹窗,含 5s 去重防抖 + 3s 自动隐藏。
- `heap-low-history.js`:基于 IndexedDB (`esp_dream_heap_low`) 持久化“最低空闲内存”创新低记录,含去重 key `(ts_ms|min_free_heap|free_heap|alloc_count)`、上限裁剪与离线可用保证。
- `app-lease.js`:自执行 IIFE 建立到 `/ws/app-lease` 的 WebSocket 长连接并 3~30s 递增退避重连,作为服务端停 EtherCAT 循环的窗口凭证。
- `base-url.js` / `utils.js`:纯函数辅助层(IP/URL 拼串、字节/时间/运行时间格式化、HTML 转义、hex 转换),被其他子模块复用,无浏览器副作用。
依赖方向:业务页 → connection/polling/log/toast → base-url/utils;只有 polling→connection 反向引用用于失败诊断,避免连接态被业务直接操作。
@@ -0,0 +1 @@
为 ESP32 DCU Web 管理端提供跨页面共享的连接状态、系统轮询、日志缓冲、提示框、IndexedDB 历史与基础 URL/格式化等通用能力。
@@ -0,0 +1,6 @@
- 对频繁读取的 DOM 节点在模块级以 `var elCache[id] = document.getElementById(id)` 方式缓存,并通过 `getEl()` 统一访问,避免每帧重复 `getElementById`。
- 文本/类名/宽度等 DOM 属性写入前先比较旧值(`textContent !== v`、`className !== cls`、元素上 `_lastW` 缓存宽度),仅在变化时才触碰样式,避免强制布局重排。
- 轮询器统一用递归 `setTimeout` 而非 `setInterval` 调度,并在每次回调末尾按 `max(0, interval - elapsed)` 重新挂载下一次任务,保证周期稳定且不会因慢响应重叠。
- 所有通过 `innerHTML` 渲染的用户可控文本均先经 `escapeHtml` 转义,防止注入。
- 需要幂等的 UI 绑定(如健康启动面板切换按钮)通过 HTML 自定义属性(如 `dataset.bootToggleBound === '1'`)标记是否已处理,避免重复 `addEventListener`。
- 可选 API(如 Toast、IndexedDB、autoScroll 按钮)在执行前做存在性判断,不存在时静吞错误,保证核心轮询逻辑在无 DOM/无痕模式环境下仍可工作。
@@ -0,0 +1,30 @@
schema_version: 1
module_path: js_frontend/app_entries
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
scope:
- js/app.js
- js/ble_ota.js
- js/can.js
source_files: []
depends_on: []
related_to:
- path: python_server
reason: OTA、BLE、CAN 控制台页面均由 python_server 的静态文件服务提供,并通过 base-url.js 中 localStorage 存储的 dcu_esp_ip 以同源请求访问设备 API。
kk_module_id: d15c38ec-fd6c-4494-8521-a48360031a6b
kk_ordinal: 5
kk_cards:
architecture_design:
id: 441b093f-3154-4236-87c1-714b50c314c2
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
coding_conventions:
id: b6fef608-2072-43c3-8b54-488a03451825
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
overview:
id: 41949d5a-6e02-4b39-a0d9-23dcc19a84ee
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
tech_stack:
id: 3d5e73da-051c-455f-a5d9-9d897c816023
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
unique_setup_and_commands:
id: b4aa8897-362a-45f5-9b97-fe13dbf1072a
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
@@ -0,0 +1 @@
ES Module (type="module") + Fetch API + Web Bluetooth (GATT) + DBC/CAN 报文解析
@@ -0,0 +1,5 @@
本模块由三个互相独立的入口脚本组成,均为薄编排层:
- `js/app.js`:ESP32 WiFi OTA 控制台入口,import `ota/*`、`common/*` 等子模块后把所有交互函数显式挂载到 `window.*`,以兼容 HTML 内联 `onclick`;同时承担顶层副作用(拖拽区绑定、SPI 输入监听、状态轮询)。通过共享状态对象 `otaSessionState`、`OtaSessionState` 在 ota 子模块间传递会话信息。
- `js/ble_ota.js`:BLE OTA 控制台入口,导出单一 `window.bleOta` API 对象(含 `init/state/disconnect/isUploading` 等),调用 `ble/session.bindEvents()` 和 `state` 完成初始化。
- `js/can.js`:CAN 诊断页面入口,导入 `can/dbc.js`、`can/messages.js`、`can/signals.js`、`can/send.js`、`can/persist.js` 以及 `common/utils`、`common/toast`,通过共享的 `el`/`state` 单例在各子模块间保持最新 DOM 引用与选中状态;使用带版本戳的 `?v=` 查询参数字面量强制缓存失效(如 `send.js?v=20260801f`),同时避免在 import 路径上加版本戳导致浏览器重复实例化单例。
依赖方向:三个入口只向上暴露到 `window`,被上层 HTML 或测试脚本消费;向下仅依赖各自子目录模块与 `common/*`,无横向耦合。
@@ -0,0 +1 @@
汇总 ESP32 WiFi OTA、BLE OTA 与 CAN 诊断三个控制台页面的入口脚本,通过 ES Module import 组装子模块并将函数挂载到 window,提供统一的事件绑定与 UI 初始化。
@@ -0,0 +1 @@
模块内 import 路径禁止加 `?v=` 版本号,版本戳仅出现在 HTML `<script src>` 标签上,以避免同一模块因不同 querystring 产生双份实例(破坏 state/toast 单例);CAN 页面中向特定文件追加硬编码 `?v=...` 以强制重新加载已发布过的模块。
@@ -0,0 +1,4 @@
- 入口脚本采用「薄编排 + window 挂载」模式:import 子模块函数后集中赋值给 `window.xxx`,供 HTML 内联事件处理器或外部脚本调用。
- 各入口先构造本地工具函数(如 `getBaseUrl`、`setConnectionState`、`resetSelectionState`),再执行 DOM cache 与事件绑定,最后调用统一的 `init` 入口完成启动。
- 子模块间的共享状态与 DOM 引用通过 ESM 导出的单例对象(`state`、`el`)传递,而非全局散落的闭包。
- 对 `common/utils`、`common/toast` 等通用能力按需要局部重名导入并立即挂载到 `window`,形成稳定的跨模块 API 边界。
@@ -0,0 +1 @@
ES Module + Web Browser API(Web Bluetooth/GATT、Web Worker、IndexedDB),运行于 ESP32 内置 Web Server 托管的静态资源环境,通过 HTTP 长轮询与服务端协议通信。
@@ -0,0 +1 @@
顶层目录以功能划分为若干独立子系统:common 提供跨子模块复用的 connection/polling/log/toast/utils;app_entries(js/app.js、can_monitor.js、ble_ota.js)作为单页应用入口,通过 ES Module import 将各子系统(ota/ble/motor/can)组合到 window 命名空间并绑定 UI 事件;各子系统内部自包含业务逻辑,如 ota 定义固件升级会话阶段,ble 基于 GATT 通道驱动同一流程,motor 通过 transport-ecat/wifi 可插拔传输层抽象驱动状态机。各子系统之间不直接互相 import,而是由浏览器加载的脚本各自按需引入 common,形成“入口聚合 + 功能隔离”的多页式架构。
@@ -0,0 +1 @@
汇聚 OTA、BLE、电机控制与 CAN 诊断等多个浏览器端控制台页面,共享连接、轮询、日志等公共能力并通过 app 入口聚合装配。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: js_frontend/motor
title: 电机控制台多传输模块
scope:
- js/motor/
source_files: []
depends_on: []
related_to:
- path: js_frontend/can
reason: CAN 控制台与电机控制台均通过 POST /can/send_batch 发送帧到 ESP32 设备,但 CAN 用于通用报文编辑而电机用于特定 CMD/CONFIG 控制流程。
- path: python_server
reason: 电机控制台通过 /ecat/api/* 反向代理访问本地 ecat_native.exe 的 /api/motor/*(transport-ecat.js 中拼接路径发送)。
kk_module_id: 71087b31-4c6d-4a07-8d58-1f3e348444bf
kk_ordinal: 3
kk_cards:
architecture_design:
id: 00c57461-8294-47bf-88b9-8c362ac14bfc
title: 电机控制台多传输模块
coding_conventions:
id: 13ef69f8-0bc9-4b61-bd64-c0fa7a39ef2c
title: 电机控制台多传输模块
overview:
id: df61a7ec-5adc-4d4f-b0e9-a2867d026ab5
title: 电机控制台多传输模块
tech_stack:
id: 1dea780b-05c9-4fe6-ad60-ffef2d6854ec
title: 电机控制台多传输模块
@@ -0,0 +1 @@
原生 JavaScript ES Module,使用 Web Worker 作为定时节拍源;WiFi 链路通过 Fetch + AbortSignal.timeout 访问设备 HTTP 端点;EtherCAT 链路经本地页面服务器的同域反向代理(绑定 127.0.0.1,无 CORS)访问后端 `ecat_native.exe` 守护进程;二进制状态协议用 DataView 解析(魔数 0x4D53,v0.70/v0.74 双版本兼容)。
@@ -0,0 +1,9 @@
模块以 `main.js` 为 ES Module 入口(由 motor.html 外提),集中维护全局 `state`(连接态、刷新间隔、解码缓存、命令序列、版本缓存等)并通过工具栏在 `wifi` / `ecat` 两条链路之间切换。
内部按职责分层:
- 传输选择层 `transport.js` 导出 `createTransport(id, ctx)` 工厂,根据 id 创建具体实现,并以 `ctx` 注入 `getBaseUrl`、`recordSlow`、`isTransient` 避免循环依赖。
- 两路传输独立实现同一契约(见 `transport-ecat.js` 头注释列出的接口表):`transport-wifi.js` 调用 ESP32 的 HTTP API(`/system/status`、`/motor/state?bin=1`、`/can/send_batch` 等);`transport-ecat.js` 经本地页面服务器 `/ecat/api/*` 反向代理到 `tools/ecat` 的 C 守护进程,完成全自动接入(自动拉起服务、打开网卡、进入 OP、起 PDO 循环)并将 EtherCAT 过程数据形状补齐为 WiFi 契约(config_data、version seq、nodeIdReport)。
- `refresh-rate.js` 抽离为纯函数库,按链路各自管理默认值/最小值/钳制/旧键迁移,使刷新策略可脱离 DOM 自测。
- `tick.worker.js` 是一个 Dedicated Worker,仅负责按指定 interval 持续投递 `tick` 消息,规避浏览器后台标签页对 setTimeout/setInterval ≥1s 节流。
依赖方向单向收敛于 `main.js` → `transport.*`;上层解码器、节点列表、STATUS 面板、波形、命令序列和全部流控策略(互斥/退避/冷却/latest-wins)完全不感知底层链路差异。
@@ -0,0 +1 @@
提供电机控制台的业务逻辑入口,通过可插拔的 WiFi/HTTP 与 EtherCAT 传输层抽象,驱动状态轮询、帧发送、命令序列与波形等共享逻辑。
@@ -0,0 +1,5 @@
- 两种传输实现统一通过工厂 `createTransport` 产出,并按相同方法签名(probe/fetchStatus/fetchMotorState/sendFrames/fetchVersionNode 等)对外暴露,主流程只调用抽象接口。
- 传输层错误对象统一附加 `.transient` 标记与可选 `.status`,由 main.js 的失败计数与断线逻辑识别,区分瞬时错误与硬失败。
- 慢请求通过注入的 `recordSlow(url, ms, errMsg?)` 回调上报,所有 fetch/POST 路径都包裹计时与记录。
- 用户偏好状态写入 localStorage 时采用「按链路分组」的 Map 结构(如 `refreshByTp`、历史 `dcu_motor_transport`),新链路扩展只需改默认表而无需改动持久化格式。
- Worker 通信仅使用 `{type, ...}` 消息体(start/update/stop 对应 tick 启停),不传递复杂对象,保持轻量心跳协议。
@@ -0,0 +1,3 @@
- 每个子系统保持自包含目录结构(会话/状态/阶段/传输分离),仅通过 common 提供的工具函数与外部解耦
- UI 交互由入口脚本统一注册到 window,子模块不直接操作 DOM 全局对象
- 设备通信均通过 common 的 polling/connection 封装进行周期性数据推送,而非自行实现定时器
@@ -0,0 +1,27 @@
schema_version: 1
module_path: python_server
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
scope:
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
source_files: []
depends_on: []
related_to: []
kk_module_id: e6df9393-2f7e-4478-8bd9-79e0f4f83b91
kk_cards:
architecture_design:
id: f440bf26-cce8-4839-9f95-db46b9d180db
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
coding_conventions:
id: e9f360b3-eb76-441e-9f4d-595c77cf8874
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
overview:
id: 2871bc12-68a1-4218-ab17-febc24f79968
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
tech_stack:
id: 6c6357bf-8885-499f-a9ab-ff7df848bde6
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
unique_setup_and_commands:
id: e191608a-80b0-4a83-b47a-c14460f54b3a
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
@@ -0,0 +1 @@
Python `http.server` + `threading` 线程型 HTTP 服务器;pywebview (WebView2) 桌面容器;VBS (`CreateObject('WScript.Shell')`) 调用 `pythonw.exe`;Windows Job Object 管理 ecat_native.exe 生命周期;原生 C/SOEM 后端 `build_native/ecat_native.exe`。
@@ -0,0 +1,7 @@
由单个 Python 进程构成,核心是 `http.server.ThreadingHTTPServer` + `SimpleHTTPRequestHandler` 派生类:
- 路由层:根路径 302 到 `/tabs.html`,`/pages/*`、`/css/*`、`/js/*` 等走内置目录静态映射;`/ws/app-lease` 实现基于全局计数器的 app 窗口租约协议(全部断开自动停止 EtherCAT PDO 循环并从站退回 INIT);`POST /api/shutdown` 终止本服务。
- EtherCAT 反向代理:`/ecat/api/<路径>` 通过 TCP 转发到 `tools/ecat/build_native/ecat_native.exe` 的 8090–8095 端口,首次命中时按进程 ID 复用已有实例,否则以 `--parent-pid` 拉起子进程并由 Job Object 连带回收。
- 单实例互斥:通过命名互斥量 + `/api/focus` HTTP 唤醒旧实例,避免双击重复弹出。
- 前端注入:响应 HTML 时在 `</body>` 前注入 `_RELOAD_SNIPPET`,为 pywebview 提供刷新按钮、F5/Ctrl+R 键盘拦截和返回主页行为。
- 启动外壳:`esp32_app.vbs` 使用 `pythonw.exe --window` 启动 pywebview 桌面窗口(无控制台),`esp32_web.vbs` 用相同入口但以隐藏方式启动并交由系统默认浏览器打开。
依赖方向单向向下:VBScript → Python HTTP 服务 → 可选拉起的 ecat_native.exe;所有业务 API 实际由 ESP32 设备 (80 端口) 提供,本服务不直接读写硬件。
@@ -0,0 +1 @@
提供 ESP32 调试 Web UI 的静态文件服务、EtherCAT 后端反向代理与 app 窗口租约管理,并通过 VBScript 启动 pywebview 桌面窗口或浏览器标签。
@@ -0,0 +1 @@
两个 VBS 入口:双击 `esp32_app.vbs` 启动带地址栏的 pywebview 桌面窗口(`--window`),双击 `esp32_web.vbs` 以隐藏窗口模式在默认浏览器中打开;Windows 环境变量 `PYTHONUTF8=1` 由 VBS 预先设置。EtherCAT 模式会按需自动检测并拉起 `tools/ecat/build_native/ecat_native.exe`(端口 8090–8095 探测)。
@@ -0,0 +1,5 @@
- 所有对外暴露能力集中在单一 Python 主进程内:路由处理、WebSocket 租约状态、子进程管理均以模块级全局变量(如 `g_mutex`、`g_httpd`、`g_ecat_child`、`g_win`)共享,不使用类实例或配置对象。
- 子进程生命周期统一通过 Windows Job Object + `--parent-pid` 参数管理,确保父进程退出后子进程被内核连带回收。
- 对外部资源访问(旧实例焦点、ECAT 端口探测、子进程启动)一律包裹 `try/except` 并用模块级锁串行化,避免因并发导致多实例或多线程竞争。
- 响应内容增强采用注入模式:将 JS/CSS 片段作为常量字符串定义并在发送 HTML 前通过 `<meta>` 或 `</body>` 标记插入,而非生成独立中间页。
- 跨组件通信约定保持一致:VBScript 通过 `pythonw.exe <脚本> [--window]` 命令行开关切换模式,HTTP 端点统一使用 JSON body + `application/json` content-type。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: frontend_pages
title: ESP32/DCU 调试控制台前端页面集
scope:
- pages/
- css/
- tabs.html
source_files: []
depends_on: []
related_to:
- path: python_server
reason: esp32_server.py 作为静态文件服务器提供 tabs.html、pages/ 与 lab/ 目录下的 HTML,页面由本服务同源 serving。
kk_module_id: d0ed905a-8c06-4326-a9e3-22a026d276f7
kk_ordinal: 1
kk_cards:
architecture_design:
id: 921fdcb0-3189-46ef-963e-539b2f5e2b3b
title: ESP32/DCU 调试控制台前端页面集
coding_conventions:
id: 4a7606c7-8beb-455d-95f1-4605e6668630
title: ESP32/DCU 调试控制台前端页面集
overview:
id: 6eafe8be-cffd-4e08-9002-1332165ffedf
title: ESP32/DCU 调试控制台前端页面集
tech_stack:
id: eb3279e1-7af2-4ade-ba37-5bb6f4118b15
title: ESP32/DCU 调试控制台前端页面集
@@ -0,0 +1 @@
纯静态 HTML + CSS + 原生 JS (无构建工具、无框架);CAN DBC 解析、BLE/GAP 连接、OTA 传输逻辑交由 `js/` 目录下的模块脚本加载。
@@ -0,0 +1 @@
以 `tabs.html` 作为单窗口多标签的外壳,通过 `<iframe>` 嵌入 `pages/` 下各功能页(OTA 主页 `index.html`、CAN 通信 `can.html`、CAN 监控 `can_monitor.html`、电机控制 `motor.html`),并引用同级 `lab/` 下的实验页;标签列表由 JS 的 `TABS` 数组驱动,当前激活标签经由 iframe `load` 事件检测 pathname 后同步高亮并持久化至 localStorage (`dcu_tab`)。所有共享样式集中在 `css/`:`base.css` 定义深色设计令牌与全局重置;`layout.css` 提供 header-bar、卡片、表单、按钮、抽屉等布局组件;`components.css` 实现上传区、进度条、指标卡、阶段胶囊等原子组件;`responsive.css` 管理断点与合成器友好动画;`can.css`、`motor.css` 为各自功能的独立主题。每个 page HTML 通过 link 引用基础样式,其中 `motor.html` 自带完整 seed tokens 隔离主题,不依赖 `base.css` 颜色变量。页面内嵌 JavaScript 或通过 `<script type="module">` 加载 `../js/...` 逻辑脚本,与后端 ESP32 HTTP API (`/can/*`, `/ota/*`) 交互。
@@ -0,0 +1 @@
提供 ESP32/DCU 设备的 OTA 中继、CAN 发送/监控与电机控制等 Web 调试页面的静态外壳与样式体系。
@@ -0,0 +1,6 @@
- 每个页面 HTML 统一在 head 内引入空 favicon (`data:,`) 以避免浏览器请求导致本地静态服务 404。
- 页面间通过相对路径 `../css/...` 引用共享样式,并通过版本号 query 参数(如 `?v=857f83a5`)强制缓存失效。
- 状态指示使用 `.pill` + `.indicator` 元素组合,通过添加 `online/offline/processing/live/idle` 类切换状态颜色与呼吸灯效果。
- 卡片采用 `.card.card--<子系统>` 修饰类注入顶部色带与图标底色,子系统色来自 `--esp32 / --gd32 / --ble / --can / --motor / --mem` 等语义 token。
- 动画关键帧仅操作 transform/opacity 以保证合成器直通;box-shadow/background-position 等重绘型动画仅限短暂临时态(如 dragover、OTA 阶段)。
- 响应式行为通过 `@media (max-width: 1100px)` 与 `640px` 两档断点在 `responsive.css` 中集中维护,页面侧不重复定义布局断点。
@@ -0,0 +1,58 @@
schema_version: 1
module_path: root
title: ESP32 DCU 调试控制台仓库根
scope:
- README.md
- CHANGELOG.md
- .gitignore
- .gitattributes
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
- tabs.html
- serve.bat
source_files: []
depends_on: []
related_to: []
kk_module_id: 217e0343-beda-41a9-bfc6-c01fb7f0cd43
kk_cards:
architecture_design:
id: c7e1b8bd-0f31-4088-802e-47c5fc6a78d7
title: ESP32 DCU 调试控制台仓库根
coding_conventions:
id: b42250fb-a810-4b88-87b4-8f7e6c2cbf3f
title: ESP32 DCU 调试控制台仓库根
overview:
id: 2d136a23-5904-4499-855b-80b1a32528d6
title: ESP32 DCU 调试控制台仓库根
tech_stack:
id: a3a9f3a6-78c7-40ee-ac5f-cc215e42fdd9
title: ESP32 DCU 调试控制台仓库根
unique_setup_and_commands:
id: 182bc6fc-a797-4b4d-b58f-e2c87fb9e1a1
title: ESP32 DCU 调试控制台仓库根
build_system:
id: 9014488e-3a7f-413b-bbbf-c2c013eaa659
title: Windows + ESP-IDF 多入口构建与部署体系
configuration_system:
id: 6302e196-7d4f-44d3-9757-72efd9035b99
title: 前端 localStorage + 命令行参数 + VBScript 启动参数的分层配置系统
dependency_management:
id: 1d5e10e6-d5cd-4510-9159-e554b8d2ea6c
title: 依赖管理:无第三方 Python/JS 包,纯内置模块 + 独立 ecat_native.exe 子进程
error_handling:
id: 6136fa8e-72aa-43f5-a824-5d5ba4a66cf4
title: 前端错误传播与 Toast/日志呈现,后端 Python 广泛 try/except 兜底
frontend_style:
id: 7f41416c-8e1a-47e9-a286-6e7ccc8c3a20
title: DCU Mission Control 前端样式体系:深色令牌、子系统语义色与 CSS 模块化
logging_system:
id: c72b9e5c-43ed-41d4-933a-189ddde135e2
title: 前端日志系统:去重环形缓冲 + /logs 服务端拉取 + 统一 addLog 接口
kk_repo_cards:
- build_system
- configuration_system
- dependency_management
- error_handling
- frontend_style
- logging_system
@@ -0,0 +1,50 @@
## 1. 采用的系统/方法
仓库是一个混合工程:Web 前端(HTML/CSS/JS)+ Python3 本地调试服务器,以及通过外部脚本触发的 ESP-IDF 固件构建。没有 Makefile、Dockerfile、CI 流水线或统一的包管理器;构建由**多个独立的 Windows 批处理脚本和 Python 启动器**拼凑而成,分别承担不同的构建目标。
### 1.1 前端静态服务与桌面窗口
- 核心运行体 `esp32_server.py` 用 stdlib `http.server.ThreadingHTTPServer` 在本地提供静态页面,并实现 `/ecat/api/*` 反向代理到 `tools/ecat/build_native/ecat_native.exe`(端口 8090–8095 自动探测)。
- 三种启动入口:
- `serve.bat`:控制台模式,默认端口 2625,输出日志方便调试。
- `esp32_app.vbs`:`pythonw.exe` 启动 pywebview 桌面窗口(无控制台),子进程承载 HTTP 服务,避免 pythonw 后台线程被杀。
- `esp32_web.vbs`:`pythonw.exe` 启动后默认打开浏览器标签页。
- 窗口模式下服务端向 HTML 响应注入 `_reload.html`,为 WebView2 补充 F5 / Ctrl+R 刷新能力。
### 1.2 ESP-IDF 固件构建
- `scripts/build_mem.bat` 通过 Git Bash 调用 `idf.py build` 构建 `G:\Robot_v2\DCU\ESP32_COMM` 下的 DCU/ESP32 固件。
- 预先 `unset MSYSTEM*` / `MINGW_*` 环境变量绕过 ESP-IDF 的 MSys 检测,并将工具链路径固定到 `C:\Espressif` 与 `F:\software\.espressif\v6.0\esp-idf\export.bat`——路径硬编码于脚本中。
### 1.3 GD32 OTA / 刷写
- `scripts/flash_gd32.py`:经本服务的 Web API 把 GD32 固件中继下载到设备侧 Flash。
- EtherCAT 链路依赖 `tools/ecat/build_native/ecat_native.exe`(由 `build_native.bat` 编译,需 VS2022 + Npcap),首次请求时由 `esp32_server.py` 的 `_ecat_autostart` 自动拉起(带 `--parent-pid` 防止清场已有实例),未找到时用端口扫描复用已运行的进程。
## 2. 关键文件
| 文件 | 角色 |
|------|------|
| `esp32_server.py` | 静态文件服务、EtherCAT 反向代理、app 窗口租约、ECAT 守护进程管理 |
| `serve.bat` | 控制台模式启动器(Python 标准输出可见) |
| `esp32_app.vbs` | pywebview 桌面窗口启动器(隐藏控制台) |
| `esp32_web.vbs` | 默认浏览器 tab 启动器(隐藏控制台) |
| `scripts/build_mem.bat` | 通过 ESP-IDF 构建 ESP32 端固件 |
| `scripts/flash_gd32.py` | 通过本服务中继刷写 GD32 固件 |
| `README.md` | 记录目录约定、双链路架构与三处构建依赖 |
## 3. 架构与约定
- **分层清晰**:前端是纯静态资源,不碰硬件;业务 API 跨域访问 ESP32 固件的 80 端口;只有 EtherCAT 后端需要本机 C/SOEM 守护进程。
- **同源代理解 CORS**:ECAT 后端绑 127.0.0.1 且禁止预检,所有 POST JSON 请求统一走 `/ecat/api/*` 同源转发,页面与两个仓库(前端 + ecat)互不耦合。
- **自动生命周期管理**:ECAT 守护进程首次代理时才拉起,`--parent-pid` 保证父退出后自杀;已存在进程直接复用(端口 8090–8095 轮询探测),避免重复拉起触发对从站的 INIT 清理。
- **单实例锁**:多双击 `esp32_app.vbs` 时通过 `/api/focus` 聚焦已有实例而非创建新窗口。
- **版本化缓存**:README 明确“改 CSS/JS 后必须 bump 引用处的 `?v=` 缓存版本”,否则浏览器加载旧代码。
- **模块组织**:新增功能页放 `pages/` 并在 `tabs.html` 的 TABS 数组登记;一次性实验页放 `lab/` 保持自包含。
## 4. 约束与规则
- **ESP32 固件构建依赖外仓路径**:`scripts/build_mem.bat` 将 `ESP_IDF_EXPORT` 指向 `F:\software\.espressif\v6.0\esp-idf\export.bat`,并 cd 到 `G:\Robot_v2\DCU\ESP32_COMM` 再 `idf.py build`——这要求开发者拥有与仓库一致的磁盘布局,不是可移植构建。
- **ECAT 后端必须先存在或可由本服务拉起**:若未找到 `tools/ecat/build_native/ecat_native.exe`,电机链路会报错提示“请先运行 tools\ecat\build_native.bat”;前端 `js/motor/main.js` 对错误消息中包含 `build_native`/Npcap 的情况做归类。
- **WebSocket 租约决定循环启停**:`ws/app-lease` 上所有 app 窗口断开则自动停止 EtherCAT PDO 循环并把从站退回 INIT。
- **刷新率上限与链路绑定**:WiFi 链路默认 250ms、最低 50ms;ECAT 链路默认 50ms、最低 1ms(受限于 Python Event.wait 与 GIL)。切链路时各记一份 `state.refreshByTp` 并立即重排节拍。
- **1ms ECAT 档位 ≠ 1000Hz**:底层以 UNTHROTTLED_MAX_MS=20 作为“不限速”阈值,1ms 档实际约 125 Hz;低于 20ms 要同时关掉理论值与 tickStuck 统计,否则界面会撒谎。
## 5. 不存在的内容
- 没有 Makefile / Gradle / npm scripts / Dockerfile / CI YAML — 该项目在 Windows 环境内用批处理与 VBScript 手工组装构建/发布流程。
- 无统一的版本号定义文件(版本号来源于 Git tag / changelog 和设备端 SHA-256)。
- 无交叉编译配置:ESP-IDF 构建路径硬编码,ECAT native 产物预期在 Windows 上由 VS2022 构建。
@@ -0,0 +1,46 @@
# ESP32 DCU 调试控制台 — 配置系统
## 1. 系统与分层
本仓库没有集中式的配置文件(无 `*.yaml`、`*.toml`、`*.env`),采用三层组合方式完成运行时配置:
- **用户偏好层**:浏览器端 `localStorage`,用于持久化设备 IP、电机传输链路、刷新率、CAN DBC 设置等。
- **进程/工具层**:Python 服务通过 `argparse` 解析命令行参数(`--port`、`--window`、`--no-browser`);Windows 批处理/VBScript 再提供默认值与启动入口。
- **外部依赖层**:通过调用外部子进程(`tools/ecat/build_native/ecat_native.exe`)并传入 `--parent-pid` 等方式传递进程间通信所需配置。
## 2. 关键文件
- `esp32_server.py`:服务端核心。HTTP 服务器(端口探测/绑定)、EtherCAT 反向代理、pywebview 窗口外壳、子进程生命周期管理,均通过 argparse 与模块级常量硬编码控制。
- `serve.bat`:控制台模式启动器,设 `PORT=2625` 后执行 `python esp32_server.py --port %PORT%`。
- `esp32_web.vbs` / `esp32_app.vbs`:分别以隐藏窗口/桌面窗(pywebview)方式启动 `esp32_server.py`,区别仅在于是否传 `--window`。
- `js/common/base-url.js`:统一封装设备 IP 的 `localStorage` key(`dcu_esp_ip`)与默认值(`DEFAULT_ESP_IP = '192.168.31.127'`)。注释明确“各页面共用”“只负责公共实现”。
- `js/motor/transport.js`:维护 ECAT/WiFi 传输切换状态,key 为 `dcu_motor_transport`。
- `js/motor/transport-ecat.js`:ECAT 传输配置,含网卡适配器、周期时间(4ms→250us 迁移标记 `LS_PERIOD_MIG`、`LS_SPIN`)等,全部落 localStorage。
- `js/can/persist.js`、`js/ota/file-handle.js`:各自功能页的偏好持久化(DBC、刷新目标 `LS_FLASH_TARGET`、最近固件元数据 `LS_FILE_META`)。
- `js/app.js`:主标签外壳页面,读写主页的共享设备 IP。
## 3. 架构与设计约定
### 3.1 设备发现与连接
- 所有页面向设备发起 API 请求的目标由 `getBaseUrl()` 计算:`http://` + 当前设备 IP,IP 从 `localStorage['dcu_esp_ip']` 取,无有效值回退到 `192.168.31.127`。
- 代码注释强调 “每个页面一致”“空值忽略”,对 `localStorage` 的访问被 try/catch 包裹(兼容隐私模式)。
### 3.2 端口与服务启动
- 端口优先级:`serve.bat` → `--port` → 默认 `2625` → `_bind_server` 自动递增重试(若占用则打印 `端口 xxx 已被占用, 尝试 xxx+1`)。
- 默认监听 `127.0.0.1`(不暴露公网)。
- Windows 下通过 Job Object 绑定子进程生命周期,父进程退出时内核连带杀掉服务子进程。
### 3.3 EtherCAT 后端自动发现与启动
- 使用内置端口数组 `ECAT_PORTS = (8090..8095)` 轮询检测正在运行的 `ecat_native.exe`。
- 首次需要时在 `g_ecat_lock` 保护下拉起子进程并传入 `--parent-pid`,避免独立实例清理逻辑误杀旧进程。
- 已运行实例通过 `_port_taken(p)` 复用,不再重复拉起。
### 3.4 多语言环境配置
- 所有中文字符串(日志、UI 文案、提示、注释中的路径与说明)均以 UTF-8 输出:`serve.bat` 先执行 `chcp 65001` 并将 `PYTHONUTF8=1` 写入子进程环境;`esp32_web.vbs` / `esp32_app.vbs` 同样设置 `PYTHONUTF8=1`。
- 前端 JS 侧无多语言文件,所有文案直接嵌入 HTML/JS 源文件中。
## 4. 约束与规则
- **设备 IP 键名唯一**:`dcu_esp_ip` 由 `js/common/base-url.js` 统一定义并通过 export 导出,所有功能页通过该共享 key 协作(OTA/CAN/电机页注释明确声明此约定)。
- **无外部配置文件的强制要求**:整个项目未使用 `.env`/`.yaml`/`*.cfg`/`*.ini` 等任何集中配置格式,如需调参必须修改源码常量或命令行参数。`esp32_server.py` 中除 `argparse` 外,其余行为均通过模块级变量(如 `ECAT_DIR`、`ECAT_PORTS`、`BASE_DIR`)与命令行参数组织。
- **安全边界**:服务默认仅监听 `127.0.0.1`;EtherCAT 原生后端也绑 `127.0.0.1` 且无 CORS,故页面需经本服务同源反向代理(`/ecat/api/*`)访问,避免跨域预检失败。
- **前端本地存储可容错**:每次 `localStorage.setItem/getItem` 都包裹 try/catch(应对无痕/隐私模式抛出异常);写前做 trim 校验,空值即忽略。
- **进程边界配置不可穿透**:VBScript 层通过命令行参数(`--window`)切换模式,但 Python 内部不会反查环境变量;子进程通过标准输出打印 `PORT={...}` 供父进程解析,而非通过共享配置文件通信。
## 5. 总结
这是一个**纯前端 + 简易 Python 静态服务器**场景下的轻量配置系统:设备地址和界面偏好完全落在浏览器的 `localStorage` 中,服务进程本身通过命令行参数与模块内常量硬编码驱动,不存在 YAML/环境变量等集中式配置机制。其优点是无依赖、可直连浏览器调试;代价是新增配置项需要同时修改多处 JS/Python 源码和脚本入口。
@@ -0,0 +1,26 @@
## 一、使用的依赖管理体系
本仓库**没有传统的包管理器**:根目录与所有子目录下不存在 `package.json`、`go.mod`、`requirements.txt`、`Pipfile*`、`*.lock`、`.npmrc` 等任何依赖声明文件;前端侧使用浏览器原生 ES module(`type="module"`)通过相对路径直接引用 `../css/*`、`../js/common/*`,不经过 npm/yarn/pnpm 解析。
Python 后端 (`esp32_server.py`) 仅依赖 Python **标准库**(`http.server.SimpleHTTPRequestHandler` / `ThreadingHTTPServer`、`socket`、`subprocess`、`ctypes`、`urllib.request`、`threading`、`hashlib` 等),以及一个可选的桌面外壳依赖 `pywebview`。该可选依赖在运行时探测缺失时回退到普通浏览器模式:`print("pywebview 未安装: pip install pywebview —— 回退浏览器模式")`,并通过 `serve.bat` / `esp32_web.vbs` 走无 GUI 路径。
非 Python 二进制依赖以独立可执行形式存在:EtherCAT 后端为编译后的单文件 `build_native/ecat_native.exe`(由外部脚本 `build_native.bat` 构建),由主服务按需在 8090–8095 端口范围内发现并启动(带 `--parent-pid` 实现父进程退出时自我结束),不属于当前仓库的包依赖图。
## 二、关键文件
- `esp32_server.py`:唯一 Python 入口;通过 `import http.server`、`ctypes`、`subprocess` 等标准库提供服务、反向代理、窗口管理与 ecat 子进程编排。
- `README.md`:文档化的启动方式(`esp32_app.vbs` / `esp32_web.vbs` / `serve.bat`),明确了 pywebview 可选性以及 ecat_native 的外部依赖关系。
- `scripts/build_mem.bat`:调用 ESP-IDF 构建 DCU/ESP32_COMM 固件的辅助脚本,不引入 Python/JS 依赖。
- `js/`、`pages/`、`css/`:纯前端资源,无任何 `<script src="node_modules/...">`,全部以相对 URL 引用。
## 三、架构与约定
1. **零打包策略**:`esp32_server.py` 直接 serve 静态 HTML/CSS/JS,页面内用 `<script type="module" src="../js/app.js">` 等加载,依赖解决完全由浏览器原生 ES module 完成。
2. **可选桌面外壳**:pywebview 作为可选依赖——有则用 `pythonw` + pywebview 开 WebView2 桌面窗口(无控制台窗体);缺则回退到系统默认浏览器标签页模式,由 `serve.bat` / `esp32_web.vbs` 启动。
3. **外置二进制子进程模式**:EtherCAT 能力不在 Python 进程中提供,而是通过子进程协议拉取 `tools/ecat/build_native/ecat_native.exe`,服务端做 `/ecat/api/*` 同源转发来绕过其无 CORS + 127.0.0.1 绑定问题。该二进制由另一个 Git 仓库产出,当前仓库只负责发现与生命周期管理。
4. **单实例互斥**:通过本地 API `/api/focus` 把新双击重定向到已有实例前台窗口,避免并发拉起多个服务端实例。
## 四、约定与约束
- **前端资源版本化**:修改 CSS/JS 后必须 bump 引用的 `?v=` 缓存参数(否则浏览器加载旧代码),这是 README 中明确记录的强制约定。
- **JS 模块内部引用须用 `import.meta.url`**:不能写文档相对路径(如 Worker 文件),页面层级变化会 404(见 `js/motor/main.js` 的 `TICK_WORKER_URL`)。
- **新增功能页必须在 `tabs.html` 的 `TABS` 数组登记**,实验性/一次性页面放 `lab/` 并保持自包含。
- **设备地址配置保存在 `localStorage.dcu_esp_ip`**(默认 `192.168.31.127`,见 `js/common/base-url.js`),本服务不持有设备 IP,业务 API 全部直连 ESP32 固件 80 端口。
- **Python 侧不得随意引入三方库**:当前仅依赖标准库与可选 `pywebview`;若需增加第三方包,应评估是否仍走“纯标准库 + 可选 GUI”的路径,因为仓库中没有 `requirements.txt` 或 `pip freeze` 产物,依赖变更无法通过 CI 锁定复现。
- **ECAT 子进程必须带 `--parent-pid`**:否则它会清理同端口段其他实例,且主服务退出后会留下孤儿进程占用 Npcap 适配器与端口(见注释阶段 4 迁移说明)。
@@ -0,0 +1,5 @@
## 1. 总体方法
本仓库的「错误处理」由三块构成:
- **Python 服务端 (esp32_server.py)**:大量使用裸 `except Exception:` 包裹每个 I/O / 子进程/线程入口,把网络/文件/Windows 句柄异常吞掉并继续运行;不存在自定义异常类型体系。
- **前端工具脚本 (scripts/*.py)**:通过 `raise SystemExit(...)` + `urllib.error.HTTPError` 区分成功/失败路径,主流程用 try 捕获异常后终止。**没有**统一的 Result/Either 封装。
- **浏览器前端 (js/…)**:采用「Promise + fetch AbortSignal.timeout → throw Error」的异步错误模型;所有可见故障统一经 `toast.js` 的 `showToast(msg, 'success'|'error'|'warning')` 在 UI 弹出(5s 去重),并通过 `log.js` 的 `addLog(level, tag, msg)` 结构化写入页面日志面板。BLE OTA、OTA 上传等长链路则在各自模块内维护状态机 (`stages.js` / `progress.js` / `session.js`),通过 `setBleStageError`、`cleanupAfterOtaFailure` 等函数显式转入错误阶段。
@@ -0,0 +1,39 @@
## 1. 整体方法与工具
- 纯原生 CSS,无 SCSS/Sass/Less、无 Tailwind、无 PostCSS 等构建管线;`pages/*.html` 直接以 `<link>` 引入静态 `.css`(带 `?v=<hash>` 缓存破坏参数)。
- 全局样式按职责拆为独立文件并通过 HTML 顺序引入:
- `css/base.css`:设计令牌(`:root` 变量)、全局 reset、背景网格与氛围光、滚动条、选区、focus 可见性。
- `css/layout.css`:header-bar、主布局 grid、卡片/表单/按钮/状态胶囊 (pill)。
- `css/components.css`:上传区、进度条、阶段切换胶囊 (progress-phase)、指标卡片、徽章、Toast、Tooltip 等可复用 UI 块。
- `css/can.css`:CAN 工作台专用样式,并派生 `motor-launch-card` 复用该布局仅换强调色。
- `css/responsive.css`:`@media` 断点(1100px / 640px)与全部关键帧动画 + 卡片入场交错延迟。
- `css/motor.css`:独立的「电机控制台」子主题,自带 seed tokens,不依赖 base/css 的共享令牌集。
- 无任何外部组件库或设计系统包,一切视觉规范通过 CSS Custom Properties 落地。
## 2. 关键文件
- `css/base.css` — 设计令牌中心,集中定义底色层级、子系统语义色、字体栈、圆角与阴影。
- `css/layout.css` — header-bar、main-layout、card、form、pill、card--{esp32,gd32,ble,can,motor,mem,status} 变体修饰类。
- `css/components.css` — upload-zone / progress-fill / metric-grid / badge-stat / progress-phase-row 等。
- `css/can.css` — can-launch-card / .can-grid / .can-header / .can-toolbar-grid 等,同时提供 motor-launch-card 变体。
- `css/responsive.css` — 断点 + @keyframes + 入口淡入交错延迟 + hover/拖拽微交互。
- `css/motor.css` — 独立的 seed token 主题(`--seed-*`),自给自足的 reset 和 toolbar/main-grid。
- `pages/index.html` — 统一引入 base/layout/components/can/responsive 样式的入口示例。
## 3. 架构与设计约定
- **设计令牌分层**:`base.css` 在 `:root` 下分组声明基础层(`--bg-root/--bg-panel/--bg-card`)、边框层、文字层、子系统语义色层(ESP32=青绿 `#2fd8c1`、GD32=琥珀 `#fcbf3f`、BLE=蓝 `#5fa8ff`、CAN=绿 `#43d97b`、motor=青 `#22d3ee`、mem=紫 `#a88bfa`)、状态层(green/amber/red),并以 `--accent/--accent-dim/--border-focus` 兼容旧引用。
- **子系统色彩编码**:通过在 card/pill/metric 上使用 `card--{esp32,gd32,ble,can,motor}` 等修饰类,配合 `--card-accent` 覆盖实现同一组件多色系复用;header-top 用三色渐变线暗示 ESP32→BLE→GD32 的子系统谱系。
- **排版与字体**:中文优先苹方/雅黑(`--font-display`),英文回退 Segoe UI/system-ui;数字/SHA/HEX/日志统一使用 `--font-mono`(SF Mono → Fira Code → Cascadia → Consolas)。`14px` 基准字号,`line-height: 1.55`。大量数值区域应用 `.tabular` 利用 `font-variant-numeric: tabular-nums` 对齐。
- **布局**:主容器 `max-width: 1480px`,默认两列 `grid-template-columns: 400px 1fr`,靠 `layout.css` 中的 `.main-layout` 组织,响应式在 `responsive.css` 中降为一列。
- **卡片与组件**:卡片顶部有 2px 渐变彩色强调带(由 `::before` 生成,颜色由 `--card-accent` 控制),hover 抬升并增强描边;按钮、输入框、选择器均走 `layout.css` 的统一 `.btn`/`.input`/`.form-label`。
- **响应式策略**:双断点 `1100px`(桌面窄屏)和 `640px`(移动端)切换 layout、badge 网格列数、抽屉宽度计算方式等;没有 mobile-first 媒体查询前缀,属于 progressive enhancement 思路。
- **动画性能约定**:注释中明确要求常驻无限动画只动 `transform/opacity`(合成器直通),禁止频繁触发重绘的 `box-shadow/background-position` 用于关键帧;例如 `shimmer`、`ringRipple`、`glowBreathFade`、`flowDash` 都遵循该规则。
- **独立子主题**:`css/motor.css` 拥有自己的 `:root { --seed-* }` 种子令牌派生一套颜色/字体/间距,并自带完整 reset,因此可以脱离 `base.css` 单独运行在 `pages/motor.html`——这是仓库内唯一采用“隔离主题”的方式。
- **页面级注入**:每个 HTML 通过 `<link rel="stylesheet" href="../css/*.css?v=...">` 注入;版本号哈希用于静态缓存破坏,未使用任何打包/热更新。
## 4. 约束与可观察到的规则
- 所有色调均来自 `base.css` 定义的 CSS Custom Properties,新增组件应优先引用 `var(--*)` 而非硬编码颜色值(已有组件如 pill、card、progress、metric 均如此)。
- 子系统色需从预定义的 `--esp32/--gd32/--ble/--can/--motor/--mem` 选取,避免自创新语义色;可通过 `*-hi`(高光)/ `*-dim`(半透明背景)组合完成 hover/accent 效果。
- 卡片必须携带 `card--{子系统}` 修饰类以正确填充顶部强调带与图标背景(`layout.css` 已为每类预定义 `--card-accent` 与 `--card-accent-dim`)。
- 按钮统一走 `.btn/.btn-primary/.btn-success/.btn-danger/.btn-outline/.btn-ghost` 家族,避免新建按钮样式;新增样式应在 `layout.css` 或 `components.css` 中扩展。
- 动画遵循 `responsive.css` 顶部注释的性能约定:常驻 animation 仅改变 `transform/opacity`;短暂态可使用 `box-shadow/background-position`。
- 响应式布局必须通过 `css/responsive.css` 中添加 `@media` 规则进行覆盖,不在页面 HTML 或 JS 中做 DOM 尺寸判断来切换样式。
- 如需全新独立页(如 motor 控制台),可仿照 `css/motor.css` 的 seed-token 模式创建隔离主题,但应尽量复用 base 组件类以保持一致的交互体验。
@@ -0,0 +1,59 @@
## 1. 使用的方案
项目在前端(ESP32/DCU Web 控制台)实现了自研轻量级日志子系统,位于 `js/common/log.js`。其职责是:**接收带标签的日志条目 → 去重/过滤 → 维护 DOM 面板 + 环形缓冲 → 按级别样式化渲染**。
日志数据并非由前端直接构造所有业务事件,而是通过 `js/common/polling.js` 以轮询方式从后端 ESP32 设备拉取 `/system/status` 与 `/logs` 两条流,把后端的结构化输出注入到统一的 UI 中;同时各模块通过 `addLog(level, tag, message)` 主动写入调试类日志。因此该“logging system”是 **后端实时日志拉取 + 前端集中式渲染** 的组合。
## 2. 核心文件
- `js/common/log.js` — 日志缓存、去重、DOM 渲染、过滤、自动滚动、条数上限裁剪的核心库(`addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll` 四导出)
- `js/common/polling.js` — 定时递归 setTimeout 调度 `/system/status` 与 `/logs` 轮询,并将远端行 `^[DIEW] ([^|]+)\|(.*)$` 解析为 `addLog(m[1], m[2], m[3])`
- `css/motor.css`、`components.css`、`can.css`、`layout.css`、`responsive.css`、`base.css` — `.log-line`、`.lt/.lv/.lg/.lm` 等日志元素的 CSS 样式
- `pages/index.html`、`pages/can_monitor.html`、`pages/motor.html`、`tabs.html` — 包含 `#logScroll`、`#logCount`、`#autoScrollBtn`、`.log-filter` 按钮的页面骨架(由 `log.js` 通过 ID 直接操作)
- 消费端(导入 `addLog` 的模块举例):`js/app.js`、`js/common/connection.js`、`js/ble/state.js`、`js/ota/file-handle.js`、`js/ota/session.js`、`js/ota/upload-esp32.js`、`js/ota/upload-gd32.js`、`js/common/polling.js`、`js/ota/progress.js`
## 3. 架构与设计约定
### 日志条目结构
每个日志是一个三元组 `(level, tag, message)`,经 `formatTime` 加上本地时间戳,再被 HTML 转义(调用 `utils.escapeHtml` 防 XSS)后渲染为一行:
```
[时间] [级别] [TAG] 消息
```
其中级别 `lv` 使用 class `D|I|W|E`,tag 用 `lg`,消息用 `lm`。
### 去重策略
- 同一帧内相邻重复项:通过 `makeLogHash(level, tag, message)` 计算字符串哈希并与 `lastLogHash` 比较,完全相同的连续条目只写一次。
- 用于避免高频率状态上报产生刷屏。
### 缓冲与裁剪
- 内存采用普通数组 `logEntries`,`MAX_LOGS = 1000` 作为硬上限;超出时 `trimIfNeeded()` 移出最旧的 DOM 节点并 shift 掉对应 entry。
- 没有持久化历史到 localStorage。
### 过滤与 UI
- 全局 `logFilter` ∈ `{ all, D, I, W, E }`,由页面 `#autoScrollBtn` 与 `.log-filter` 控制。
- `setLogFilter` 会清空 `#logScroll` 并按当前 filter 重建 Fragment 显示,支持运行时切换。
- `toggleAutoScroll` 切换自动滚动开关,文字在 “🔽 自动滚动” / “⏸️ 暂停滚动” 间切换。
- 自动滚动使用 `requestAnimationFrame` 批量合并,防止每条日志都触发强制布局。
### 来源分流
- **后端实时流**:`polling.js` 周期性 GET `/logs`,将每行正则 `^(.) (.+)\|(.+)$` 解析为 level/tag/message,直接 `addLog(...)` 投递。
- **前端主动日志**:连接、OTA、BLE、文件处理等各模块遇到关键流程点(断线、重启、握手、校验失败、SHA 不匹配等)调用 `addLog`,按 `connection`/`系统`/`诊断`/`上传`/`文件`/`OTA`/`内存`/`BLE` 等 tag 区分领域。
### 后端协议契约(由 poller 侧 enforce)
- `/system/status` 返回 JSON,含 `sys_state_code`、`relay_state`、堆低水位等字段。
- `/logs` 返回文本行,格式约定为 `LEVEL TAG|MESSAGE`(首字符为 `D/I/W/E`,然后空格、tag、竖线、正文),这是前后端共享的契约,而非任何框架约定。
## 4. 约定与约束
- **唯一入口**:全仓库前端日志通过 `common/log.js` 暴露的四函数进出;外部模块仅 import/addLog,不允许自行创建日志 DOM。
- **必须带 tag**:所有调用均传入二级分类 tag(如 `'连接'`、`'诊断'`、`'上传'`、`'文件'`、`'内存'`、`'系统'`、`'BLE'`、`'OTA'`),以便后期按 tag 过滤或筛选。
- **级别限定**:level 严格为 `D`(debug)、`I`(info)、`W`(warn)、`E`(error) 其一;UI 据此设置 CSS 颜色与过滤器类别。
- **XSS 防护**:level/tag/message 插入 innerHTML 前全部经 `escapeHtml` 处理;新增内容也需遵循此规则。
- **性能约束**:单条日志路径禁用多次 DOM query;高频重复内容依赖去重;批量追加走 `DocumentFragment`(filter 重建时);自动滚动批量化到下一 rAF。
- **轮询间隔**:`status` 与 `logs` 轮询默认 1000ms,上传期间暂停(`pausePollingForUpload`),GD32 中继期临时降至 400ms(`setStatusPollIntervalMs(400)`)以减少日志滞后,结束后恢复。
- **页面前置条件**:页面需存在 `#logScroll`、`#logCount`、`#autoScrollBtn`、`.log-filter` 四个选择器目标,否则 `clearLogs`/`setLogFilter`/`toggleAutoScroll` 无作用但不会抛错。
- **未覆盖场景**:Node.js / Python 后端脚本目录(`scripts/*.py`、`scripts/*.mjs`)普遍使用原生 `console.*`(见 grep 结果),不使用前端日志系统——说明本日志系统限定于浏览器端控制台。
## 5. 总结
这是一个围绕前端 DOM 的微型日志子系统:通过统一的 `addLog(level,tag,message)` 接口汇聚多模块的调试信息,并以环形缓冲 + 去重 + 自动滚动的形式挂载到页面上;同时通过 `polling.js` 把后端 ESP32 的 `/logs` 实时串流入这个面板,形成“设备端打 log + 前端集中呈现”的统一调试窗口。
@@ -0,0 +1 @@
Python + pywebview 作为本地桌面容器,JavaScript (ES modules) 构成前端控制台,EtherCAT PDO/Slot 协议贯穿 JS 前端与 Python 后端通信,Windows VBScript/bat 作为统一启动外壳。
@@ -0,0 +1 @@
仓库以 Python 后端 `esp32_server.py` 为统一入口,通过 VBScript (`esp32_app.vbs` / `esp32_web.vbs`) 或批处理 (`serve.bat`) 在 Windows 上拉起 pywebview 桌面窗口或浏览器标签,将 `pages/`、`js/`、`lab/` 等静态资源暴露给前端;`pages/*` 是薄壳 HTML,实际逻辑落在 `js/motor`、`js/ota` 等子模块中,由 JS 前端通过 WebSocket 与服务端进行 EtherCAT/CAN/Ota 通信;`scripts/` 提供 ECAT 撕裂检测、GD32 固件刷写、浏览器 E2E 测试等运行时工具,面向同一服务端接口;`lab/` 提供离线诊断页面和原始抓包 CSV,供产线验证总线连通性。各子模块围绕同一套“本地 Web 服务器 + ECAT 反向代理”契约协作,无需独立部署。
@@ -0,0 +1 @@
汇聚 ESP32/DCU Web 调试服务、多页面前端、JS 客户端、ECAT 工具脚本与 lab 诊断资源的单仓调试控制台,提供本地启动与跨平台运行入口。
@@ -0,0 +1 @@
根目录 `serve.bat` 提供一键启动服务并在默认浏览器打开 `tabs.html`;VBScript (`esp32_app.vbs` / `esp32_web.vbs`) 可在无浏览器环境下以 pywebview 桌面窗口方式加载调试页面,实现双模式运行。
@@ -0,0 +1,3 @@
- 所有需要与服务端通信的页面统一通过 `tabs.html` 以 app 窗口租约形式加载,避免多窗口抢占 ECAT 循环
- 前端静态页面集中在 `pages/` 目录并以薄壳 HTML 形式存在,业务逻辑迁移到 `js/` 下的 ES module 文件组织
- 浏览器端测试与运行时诊断脚本集中于 `scripts/`,按功能(ECAT/CPU 采样/刷写)划分命名空间并可直接从仓库根执行
@@ -0,0 +1,27 @@
schema_version: 1
module_path: lab
title: 调试诊断与 EtherCAT 采样数据
scope:
- lab/
- ecat_slots_raw.csv
source_files: []
depends_on: []
related_to: []
kk_module_id: 3f11b5ed-b020-40ab-be49-36bfc4fea893
kk_ordinal: 4
kk_cards:
architecture_design:
id: a83bab26-0eaa-442b-8401-d73020dbf89c
title: 调试诊断与 EtherCAT 采样数据
coding_conventions:
id: a0e4b780-bcb8-4a6c-bf02-5152189b1dee
title: 调试诊断与 EtherCAT 采样数据
overview:
id: 19093973-5734-403e-b0e2-ace21e683cb1
title: 调试诊断与 EtherCAT 采样数据
tech_stack:
id: 86af3241-db34-4640-bc79-bed4bab9aace
title: 调试诊断与 EtherCAT 采样数据
unique_setup_and_commands:
id: 35ccbf10-bc4b-46d9-9446-49ef5d4be525
title: 调试诊断与 EtherCAT 采样数据
@@ -0,0 +1 @@
纯前端 HTML + 内联 CSS/JS(无外部依赖),通过浏览器 fetch 直接访问 ESP32/GD32 内置 HTTP 端点;EtherCAT 采样数据采用自定义 CSV 格式,字段含 kind(H/M/B/X)、微秒级时间戳及十六进制 PDO payload。
@@ -0,0 +1 @@
本模块由两部分互不耦合的资源组成:lab/ 下是独立、自包含的浏览器单文件测试页(spi_test.html 通过 GET /spi/speedtest 调用固件探针;continuity_test.html 通过 LAN9252 通断测试),它们不依赖项目其他 JS/CSS,通过 serve.bat 直接访问;ecat_slots_raw.csv 则是按固定列(kind、时间戳、设备/槽号等)记录的 EtherCAT PDO/Slot 原始采样日志,被 scripts/probe_ecat_* 等 Python 分析脚本消费。_last_gd32_sha.txt 用于记录最近一次 GD32 固件 SHA 以判断固件是否变更。整体无构建步骤,仅作为静态资源与数据采集产物存放区。
@@ -0,0 +1 @@
提供 SPI/以太网链路独立诊断 HTML 测试页及 EtherCAT PDO/Slot 原始抓包 CSV,用于产线和调试阶段的硬件连通性与总线吞吐验证。
@@ -0,0 +1 @@
使用仓库根目录 serve.bat 启动本地 HTTP 服务器后,在浏览器打开 http://localhost:2625/spi_test.html 或 continuity_test.html 即可运行诊断;测试前需确保设备 IP 正确并通过网线连接至同一网段。
@@ -0,0 +1,3 @@
- 每个诊断页均为自包含单文件 HTML,所有样式与脚本内联,不引入任何外部 css/js 依赖
- CSV 采样文件以紧凑列式文本表示 EtherCAT 周期帧,使用单字符 kind 标识 H/M/B/X 报文类型并附带带符号小数毫秒时间戳
- 临时工具文件头部注释明确标注功能标签(如「SPI-SPEEDTEST」)及删除方式,便于后续清理时对照固件端点一并移除
+154
View File
@@ -0,0 +1,154 @@
# 知识卡导出索引文件
schema_version: 1
locale: zh-CN
commit: 43d42ad891b652f79db1f37091141cb7a2b4e314
branch: main
exported_at: "2026-09-06T19:46:36Z"
modules:
frontend_pages:
dir_name: ESP32_DCU 调试控制台前端页面集
title: ESP32/DCU 调试控制台前端页面集
scope:
- pages/
- css/
- tabs.html
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: esp32_server.py 作为静态文件服务器提供 tabs.html、pages/ 与 lab/ 目录下的 HTML,页面由本服务同源 serving。
js_frontend:
dir_name: ESP32 DCU 前端 Web 控制台集合
title: ESP32 DCU 前端 Web 控制台集合
scope:
- js/
source_files: []
children:
- js_frontend/app_entries
- js_frontend/ble
- js_frontend/can
- js_frontend/common
- js_frontend/motor
- js_frontend/ota
depends_on: []
related_to: []
js_frontend/app_entries:
dir_name: 前端应用入口聚合层(ESP32 OTA _ BLE OTA _ CAN 诊断)
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
scope:
- js/app.js
- js/ble_ota.js
- js/can.js
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: OTA、BLE、CAN 控制台页面均由 python_server 的静态文件服务提供,并通过 base-url.js 中 localStorage 存储的 dcu_esp_ip 以同源请求访问设备 API。
js_frontend/ble:
dir_name: BLE 通道 OTA 客户端(Web Bluetooth GATT)
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
scope:
- js/ble/
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/can:
dir_name: CAN 控制台前端:DBC 解析、报文发送与接收监控
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
scope:
- js/can/
- js/can_monitor.js
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/common:
dir_name: 前端公共工具库(连接_轮询_日志_Toast_IndexedDB)
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
scope:
- js/common/
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/motor:
dir_name: 电机控制台多传输模块
title: 电机控制台多传输模块
scope:
- js/motor/
source_files: []
children: []
depends_on: []
related_to:
- path: js_frontend/can
reason: CAN 控制台与电机控制台均通过 POST /can/send_batch 发送帧到 ESP32 设备,但 CAN 用于通用报文编辑而电机用于特定 CMD/CONFIG 控制流程。
- path: python_server
reason: 电机控制台通过 /ecat/api/* 反向代理访问本地 ecat_native.exe 的 /api/motor/*(transport-ecat.js 中拼接路径发送)。
js_frontend/ota:
dir_name: 前端 OTA 刷写会话与进度管理
title: 前端 OTA 刷写会话与进度管理
scope:
- js/ota/
source_files: []
children: []
depends_on: []
related_to:
- path: js_frontend/ble
reason: 两者均为浏览器端固件升级方案:js/ota 走 WiFi ESP32 HTTP(分块上传+SHA-256),js/ble 走 Web Bluetooth GATT(分包 ACK+窗口+SHA-256),同一业务目标的不同实现。
lab:
dir_name: 调试诊断与 EtherCAT 采样数据
title: 调试诊断与 EtherCAT 采样数据
scope:
- lab/
- ecat_slots_raw.csv
source_files: []
children: []
depends_on: []
related_to: []
python_server:
dir_name: ESP32 调试控制台本地 Web 服务器与窗口外壳
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
scope:
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
source_files: []
children: []
depends_on: []
related_to: []
root:
dir_name: ESP32 DCU 调试控制台仓库根
title: ESP32 DCU 调试控制台仓库根
scope:
- README.md
- CHANGELOG.md
- .gitignore
- .gitattributes
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
- tabs.html
- serve.bat
source_files: []
children:
- frontend_pages
- js_frontend
- lab
- python_server
- scripts_and_tests
depends_on: []
related_to: []
scripts_and_tests:
dir_name: ECAT 调试、固件刷写与端到端测试脚本集
title: ECAT 调试、固件刷写与端到端测试脚本集
scope:
- scripts/
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: 脚本 test_app_lease_e2e.mjs 等连接本地页面服务器的 /ws/app-lease 端点做端到端断言(端口探测与 POST 触发)。
+43 -14
View File
@@ -101,21 +101,27 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
| 链路 | 默认 | 下限 | 为什么 | | 链路 | 默认 | 下限 | 为什么 |
|------|------|------|--------| |------|------|------|--------|
| `wifi` | 250 ms | 50 ms | 每拍是一次无线 HTTP 往返,还要与 GD32 `0xC5` 主动推送周期对齐;再快只会让请求排队积压 | | `wifi` | 250 ms | 50 ms | 每拍是一次无线 HTTP 往返,还要与 GD32 `0xC5` 主动推送周期对齐;再快只会让请求排队积压 |
| `ecat` | 50 ms | **1 ms** | 只是读本机后台 PDO 快照,无总线代价;总线周期另由后台循环维持(默认 4 ms) | | `ecat` | 50 ms | **6 ms** | 只是读本机后台 PDO 快照,无总线代价;总线周期另由后台循环维持(默认 4 ms)。下限取 6 ms 是因为绘制上限由显示器刷新率封顶(本机 160 Hz),再快的轮询没有消费者 |
两条链路**不能共用一个变量**:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队 两条链路**不能共用一个变量**:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
(`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。 (`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的, 旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的,
不该套到 ECAT 上。 不该套到 ECAT 上。
- **ECAT 的 1ms 语义是「不限速」而不是 1000Hz**(`UNTHROTTLED_MAX_MS = 20`)。 - **ECAT 的 6ms 语义是「不限速」而不是 166Hz**(`UNTHROTTLED_MAX_MS = 20`)。
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_server` → 回来), 单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_native` → 回来),
浏览器里 1ms 档实测约 **125 Hz**。 实测均值 ≈1.8ms → 6ms 档约 **160 Hz**,正好贴着本机 160Hz 屏的绘制上限。
所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎: 所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎:
1. `tickStuck` 卡顿黄标 —— 该档每拍都会撞上"上一拍还在飞",这是设计行为, 1. `tickStuck` 卡顿黄标 —— 该档贴着往返耗时,尾部抖动就会撞上"上一拍还在飞",
再报"连续 N 拍未按时完成"就是误报; 这是设计行为,再报"连续 N 拍未按时完成"就是误报;
2. 「≈N Hz」理论值 —— 1ms 硬算是 1000Hz。这时只显示 `statusHzText` 2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText`
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s, 按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s,
否则 `1ms × 2 × 10 = 20ms` 的旧数据 cutoff 会把刚到的样本全清空)。 否则 `6ms × 2 × 10 = 120ms` 的旧数据 cutoff 会把刚到的样本全清空)。
- **轮询不要快过绘制上限**:1ms 档实测能跑到 **~550 Hz**(`ecat_native` 侧
写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s
在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链
≈1.0 核(renderer 0.35 + 网络服务 0.26 + browser 0.27 + 代理 0.15),
降到 160 Hz 约省 0.7 核。所以下限抬到 6ms;存量 localStorage 里的 1ms
会在读取时钳到 6ms。
- **代理转发绝不能给 socket 传 timeout**(`_ecat_post`,已踩过)。 - **代理转发绝不能给 socket 传 timeout**(`_ecat_post`,已踩过)。
给 socket 设 timeout 会让 Python 把它切成非阻塞模式,而 **Windows 上非阻塞 给 socket 设 timeout 会让 Python 把它切成非阻塞模式,而 **Windows 上非阻塞
connect 要靠 select 轮询完成,于是撞上约 10ms 的系统调度粒度**。 connect 要靠 select 轮询完成,于是撞上约 10ms 的系统调度粒度**。
@@ -131,7 +137,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
排查时很容易误判成"EtherCAT 就这么快"。现在 `_ecat_post` 手写最小 HTTP/1.0 排查时很容易误判成"EtherCAT 就这么快"。现在 `_ecat_post` 手写最小 HTTP/1.0
客户端:`create_connection` 不传 timeout(本机回环要么立刻成功要么立刻 客户端:`create_connection` 不传 timeout(本机回环要么立刻成功要么立刻
`ECONNREFUSED`,不会挂住),连上之后才 `settimeout` 管收发。 `ECONNREFUSED`,不会挂住),连上之后才 `settimeout` 管收发。
这一改让 1ms 档从 62 Hz 提到 125 Hz。 这一改让当时的 1ms 档从 62 Hz 提到 125 Hz(该档现已按绘制上限抬到 6ms 下限)。
- **别把 EtherCAT 当以太网用:PDO 窗口要自适应**(`ecat_server._cover_bases`,已踩过)。 - **别把 EtherCAT 当以太网用:PDO 窗口要自适应**(`ecat_server._cover_bases`,已踩过)。
从站一个 PDO 只带 7 个状态槽,而槽表有 27 个(9 节点 × 3 通道是**容量**, 从站一个 PDO 只带 7 个状态槽,而槽表有 27 个(9 节点 × 3 通道是**容量**,
不是实际接入数)。旧实现每周期把窗口 `+7` 无脑轮扫 4 个窗口,于是 不是实际接入数)。旧实现每周期把窗口 `+7` 无脑轮扫 4 个窗口,于是
@@ -302,6 +308,29 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
最坏约 24s,所以前端超时给 30s、代理也给 30s)。 最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。 4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。 断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。
- **关掉 app 必停从站**(上面"自动接入"的反向操作,`esp32_server.py` 的
`_app_lease_*` 一节):每个 app 页面与页面服务器保持一条 WebSocket
(`js/common/app-lease.js` → `GET /ws/app-lease`),**最后一条**租约连接断开
(所有 app 窗口关闭)后,服务端调后端 `/api/op/stop` 停 PDO 循环并把从站退回
INIT。为什么必须有它:循环在喂从站 1000ms 过程数据看门狗,没人停它就永远
卡在 OP、自旋节拍白占一个核;而 native 的 `--parent-pid` 看门狗只覆盖
"本服务拉起的实例",浏览器模式关窗后服务还活着、复用现成实例时 parent
又是别人,两种常见离开方式都够不着。要点:
- 断开感知靠 TCP FIN / Close 帧回显,**不是心跳计时器** —— 后台标签页的
定时器节流不会把"挂着后台的 app"误判成已关闭(服务端必须回 Close 回显,
否则浏览器拆不掉 TCP,Edge 实测踩过);
- 断连后隔 3s GRACE 才停:F5 刷新只断几百毫秒,不该被当成关窗;
- 外壳内切标签不停(`tabs.html` 自己持有租约),与 `transport-ecat.teardown`
"切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致;
- 只停"本服务经代理确认跑过"的循环(`g_ecat_motor_live` 代理记账),
直接从 ecat 控制台(:8090)起的循环不动;
- 服务进程自己的退出路径(`/api/shutdown`、Ctrl+C)同步补停一次 ——
租约定时器随进程死亡,等不到 GRACE 到点。
代价:每次重开 app 都要重走 PREOP→SAFEOP→OP,有概率撞 SSC 邮箱楔死
(见下一段)—— 这是"关闭必须停"这个要求本身的代价,不是机制缺陷。
自测:`python scripts/test_app_lease.py`(服务端逻辑,裸 TCP 全路径)+
`node scripts/test_app_lease_e2e.mjs`(真 Edge headless + 真服务器,
假后端钉在 8091,绝不碰真总线)。
- 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示 - 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。 (`summarizeLinkError`),完整诊断放在两者的 `title` 里。
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成 典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
@@ -320,17 +349,17 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
其覆盖结论记录在 `tools/ecat/MIGRATION.md`。 其覆盖结论记录在 `tools/ecat/MIGRATION.md`。
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端, - `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。 不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(53 项, - `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(55 项,
纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、 纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、
两链路互不污染、不限速谓词边界)。 存量 1ms 钳到 6ms、两链路互不污染、不限速谓词边界)。
- 浏览器实测(需 headless Chrome/Edge 开 `--remote-debugging-port=9333`, - 浏览器实测(需 headless Chrome/Edge 开 `--remote-debugging-port=9333`,
页面服务端口可作第一个参数传入,默认 8080): 页面服务端口可作第一个参数传入,默认 8080):
`node scripts/browser_check_refresh.mjs [port]` 验 DOM 回填与 `node scripts/browser_check_refresh.mjs [port]` 验 DOM 回填与
跨刷新持久化(22 项);`node scripts/browser_check_tick.mjs [port]` 数真实请求频率, 跨刷新持久化(22 项);`node scripts/browser_check_tick.mjs [port]` 数真实请求频率,
确认切链路后**实际节拍**跟着变而不只是 state 变了(9 项,实测 20.0/2.0 Hz); 确认切链路后**实际节拍**跟着变而不只是 state 变了(9 项,实测 20.0/2.0 Hz);
`node scripts/browser_check_1ms.mjs [port]` 验 ECAT 1ms 档(24 项:`min`/`step` 同步、 `node scripts/browser_check_floor.mjs [port]` 验 ECAT 下限档 6ms(26 项:
通过浏览器原生校验、实测 124 Hz、无卡顿黄标、面板 Hz 不谎报、 `min`/`step` 同步、通过浏览器原生校验、实测 ≈160 Hz、无卡顿黄标、
切 WiFi 时 1ms 被钳到 50)。 面板 Hz 不谎报、硬填旧的 1ms 被抬到 6ms、切 WiFi 时 6ms 被钳到 50)。
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项: `node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示 `setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后 `PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
+6
View File
@@ -723,6 +723,10 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent); border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root)); background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root));
} }
.seq-step.sending {
border-color: color-mix(in srgb, var(--ok) 55%, transparent);
background: color-mix(in srgb, var(--ok) 5%, var(--bg-root));
}
.seq-locked .seq-step { opacity: .55; pointer-events: none; } .seq-locked .seq-step { opacity: .55; pointer-events: none; }
.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); } .seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; } .seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; }
@@ -754,6 +758,8 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
} }
.seq-ops button svg { flex-shrink: 0; } .seq-ops button svg { flex-shrink: 0; }
.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); } .seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); }
.seq-ops button.seq-send-one { color: var(--ok); }
.seq-ops button.seq-send-one:hover:not(:disabled) { background: var(--ok-dim); color: var(--ok); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); } .seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; } .seq-ops button:disabled { opacity: .25; cursor: default; }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); } .seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
+217 -5
View File
@@ -8,6 +8,8 @@
GET / → 302 到 /tabs.html (标签外壳, 工具主页) GET / → 302 到 /tabs.html (标签外壳, 工具主页)
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/) GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
GET /ws/app-lease → app 窗口租约 WebSocket: 所有 app 窗口断开后自动
停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出) POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径> POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
@@ -32,8 +34,10 @@ pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTM
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。 _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
""" """
import argparse import argparse
import base64
import collections import collections
import ctypes import ctypes
import hashlib
import json import json
import os import os
import re import re
@@ -118,13 +122,13 @@ g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务
# 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /api/motor/*, # 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /api/motor/*,
# 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin, # 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin,
# 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON, # 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON,
# 属非简单请求, 浏览器必发预检 → 从本页面(:8080)直接 fetch 它(:8090)必然失败。 # 属非简单请求, 浏览器必发预检 → 从本页面(:2625)直接 fetch 它(:8090)必然失败。
# #
# 三种解法里选反向代理: # 三种解法里选反向代理:
# 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改; # 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改;
# 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让 # 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让
# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求; # WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求;
# 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。 # 3) 本服务代理 —— 页面永远同源(:2625), 两个仓库都不用动。
# 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。 # 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。
ECAT_PROXY_PREFIX = "/ecat/api/" ECAT_PROXY_PREFIX = "/ecat/api/"
ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095) ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095)
@@ -224,10 +228,13 @@ def _ecat_autostart():
try: try:
# --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后 # --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后
# 自我了断(不留孤儿占 Npcap/端口)。 # 自我了断(不留孤儿占 Npcap/端口)。
# --mask 0x10: PDO 自旋线程钉 LP4(i7-14700K P核2), 避开 DPC 集中的
# 核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。不钉的话
# 自旋线程随调度器漂移, 1ms 档周期抖动明显放大。
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。 # cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
g_ecat_child = subprocess.Popen( g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", "I226", "--web", "src/web", [ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10",
"--parent-pid", str(os.getpid())], "--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR, cwd=ECAT_DIR,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
@@ -347,6 +354,127 @@ def _ecat_proxy(path, body, timeout=6.0):
return 503, body return 503, body
# ── app 窗口租约: "关掉 app 就必须停掉从站" 的兜底 ────────────────────────
# PDO 循环起来后是在 ecat_native 里一直跑的: 从站侧 1000ms 过程数据看门狗
# 要求持续喂, 而 native 的自清理只覆盖"我拉起的进程死掉"这一种情况。用户
# 最常遇到的离开方式恰恰不走那条路:
# - 浏览器模式(esp32_web.vbs / serve.bat): 关掉浏览器窗口后本服务还在,
# 循环就一直空转 —— 从站卡在 OP, 自旋节拍占满一个 P 核, 直到下次手工清场;
# - 窗口模式复用现成 native(如之前用 ecat_web.vbs 起过): 关窗只结束本服务的
# 子进程, native 的 parent 是别人, 不会替它清场。
#
# 解法: 每个 app 页面与本页服务器保持一条 WebSocket 连接
# (js/common/app-lease.js → GET /ws/app-lease), 连接在 = "还有 app 窗口开着":
# - 不用心跳计时器: 断开靠 TCP FIN 感知, 浏览器后台标签页的定时器节流
# 弄不死一条已建立的连接, 也就不会在后台开着 app 时误停从站;
# - 外壳内切标签不断连(外壳 tabs.html 自己也持有连接),
# 与 transport-ecat.teardown "切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致;
# - 最后一条断开后**隔 GRACE 再停**: F5 刷新的断连→重连只有几百毫秒,
# 不留这个窗口会把"刷新页面"误判成"关闭 app"(每次误停都要重进 OP,
# 还平白多撞一次 SSC 邮箱楔死的风险);
# - 只停"我们确认跑过"的循环: g_ecat_motor_live 仅在本服务代理过成功的
# /motor/start 时置位, 有人直接从 ecat 控制台(:8090)开的循环不受影响。
#
# 停法用 /api/op/stop(内部先 loop_stop 再把从站写回 INIT): 确定性地"从站已停",
# 不留"等 1s 看门狗把自己踢出 OP"的中间态; 下次 motor/start 本来就要现读
# AL 状态重走 PREOP→SAFEOP→OP, 提前回 INIT 不增加额外代价。
APP_LEASE_PATH = "/ws/app-lease"
APP_LEASE_GRACE_S = 3.0 # 断连→重连的吸收窗口(页面刷新 / 新窗口接手)
_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 握手固定常量
g_app_leases = 0 # 当前挂着的租约连接数
g_app_lock = threading.Lock()
g_ecat_motor_live = False # 本服务确认过"PDO 循环在跑"(经代理观测)
def _ws_accept_key(key):
"""Sec-WebSocket-Accept = base64(SHA1(key + magic)), RFC 6455。"""
return base64.b64encode(
hashlib.sha1((key + _WS_MAGIC).encode("ascii")).digest()).decode("ascii")
def _app_lease_release():
global g_app_leases
with g_app_lock:
g_app_leases = max(0, g_app_leases - 1)
empty = g_app_leases == 0
if empty:
_schedule_loop_release()
def _schedule_loop_release(attempt=0):
"""GRACE 秒后若仍无一条租约连接且循环在跑, 停掉它。
多个断连各自起一个定时器没关系 —— _check 里现读现判, 条件不满足就静默退出。
"""
def _check():
with g_app_lock:
if g_app_leases > 0:
return # 有窗口重连上了(F5/新窗口), 本次释放作废
_ecat_stop_motor_loop(attempt)
t = threading.Timer(APP_LEASE_GRACE_S if attempt == 0 else 3.0, _check)
t.daemon = True
t.start()
def _ecat_stop_motor_loop(attempt, why="app 全部窗口已关闭"):
"""经代理让后端停循环 + 从站退回 INIT。幂等, 失败最多再试 2 轮。"""
global g_ecat_motor_live
with g_app_lock:
if not g_ecat_motor_live:
return
port = _ecat_find_port()
if port is None:
with g_app_lock:
g_ecat_motor_live = False # 后端本来就不在了, 没东西可停
return
ok = False
try:
status, data = _ecat_post(port, "/api/op/stop", b"{}", 5.0)
ok = status == 200 and _json_ok(data)
except Exception:
pass
if ok:
with g_app_lock:
g_ecat_motor_live = False
print(f"[lease] {why} → EtherCAT PDO 循环已停, 从站退回 INIT", flush=True)
return
if attempt < 2:
_schedule_loop_release(attempt + 1) # 后端可能正忙(持有 bus 锁), 稍后再试
else:
with g_app_lock:
g_ecat_motor_live = False
print("[lease] 警告: app 已关闭但 EtherCAT 循环停止失败"
"(服务无响应?), 请手工关闭 EtherCAT 服务", flush=True)
def _json_ok(data):
"""后端信封 ok:true 的判定。不能用子串匹配: json.dumps 会写成 "ok": true
(带空格), 真 native 是 C 打印的紧凑格式 —— 两种都得认, 低频路由正经解析一次。"""
try:
return json.loads(data.decode("utf-8", "replace")).get("ok") is True
except Exception:
return False
def _note_ecat_loop(path, status, body):
"""代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。
判定只针对低频路由(motor/state 那种高频轮询不在表内, 不进这里分支)。
"""
global g_ecat_motor_live
tail = path[len(ECAT_PROXY_PREFIX):]
if tail == "motor/start":
target, flag = True, True
elif tail in ("motor/stop", "op/stop", "op/start", "open", "close", "shutdown"):
target, flag = True, False
else:
return
if status == 200 and _json_ok(body): # 后端 200 + ok:true 才算数
with g_app_lock:
g_ecat_motor_live = flag
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
server_version = "Esp32Tools/1.0" server_version = "Esp32Tools/1.0"
@@ -363,6 +491,9 @@ class Handler(SimpleHTTPRequestHandler):
def do_GET(self): def do_GET(self):
path = self.path.split("?")[0].lower() path = self.path.split("?")[0].lower()
if path == APP_LEASE_PATH:
self._handle_ws_app_lease()
return
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳 # 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
self.send_response(302) self.send_response(302)
@@ -418,10 +549,78 @@ class Handler(SimpleHTTPRequestHandler):
"/flash", "/eeprom/write", "/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0 "/eeprom/read", "/eeprom/verify")) else 6.0
status, body = _ecat_proxy(path, raw, timeout) status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
self._post_json(body, status) self._post_json(body, status)
return return
self.send_error(404) self.send_error(404)
def _handle_ws_app_lease(self):
"""app 窗口租约 WebSocket(契约见 APP_LEASE_PATH 一节)。
只做握手然后盯断开: 浏览器发来什么都不需要回应(连 WS 帧都不用解析,
读到的字节直接丢弃), 本线程唯一的产出是"断开时调一次 release"。
ThreadingHTTPServer 每连接一线程, 这里每个 app 窗口占一条阻塞在
recv 上的线程, 数量就是窗口数, 可以忽略。
"""
key = self.headers.get("Sec-WebSocket-Key") or ""
if "websocket" not in (self.headers.get("Upgrade") or "").lower() or not key:
self.send_error(400, "此端点仅支持 WebSocket 握手")
return
self.wfile.write(
("HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {_ws_accept_key(key)}\r\n"
"\r\n").encode("ascii"))
self.wfile.flush()
global g_app_leases
with g_app_lock:
g_app_leases += 1
try:
self._ws_pump(self.connection)
except OSError:
pass # 强杀/复位: 连接报错同样算断开
finally:
_app_lease_release()
@staticmethod
def _ws_pump(conn):
"""读掉对端的 WS 帧直到连接结束, 唯一的产出是"断开时返回"。
不能只 recv 丢弃: 浏览器/客户端关连接前会先发 Close 控制帧并**等回显**
才拆 TCP(Edge 实测: 不回则导航走后半条连接挂着, FIN 永远等不到,
租约不释放)。所以 Close(0x8) 必须回一个空 Close, Ping(0x9) 顺手回
Pong(0xA); 文本/二进制帧没有语义, 读掉即可。
"""
rf = conn.makefile("rb")
while True:
hdr = rf.read(2)
if len(hdr) < 2:
return # FIN: 对端已关闭
op = hdr[0] & 0x0F
ln = hdr[1] & 0x7F
if ln == 126:
ln = int.from_bytes(rf.read(2), "big")
elif ln == 127:
ln = int.from_bytes(rf.read(8), "big")
if ln > 65536:
return # 租约通道不该有大帧, 当协议错误断开
mask = rf.read(4) if hdr[1] & 0x80 else b""
payload = rf.read(ln) if ln else b""
if mask and payload:
payload = bytes(c ^ mask[i & 3] for i, c in enumerate(payload))
if op == 0x8: # Close → 回显空 Close, 租约到此为止
try:
conn.sendall(b"\x88\x00")
except OSError:
pass
return
if op == 0x9: # Ping → Pong(原样带回 payload)
try:
conn.sendall(bytes([0x8A, len(payload)]) + payload)
except OSError:
pass
def _post_json(self, body, status=200): def _post_json(self, body, status=200):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -431,6 +630,14 @@ class Handler(SimpleHTTPRequestHandler):
def _shutdown_self(): def _shutdown_self():
# 退出前把"我们启动过的"EtherCAT 循环先停掉。租约断开后的停循环靠 3s 延迟
# 定时器, 而定时器随本进程死亡一起消失; 窗口模式(_stop_child → 本路由)是
# 正常关窗的主路径, 复用的 ecat_native(parent 是别的进程)不会替我们收尾。
# attempt=2 表示尽力一次即止, 不在退出路径上重试磨蹭。
try:
_ecat_stop_motor_loop(2, why="本服务进程退出")
except Exception:
pass
if g_httpd is not None: if g_httpd is not None:
try: try:
g_httpd.shutdown() g_httpd.shutdown()
@@ -520,6 +727,11 @@ def _run_browser(args):
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
# 同 _shutdown_self: 进程要没了, 别让租约定时器带着"停循环"的责任一起死
try:
_ecat_stop_motor_loop(2, why="本服务进程退出")
except Exception:
pass
httpd.server_close() httpd.server_close()
print("\n已退出") print("\n已退出")
@@ -748,7 +960,7 @@ def _gui_bringToFront():
def main(): def main():
ap = argparse.ArgumentParser(description="ESP32 调试控制台页面服务器") ap = argparse.ArgumentParser(description="ESP32 调试控制台页面服务器")
ap.add_argument("--port", type=int, default=8080) ap.add_argument("--port", type=int, default=2625)
ap.add_argument("--no-browser", action="store_true") ap.add_argument("--no-browser", action="store_true")
ap.add_argument("--window", action="store_true", ap.add_argument("--window", action="store_true",
help="pywebview 桌面窗口模式") help="pywebview 桌面窗口模式")
+4
View File
@@ -33,6 +33,7 @@ import {
import { import {
updateStagesFromRelayState, updateSystemStatusUi updateStagesFromRelayState, updateSystemStatusUi
} from './ota/stages.js'; } from './ota/stages.js';
import { startEcatLoadPoller } from './ota/ecat_load.js';
import { import {
handleFileSelect, pickFileViaFSAPI, setupDropZone, handleFileSelect, pickFileViaFSAPI, setupDropZone,
parseIntelHex, isIntelHexText, convertHexFileToBin, parseIntelHex, isIntelHexText, convertHexFileToBin,
@@ -269,6 +270,9 @@ setupDropZone('uploadZoneGd32', 'gd32');
// 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError) // 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError)
startPolling(); startPolling();
// GD32 CPU 负载率(EtherCAT 通道,独立于 /system/status 轮询;页面上无该指标则自动空转)
startEcatLoadPoller();
// IP 输入变化时清零失败计数并立即刷新状态 // IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp'); var ipInput = document.getElementById('espIp');
if (ipInput) { if (ipInput) {
+43
View File
@@ -0,0 +1,43 @@
/**
* app-lease.js — "app 窗口还开着"的凭证。
*
* 与 esp32_server.py 的 /ws/app-lease 保持一条 WebSocket。服务端在
* **所有** 租约连接断开后的宽限期结束时, 自动把 EtherCAT PDO 循环停掉、
* 从站退回 INIT —— 这是"关掉 app 从站必须停"的兜底, 无论 ecat_native
* 是谁拉起的(本服务复用现成实例时, 它的 --parent-pid 看门狗够不着)。
*
* 为什么用 WebSocket 而不是心跳轮询: 已建立的连接不受浏览器后台标签页
* 定时器节流影响(那会把"挂在后台的 app"误判成已关闭), 断开靠 TCP FIN
* 即时感知, 不依赖任何计时器。服务端另有 3s 断连吸收窗口, 页面刷新不会误停。
*
* 接入位置: tabs.html(外壳, 覆盖整个 app 窗口生命周期) 和 pages/motor.html
* (单独打开电机页时它自己就是窗口)。同一窗口里两条连接并存没问题,
* 服务端判据是"连接数归零"。断线自动重连; file:// 直开页面时静默跳过。
*/
(function () {
"use strict";
if (location.protocol !== "http:" && location.protocol !== "https:") return;
var url = (location.protocol === "https:" ? "wss://" : "ws://") +
location.host + "/ws/app-lease";
var retryTimer = null;
var delay = 3000;
function scheduleReconnect() {
if (retryTimer) return;
// 3~30s 递增退避: 服务没起/刚重启时不去刷屏, 起来后最多等半分钟接上。
retryTimer = setTimeout(function () {
retryTimer = null;
delay = Math.min(delay * 2, 30000);
connect();
}, delay);
}
function connect() {
var ws;
try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(); return; }
ws.onopen = function () { delay = 3000; };
ws.onclose = function () { scheduleReconnect(); };
ws.onerror = function () { try { ws.close(); } catch (e) {} };
}
connect();
})();
+233 -95
View File
@@ -332,8 +332,8 @@ function showModal(title, desc) {
$("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); }; $("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); };
$("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); }; $("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); };
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.70) ═══════════════════════ /* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.74) ═══════════════════════
* BO_ STATUS DLC=32,信号起止与字节(Intel @1,除非注明 Motorola @0): * BO_ STATUS DLC=48,信号起止与字节(Intel @1,除非注明 Motorola @0):
* MCU_temp 0|16@1- ×0.01 → B0-1 * MCU_temp 0|16@1- ×0.01 → B0-1
* Motor_temp 16|16@1- ×0.01 → B2-3 * Motor_temp 16|16@1- ×0.01 → B2-3
* U/V/W_cur 32/48/64|16@1- mA → B4-5 / B6-7 / B8-9 * U/V/W_cur 32/48/64|16@1- mA → B4-5 / B6-7 / B8-9
@@ -348,15 +348,18 @@ $("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); i
* test_float 200|32@1- float → B25-28 * test_float 200|32@1- float → B25-28
* test_U16 232|16@1+ → B29-30 * test_U16 232|16@1+ → B29-30
* CPU_Usage 248|8@1+ % → B31 * CPU_Usage 248|8@1+ % → B31
* InitStatus 256|32@1+ → B32-35 初始化错误码位图(0=全部正常)
* *
* 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。 * 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。
*/ */
function decodeStatus(bytes) { function decodeStatus(bytes) {
if (!bytes || bytes.length < 32) return null; if (!bytes || bytes.length < 32) return null;
/* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView */ /* 拷到独立 ArrayBuffer,避免 SharedArrayBuffer/子视图偏移干扰 DataView;
* 36B 覆盖 v0.74 新增的 InitStatus(B32-35)。载荷不足 36B 说明这条链路根本没
* 送这个字段 —— 必须返回 null 让 UI 显示"—",补 0 会把"无数据"显示成"初始化正常"。 */
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const copy = new Uint8Array(32); const copy = new Uint8Array(36);
copy.set(u8.subarray(0, 32)); copy.set(u8.subarray(0, Math.min(36, u8.length)));
const dv = new DataView(copy.buffer); const dv = new DataView(copy.buffer);
const b10 = copy[10]; const b10 = copy[10];
@@ -383,6 +386,7 @@ function decodeStatus(bytes) {
testFloat: f32(25), /* bit200 → B25 */ testFloat: f32(25), /* bit200 → B25 */
testU16: dv.getUint16(29, true), /* bit232 → B29 */ testU16: dv.getUint16(29, true), /* bit232 → B29 */
cpuUsage: copy[31], /* bit248 → B31 */ cpuUsage: copy[31], /* bit248 → B31 */
initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null, /* bit256 → B32-35,0=全部正常;null=这条链路没送 */
}; };
} }
@@ -416,8 +420,8 @@ function decodeConfig(hexStr) {
/** /**
* STATUS 故障位简写(节点卡片右上角,空间紧凑)。 * STATUS 故障位简写(节点卡片右上角,空间紧凑)。
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit)。 * 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit),及 InitStatus(B32-35)。
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3"。 * 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3" / "初始化"。
*/ */
function faultShortParts(d) { function faultShortParts(d) {
if (!d) return []; if (!d) return [];
@@ -426,6 +430,7 @@ function faultShortParts(d) {
if (d.magStatus) parts.push("磁编"); if (d.magStatus) parts.push("磁编");
if (d.thermalStatus) parts.push("过温"); if (d.thermalStatus) parts.push("过温");
if (d.eepromStatus) parts.push("EE" + d.eepromStatus); if (d.eepromStatus) parts.push("EE" + d.eepromStatus);
if (d.initStatus) parts.push("初始化");
return parts; return parts;
} }
function faultShortLabel(d) { function faultShortLabel(d) {
@@ -766,14 +771,14 @@ function applyTransportRefresh() {
state.refreshIntervalMs = v; state.refreshIntervalMs = v;
/* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写, /* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写,
* 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。 * 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。
* min/step 也随链路走:ECAT 允许到 1ms(读本机快照,见 refresh-rate.js), * min/step 也随链路走:ECAT 允许到 6ms(读本机快照,见 refresh-rate.js),
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把 * WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
* 合法的 1ms 标成 :invalid,微调箭头也会停在错误的档位上。 */ * 合法值标成 :invalid,微调箭头也会停在错误的档位上。 */
const el = $("#refreshRate"); const el = $("#refreshRate");
if (el) { if (el) {
const lo = minRefreshFor(state.tpId); const lo = minRefreshFor(state.tpId);
el.min = String(lo); el.min = String(lo);
el.step = lo <= 1 ? "1" : "50"; /* 1ms 档用 1 步进,否则 50 步进太粗 */ el.step = isUnthrottled(lo) ? "1" : "50"; /* 不限速档要能逐 ms 微调 */
el.value = v; el.value = v;
} }
if (!changed) return v; /* 值没变就别白重排节拍 */ if (!changed) return v; /* 值没变就别白重排节拍 */
@@ -1191,10 +1196,10 @@ function tickHandler() {
if (!state.connected || state.probing) return; if (!state.connected || state.probing) return;
if (state.tickInFlight) { if (state.tickInFlight) {
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。 /* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
* 「不限速」档(如 ECAT 的 1ms)下这是**预期行为**:设定间隔本就小于 * 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
* 单次往返耗时,节拍源必然比请求快,每拍都会撞上。此时不计卡顿, * 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
* 否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",而用户要的 * 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
* 恰恰就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */ * 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
if (!isUnthrottled(state.refreshIntervalMs)) { if (!isUnthrottled(state.refreshIntervalMs)) {
state.tickStuck = (state.tickStuck || 0) + 1; state.tickStuck = (state.tickStuck || 0) + 1;
updateTickStuckUi(); updateTickStuckUi();
@@ -1602,8 +1607,9 @@ const RECV_HZ_WINDOW = 10;
function statusHzText() { function statusHzText() {
const ts = state.recvTs; const ts = state.recvTs;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。 /* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
* 「不限速」档要按实测往返而非设定值算这个窗口:1ms × 2 × 10 = 20ms, * 「不限速」档要按实测往返而非设定值算这个窗口:6ms × 2 × 10 = 120ms,
* 会把刚到的样本全判成"旧数据"清空,Hz 就永远退回理论值(谎报 1000Hz)。 * 一次尾部抖动(>120ms)就把刚到的样本全判成"旧数据"清空,
* Hz 会永远退回理论值(谎报 166Hz)。
* 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */ * 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS; const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
const maxAge = isUnthrottled(iv) ? 2000 : iv * 2 * RECV_HZ_WINDOW; const maxAge = isUnthrottled(iv) ? 2000 : iv * 2 * RECV_HZ_WINDOW;
@@ -1618,7 +1624,7 @@ function statusHzText() {
} }
} }
/* 窗口数据不足时回退到理论值,不让面板空着。 /* 窗口数据不足时回退到理论值,不让面板空着。
* 不限速档没有有意义的理论值(1ms≠1000Hz),显示 "…" 等实测数据到齐。 */ * 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */
if (isUnthrottled(iv)) return "\u2026 Hz"; if (isUnthrottled(iv)) return "\u2026 Hz";
const hz = 1000 / iv; const hz = 1000 / iv;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
@@ -1640,10 +1646,27 @@ function updateNodeLabels() {
} }
/* ═══════════════════════ STATUS 面板渲染 ═══════════════════════ */ /* ═══════════════════════ STATUS 面板渲染 ═══════════════════════ */
/* 值变了才写 DOM。轮询下限 6ms(≈160Hz)下,无条件的 textContent/className/style
* 覆写每一拍都会把整片面板标脏,样式重算和重绘全程满载 —— 而其中绝大多数拍
* 写的值和上一拍一模一样。左侧节点列表早就是这个写法,面板对齐它。 */
function setTextIfChanged(el, s) { if (el && el.textContent !== s) el.textContent = s; }
function setClassIfChanged(el, c) { if (el && el.className !== c) el.className = c; }
function setStyleIfChanged(el, prop, v) { if (el && el.style[prop] !== v) el.style[prop] = v; }
/* 高亮"续期"而不是每拍重排:160Hz 轮询下每拍都会调这里,若每次都 add + 排一个
* 350ms 的 remove,就是每秒 ~480 个定时器,而且摘与下一拍重新加上之间会留
* 一个可见的空档。改成每次只把 _flashUntil 往后推,收尾定时器发现还在续期
* 窗口内就自己改期重排 —— 数据在流期间 class 一直挂着,停流 350ms 后才摘。 */
function flashEl(el) { function flashEl(el) {
if (!el) return; if (!el) return;
el._flashUntil = nowMs() + 350;
if (el.classList.contains("flash")) return;
el.classList.add("flash"); el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 350); setTimeout(function done() {
const left = el._flashUntil - nowMs();
if (left > 0) { setTimeout(done, left); return; } /* 期间被续期,改期再摘 */
el.classList.remove("flash");
}, 350);
} }
function renderStatusPanel() { function renderStatusPanel() {
@@ -1661,81 +1684,95 @@ function renderStatusPanel() {
$("#statusLive").classList.toggle("hide", offline); $("#statusLive").classList.toggle("hide", offline);
if (offline) { if (offline) {
if (!state.connected) { if (!state.connected) {
$("#offlineTitle").textContent = "设备未连接"; setTextIfChanged($("#offlineTitle"), "设备未连接");
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接"; setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接");
} else if (onOtherCh) { } else if (onOtherCh) {
const chs = others.map((o) => "CH" + o.ch).sort().join(" / "); const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
$("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`; setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`);
$("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`; setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`);
} else if (!motor || !motor.online) { } else if (!motor || !motor.online) {
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)"; setTextIfChanged($("#offlineTitle"), n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)");
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写"; setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写");
} else { } else {
$("#offlineTitle").textContent = `节点 ${n} 暂无解码数据`; setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`);
$("#offlineSub").textContent = "等待 STATUS 帧;OTA 刷写不受影响"; setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响");
} }
return; return;
} }
$("#rtAngle").textContent = d.rtAngle.toFixed(2); setTextIfChanged($("#rtAngle"), d.rtAngle.toFixed(2));
$("#rtVelocity").textContent = d.rtVelocity.toFixed(1); setTextIfChanged($("#rtVelocity"), d.rtVelocity.toFixed(1));
$("#rtCurrent").textContent = d.rtCurrent.toFixed(0); setTextIfChanged($("#rtCurrent"), d.rtCurrent.toFixed(0));
$("#mcuTemp").textContent = d.mcuTemp.toFixed(2) + " °C"; setTextIfChanged($("#mcuTemp"), d.mcuTemp.toFixed(2) + " °C");
$("#motTemp").textContent = d.motTemp.toFixed(2) + " °C"; setTextIfChanged($("#motTemp"), d.motTemp.toFixed(2) + " °C");
/* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host), /* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host),
* 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */ * 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */
const cfg = state.decodedConfig.get(motorKey(motor)); const cfg = state.decodedConfig.get(motorKey(motor));
if (cfg) { if (cfg) {
$("#tgtAngle").textContent = cfg.targetAngle.toFixed(1); setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1));
$("#tgtVelocity").textContent = cfg.targetVelocity.toFixed(0); setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0));
$("#tgtCurrent").textContent = cfg.targetCurrent.toFixed(0); setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0));
} else { } else {
const tgt = state.lastSentTargets.get(n); const tgt = state.lastSentTargets.get(n);
if (tgt) { if (tgt) {
const MODES_TARGETS = { 1: ["angle"], 2: ["velocity"], 3: ["velocity", "angle"], 4: ["current", "velocity", "angle"], 5: ["current"], 6: ["velocity"], 7: ["velocity"] }; const MODES_TARGETS = { 1: ["angle"], 2: ["velocity"], 3: ["velocity", "angle"], 4: ["current", "velocity", "angle"], 5: ["current"], 6: ["velocity"], 7: ["velocity"] };
const active = MODES_TARGETS[tgt.mode] || []; const active = MODES_TARGETS[tgt.mode] || [];
const fmtTgt = (id, val, active) => { const el = $("#" + id); if (el) el.textContent = active ? String(val) : "--"; }; const fmtTgt = (id, val, active) => setTextIfChanged($("#" + id), active ? String(val) : "--");
fmtTgt("tgtAngle", tgt.angle.toFixed(1), active.includes("angle")); fmtTgt("tgtAngle", tgt.angle.toFixed(1), active.includes("angle"));
fmtTgt("tgtVelocity", tgt.velocity.toFixed(0), active.includes("velocity")); fmtTgt("tgtVelocity", tgt.velocity.toFixed(0), active.includes("velocity"));
fmtTgt("tgtCurrent", tgt.current.toFixed(0), active.includes("current")); fmtTgt("tgtCurrent", tgt.current.toFixed(0), active.includes("current"));
} else { } else {
$("#tgtAngle").textContent = "--"; setTextIfChanged($("#tgtAngle"), "--");
$("#tgtVelocity").textContent = "--"; setTextIfChanged($("#tgtVelocity"), "--");
$("#tgtCurrent").textContent = "--"; setTextIfChanged($("#tgtCurrent"), "--");
} }
} }
const mcuPct = clampNum(d.mcuTemp, 0, 100); const mcuPct = clampNum(d.mcuTemp, 0, 100);
const motPct = clampNum(d.motTemp, 0, 100); const motPct = clampNum(d.motTemp, 0, 100);
$("#mcuTempBar").style.width = mcuPct + "%"; setStyleIfChanged($("#mcuTempBar"), "width", mcuPct + "%");
$("#mcuTempBar").className = "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : ""); setClassIfChanged($("#mcuTempBar"), "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : ""));
$("#motTempBar").style.width = motPct + "%"; setStyleIfChanged($("#motTempBar"), "width", motPct + "%");
$("#motTempBar").className = "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : ""); setClassIfChanged($("#motTempBar"), "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : ""));
$("#valU").textContent = `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`; setTextIfChanged($("#valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`);
$("#valV").textContent = `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`; setTextIfChanged($("#valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`);
$("#valW").textContent = `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`; setTextIfChanged($("#valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`);
$("#pwrVolt").textContent = d.powerVoltage.toFixed(3) + " V"; setTextIfChanged($("#pwrVolt"), d.powerVoltage.toFixed(3) + " V");
$("#testFloat").textContent = d.testFloat.toFixed(3); setTextIfChanged($("#testFloat"), d.testFloat.toFixed(3));
$("#testU16").textContent = String(d.testU16); setTextIfChanged($("#testU16"), String(d.testU16));
$("#cpuPct").textContent = Math.round(d.cpuUsage) + "%"; setTextIfChanged($("#cpuPct"), Math.round(d.cpuUsage) + "%");
$("#cpuSub").textContent = `seq ${motor.seq} · rx ${motor.rx_count}`; setTextIfChanged($("#cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`);
const circumference = 131.9; const circumference = 131.9;
$("#cpuRingFg").style.strokeDashoffset = (circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1); setStyleIfChanged($("#cpuRingFg"), "strokeDashoffset",
(circumference * (1 - clampNum(d.cpuUsage, 0, 100) / 100)).toFixed(1));
/* 状态标志位 */ /* 状态标志位 */
const setFlag = (itemId, ledId, stateId, active, isFault, okText, faultText) => { const setFlag = (itemId, ledId, stateId, active, isFault, okText, faultText) => {
$("#" + itemId).className = "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : ""); setClassIfChanged($("#" + itemId), "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : ""));
$("#" + ledId).className = "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : ""); setClassIfChanged($("#" + ledId), "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : ""));
$("#" + stateId).textContent = active ? (isFault ? faultText : okText) : "—"; setTextIfChanged($("#" + stateId), active ? (isFault ? faultText : okText) : "—");
}; };
/* motStatus:DBC 0=正常 / 1=报错(不是使能位) */ /* motStatus:DBC 0=正常 / 1=报错(不是使能位) */
setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT"); setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT"); setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT");
setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!"); setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!");
setFlag("flagItemEeprom", "flagEeprom", "flagEepromState", true, d.eepromStatus !== 0, "NORMAL", "ERR=" + d.eepromStatus); setFlag("flagItemEeprom", "flagEeprom", "flagEepromState", true, d.eepromStatus !== 0, "NORMAL", "ERR=" + d.eepromStatus);
/* InitStatus(v0.74 STATUS B32-35):初始化错误码位图,0=全部正常;
* 含电流检测配平超限/无采样帧等,详见 config.h INIT_ERR_* */
const INIT_ERR_NAMES = ["角度传感器", "ADC", "CAN", "角度滤波", "速度滤波", "FOC", "电流配平", "电流无帧", "门驱动"];
let initFaultTxt = "";
if (d.initStatus) {
const names = [];
for (let i = 0; i < INIT_ERR_NAMES.length; i++) {
if (d.initStatus & (1 << i)) names.push(INIT_ERR_NAMES[i]);
}
initFaultTxt = names.length ? names.join("·") : "0x" + (d.initStatus >>> 0).toString(16);
}
setFlag("flagItemInit", "flagInit", "flagInitState", d.initStatus !== null,
d.initStatus !== 0, "NORMAL", initFaultTxt);
flashEl($("#rtAngle")); flashEl($("#rtVelocity")); flashEl($("#rtCurrent")); flashEl($("#rtAngle")); flashEl($("#rtVelocity")); flashEl($("#rtCurrent"));
} }
@@ -1776,16 +1813,16 @@ function updateChSelectOptions() {
const en = state.chEnabled[i]; const en = state.chEnabled[i];
const cnt = state.chOnline[i] || 0; const cnt = state.chOnline[i] || 0;
const base = "CH" + i; const base = "CH" + i;
opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base); setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base));
opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""); setStyleIfChanged(opt, "color", !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""));
} }
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效), /* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。 * 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。
* 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */ * 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */
const curEn = state.chEnabled[state.ch]; const curEn = state.chEnabled[state.ch];
const curCnt = state.chOnline[state.ch] || 0; const curCnt = state.chOnline[state.ch] || 0;
sel.style.color = (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)" setStyleIfChanged(sel, "color", (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")); : (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")));
} }
function onFeedback(data) { function onFeedback(data) {
@@ -1960,6 +1997,7 @@ const seq = {
round: 1, /* 当前轮(1 起) */ round: 1, /* 当前轮(1 起) */
stepIdx: 0, /* 当前步索引 */ stepIdx: 0, /* 当前步索引 */
statusText: "未运行", statusText: "未运行",
singleIdx: null, /* 正在单发的步骤索引,null = 无单发在途 */
}; };
const seqListEl = $("#seqList"); const seqListEl = $("#seqList");
const seqStartBtn = $("#seqStartBtn"); const seqStartBtn = $("#seqStartBtn");
@@ -2032,6 +2070,9 @@ function renderSeqList() {
<input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="步骤 ${i + 1} 保持时间 ms" /> <input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="步骤 ${i + 1} 保持时间 ms" />
<span class="seq-hold-unit">ms</span> <span class="seq-hold-unit">ms</span>
<div class="seq-ops"> <div class="seq-ops">
<button data-op="sendOne" title="单发本步(仅发送这一步的全部电机动作,不推进序列)" class="seq-send-one">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M4.5 2.8 11 7l-6.5 4.2z" fill="currentColor"/></svg>
</button>
<button data-op="addAction" title="本步添加电机动作(复制最后一个动作)"> <button data-op="addAction" title="本步添加电机动作(复制最后一个动作)">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
@@ -2081,8 +2122,10 @@ function renderSeqList() {
function updateSeqUi() { function updateSeqUi() {
const running = seq.running; const running = seq.running;
const sendingIdx = seq.singleIdx;
seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列"; seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列";
seqStartBtn.classList.toggle("btn-primary", running); seqStartBtn.classList.toggle("btn-primary", running);
seqStartBtn.disabled = !running && sendingIdx != null;
const st = $("#seqStatus"); const st = $("#seqStatus");
if (st) { if (st) {
if (running) { if (running) {
@@ -2090,6 +2133,9 @@ function updateSeqUi() {
const cnt = cs ? cs.actions.length : 0; const cnt = cs ? cs.actions.length : 0;
st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`; st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`;
st.classList.add("on"); st.classList.add("on");
} else if (sendingIdx != null) {
st.textContent = `单发步骤 ${sendingIdx + 1}…`;
st.classList.add("on");
} else { } else {
st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`; st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`;
st.classList.remove("on"); st.classList.remove("on");
@@ -2097,7 +2143,9 @@ function updateSeqUi() {
} }
seqListEl.classList.toggle("seq-locked", running); seqListEl.classList.toggle("seq-locked", running);
seqListEl.querySelectorAll(".seq-step").forEach((row) => { seqListEl.querySelectorAll(".seq-step").forEach((row) => {
row.classList.toggle("current", running && parseInt(row.dataset.idx, 10) === seq.stepIdx); const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle("current", running && idx === seq.stepIdx);
row.classList.toggle("sending", !running && sendingIdx === idx);
}); });
seqLoopsInput.disabled = running; seqLoopsInput.disabled = running;
$("#seqAddBtn").disabled = running; $("#seqAddBtn").disabled = running;
@@ -2142,6 +2190,10 @@ seqListEl.addEventListener("click", (e) => {
const step = seq.steps[i]; const step = seq.steps[i];
if (!step) return; if (!step) return;
const op = btn.dataset.op; const op = btn.dataset.op;
if (op === "sendOne") {
sendSeqStepOnce(i);
return;
}
if (op === "up" && i > 0) { if (op === "up" && i > 0) {
const t = seq.steps[i]; seq.steps[i] = seq.steps[i - 1]; seq.steps[i - 1] = t; const t = seq.steps[i]; seq.steps[i] = seq.steps[i - 1]; seq.steps[i - 1] = t;
} else if (op === "down" && i < seq.steps.length - 1) { } else if (op === "down" && i < seq.steps.length - 1) {
@@ -2194,6 +2246,46 @@ seqStartBtn.addEventListener("click", () => {
startSeq(); startSeq();
}); });
/* 单发一步:只把该步的全部动作经一次 sendBurst 近同时下发,不循环、不推进步骤、不等 holdMs。
* 与序列运行互斥(运行中按钮被 .seq-locked 屏蔽),同一时刻只允许一个单发在途。 */
async function sendSeqStepOnce(i) {
if (seq.running || seq.singleIdx != null) return;
const step = seq.steps[i];
if (!step || !step.actions.length) return;
if (!state.connected) { toast("设备未连接", "err"); return; }
if (inBusCooldown()) return;
const chBad = step.actions.find((a) => !state.chEnabled[a.ch]);
if (chBad) { toast(`步骤 ${i + 1} 的通道 CH${chBad.ch} 已禁用`, "err"); return; }
seq.singleIdx = i;
updateSeqUi();
const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" ");
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: 单发步${i + 1} → ${step.actions.length} 电机 [${targets}] ${time}`;
try {
const res = await sendBurst(step.actions);
if (res.busy) { toast("发送通道忙,请稍后重试", "warn"); return; }
if (!res.ok) { toast(`步骤 ${i + 1} 发送失败:${res.desc || "未知错误"}`, "err"); return; }
for (const a of step.actions) {
state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0);
state.lastSentTargets.set(a.node, {
mode: parseInt(a.mode, 10) || 0,
angle: parseFloat(a.angle) || 0,
velocity: parseFloat(a.velocity) || 0,
current: parseFloat(a.current) || 0,
voltage: parseFloat(a.voltage) || 0,
});
}
state.txCount++;
renderNodeList();
seq.statusText = `已单发步骤 ${i + 1}`;
toast(`步骤 ${i + 1} 已单发 · ${step.actions.length} 电机 [${targets}]`, "ok");
} finally {
seq.singleIdx = null;
updateSeqUi();
}
}
function startSeq() { function startSeq() {
if (!state.connected) { toast("设备未连接", "err"); return; } if (!state.connected) { toast("设备未连接", "err"); return; }
if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; } if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; }
@@ -3272,7 +3364,7 @@ async function sendMotorPeriod(ms) {
} }
function applyRefreshRate() { function applyRefreshRate() {
const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS; const dflt = REFRESH_DEFAULTS[state.tpId] || TICK_INTERVAL_MS;
const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 1ms,WiFi 仍 50ms */ const lo = minRefreshFor(state.tpId); /* 下限按链路:ECAT 到 6ms,WiFi 仍 50ms */
const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS); const v = clampNum(parseInt(refreshRateInput.value, 10) || dflt, lo, REFRESH_MAX_MS);
refreshRateInput.value = v; refreshRateInput.value = v;
state.refreshIntervalMs = v; state.refreshIntervalMs = v;
@@ -3289,8 +3381,8 @@ function applyRefreshRate() {
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */ schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */ sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
} }
/* 极小间隔别报"≈1000 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定 /* 极小间隔别报"≈166 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
* (ECAT 每拍一次 HTTP,浏览器里实测约 125Hz)。这时只说"不限速", * (ECAT 每拍一次 HTTP,6ms 档实测约 160Hz,正好贴着屏幕绘制上限)。这时只说"不限速",
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */ * 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
const hz = 1000 / v; const hz = 1000 / v;
toast(isUnthrottled(v) toast(isUnthrottled(v)
@@ -3329,16 +3421,44 @@ const scopeSys = (() => {
let C = colors(); let C = colors();
setInterval(() => { C = colors(); }, 2000); setInterval(() => { C = colors(); }, 2000);
function fit(canvas) { /* 尺寸缓存:fit 每帧调一次 getBoundingClientRect = 每秒 160 次强制同步布局。
const dpr = dprOf(); * 改成 ResizeObserver 只在真变化时量一次;borderBoxSize 与原来的
* getBoundingClientRect 同口径(迷你波形的 canvas 有 1px 边框,
* 用 contentRect 会让几何悄悄缩 2px)。 */
const sizes = new WeakMap();
let sizeObs = null;
function cssSize(canvas) {
let s = sizes.get(canvas);
if (s) return s;
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
if (r.width === 0) return false; s = { w: r.width, h: r.height };
if (canvas.width !== Math.round(r.width * dpr) || canvas.height !== Math.round(r.height * dpr)) { sizes.set(canvas, s);
canvas.width = Math.round(r.width * dpr); if (!sizeObs) {
canvas.height = Math.round(r.height * dpr); sizeObs = new ResizeObserver((entries) => {
for (const e of entries) {
const t = sizes.get(e.target);
if (!t) continue;
const b = e.borderBoxSize && e.borderBoxSize[0];
t.w = b ? b.inlineSize : e.contentRect.width;
t.h = b ? b.blockSize : e.contentRect.height;
}
});
} }
canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0); sizeObs.observe(canvas);
return true; return s;
}
/** 对齐 backing store 与 CSS 尺寸;返回 CSS 尺寸,宽度为 0(未显示)时返回 null。 */
function fit(canvas, ctx) {
const dpr = dprOf();
const s = cssSize(canvas);
if (s.w === 0) return null;
const cw = Math.round(s.w * dpr), ch = Math.round(s.h * dpr);
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return s;
} }
function grid(ctx, w, h) { function grid(ctx, w, h) {
ctx.strokeStyle = "rgba(255,255,255,0.05)"; ctx.strokeStyle = "rgba(255,255,255,0.05)";
@@ -3351,34 +3471,40 @@ const scopeSys = (() => {
/* 分段描线:valid 样本连成实线(发光 + 可选基线填充);无数据样本(离线 /* 分段描线:valid 样本连成实线(发光 + 可选基线填充);无数据样本(离线
* 补零段)画虚线、无发光无填充 —— 数据停更一眼可辨。相邻样本任一无效, * 补零段)画虚线、无发光无填充 —— 数据停更一眼可辨。相邻样本任一无效,
* 该连线即按虚线处理;边界点同时属于两段,段间不脱节。 */ * 该连线即按虚线处理;边界点同时属于两段,段间不脱节。 */
/* 每帧 new 300 个点对象是 160Hz 下最大的分配源(一次全量重绘 ≈2400 个对象,
* 也正是工作集一路涨到几 GB 的原因)。改成把 y 写进复用的 scratch 数组,
* x 因为等间距直接算出来,不再存。 */
const scratchY = new Float32Array(BUF);
const okAt = (i) => valid[(head + i) % BUF] === 1;
function drawTrace(ctx, buf, color, yOf, base, w, lw, fill) { function drawTrace(ctx, buf, color, yOf, base, w, lw, fill) {
const pts = []; const xOf = (i) => i / (BUF - 1) * w;
for (let i = 0; i < BUF; i++) { for (let i = 0; i < BUF; i++) scratchY[i] = yOf(buf[(head + i) % BUF]);
const idx = (head + i) % BUF;
pts.push({ x: i / (BUF - 1) * w, y: yOf(buf[idx]), ok: valid[idx] === 1 });
}
const linePath = (a, b) => { const linePath = (a, b) => {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(pts[a].x, pts[a].y); ctx.moveTo(xOf(a), scratchY[a]);
for (let i = a + 1; i <= b; i++) ctx.lineTo(pts[i].x, pts[i].y); for (let i = a + 1; i <= b; i++) ctx.lineTo(xOf(i), scratchY[i]);
}; };
ctx.lineWidth = lw; ctx.lineJoin = "round"; ctx.strokeStyle = color; ctx.lineJoin = "round"; ctx.strokeStyle = color;
let start = 0; let start = 0;
while (start < BUF - 1) { while (start < BUF - 1) {
const solid = pts[start].ok && pts[start + 1].ok; const solid = okAt(start) && okAt(start + 1);
let end = start + 1; let end = start + 1;
while (end + 1 < BUF && (pts[end].ok && pts[end + 1].ok) === solid) end++; while (end + 1 < BUF && (okAt(end) && okAt(end + 1)) === solid) end++;
if (solid && fill) { if (solid && fill) {
linePath(start, end); linePath(start, end);
ctx.lineTo(pts[end].x, base); ctx.lineTo(pts[start].x, base); ctx.closePath(); ctx.lineTo(xOf(end), base); ctx.lineTo(xOf(start), base); ctx.closePath();
ctx.fillStyle = color + "16"; ctx.fillStyle = color + "16";
ctx.fill(); ctx.fill();
} }
linePath(start, end); linePath(start, end);
ctx.setLineDash(solid ? [] : [5, 4]); ctx.setLineDash(solid ? [] : [5, 4]);
ctx.shadowColor = color; ctx.shadowBlur = solid ? 5 : 0; /* 发光:同一条路径先描一道宽而淡的底。原来的 shadowBlur 是逐像素模糊,
ctx.stroke(); * 视觉上两者等价,成本差一个量级。 */
ctx.shadowBlur = 0; if (solid) {
ctx.globalAlpha = 0.16; ctx.lineWidth = lw * 3.2; ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.lineWidth = lw; ctx.stroke();
start = end; start = end;
} }
} }
@@ -3395,8 +3521,9 @@ const scopeSys = (() => {
const big = $("#scopeCanvas"); const big = $("#scopeCanvas");
const bigCtx = big.getContext("2d"); const bigCtx = big.getContext("2d");
function drawBig() { function drawBig() {
if (!fit(big)) return; const s = fit(big, bigCtx);
const w = big.width / dprOf(), h = big.height / dprOf(); if (!s) return;
const w = s.w, h = s.h;
bigCtx.clearRect(0, 0, w, h); bigCtx.clearRect(0, 0, w, h);
grid(bigCtx, w, h); grid(bigCtx, w, h);
trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false); trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false);
@@ -3415,7 +3542,13 @@ const scopeSys = (() => {
mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v }, mcuTemp: { el: $("#miniMcuTemp"), buf: hist.mcuTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.v },
motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w }, motTemp: { el: $("#miniMotTemp"), buf: hist.motTemp, uni: { min: 20, max: 100 }, ticks: [100, 60, 20], unit: "\u00B0C", color: () => C.w },
}; };
for (const m of Object.values(MINIS)) m.ctx = m.el.getContext("2d"); /* ctx 与宿主卡片引用都在初始化时取一次:卡片 DOM 是静态的,
* 原来每帧每个 mini 都要 querySelector 一遍(6 个查询 × 30 次/s)。 */
const MINI_LIST = Object.entries(MINIS).map(([sig, m]) => {
m.ctx = m.el.getContext("2d");
m.card = document.querySelector(`[data-signal="${sig}"]`);
return m;
});
const MONO = '"Cascadia Code","Fira Code",Consolas,monospace'; const MONO = '"Cascadia Code","Fira Code",Consolas,monospace';
const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v)); const fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v));
@@ -3492,8 +3625,9 @@ const scopeSys = (() => {
} }
function drawMini(m) { function drawMini(m) {
if (!fit(m.el)) return; const s = fit(m.el, m.ctx);
const w = m.el.width / dprOf(), h = m.el.height / dprOf(); if (!s) return;
const w = s.w, h = s.h;
/* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */ /* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */
if (m.auto) { if (m.auto) {
const sc = autoScale(m.buf); const sc = autoScale(m.buf);
@@ -3546,13 +3680,17 @@ const scopeSys = (() => {
} }
let lastSample = 0; let lastSample = 0;
/* 只有写入了新样本才重绘:采样节流在 33ms(≈30Hz),而 rAF 跟着显示器跑到
* 160Hz —— 中间那 ~130 帧画的是同一份数据,像素一模一样,纯属白烧 GPU 进程。 */
let dirty = true;
function loop(ts) { function loop(ts) {
/* 采样节流到 ~30Hz,避免 1Hz 数据下过度密集 */ if (ts - lastSample > 33) { sample(); lastSample = ts; dirty = true; }
if (ts - lastSample > 33) { sample(); lastSample = ts; } if (dirty) {
drawBig(); dirty = false;
for (const [sig, m] of Object.entries(MINIS)) { drawBig();
const card = document.querySelector(`[data-signal="${sig}"]`); for (const m of MINI_LIST) {
if (card && card.classList.contains("open")) drawMini(m); if (m.card && m.card.classList.contains("open")) drawMini(m);
}
} }
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
+11 -6
View File
@@ -7,10 +7,14 @@
* *
* 为什么两条链路不能共用一个值:往返代价差一个数量级。 * 为什么两条链路不能共用一个值:往返代价差一个数量级。
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐; * wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
* ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms), * ecat 50ms(下限 6ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
* PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗), * PDO 总线周期由后台循环独立维持(默认 4ms,喂 1000ms 看门狗),
* 界面读得快只是显示更跟手,不给总线加负担。 * 界面读得快只是显示更跟手,不给总线加负担。
* 1ms 的语义是「不限速」而非 1000Hz,见 UNTHROTTLED_MAX_MS。 * 6ms 下限的来由:实测 1ms 档能跑到 ~550Hz,而画面绘制上限由显示器
* 刷新率封顶(本机 160Hz),160Hz 以上那部分轮询没有消费者,
* 纯粹是 HTTP 过路费(每拍 ≈1.6KB 快照,renderer / 网络服务 /
* browser / Python 代理各被点一次)。填 6 的语义仍是「不限速」,
* 见 UNTHROTTLED_MAX_MS。
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队 * 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。 * (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
*/ */
@@ -22,14 +26,15 @@ export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同: * 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求 * wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
* 排队积压(tickStuck 亮黄),毫无收益。 * 排队积压(tickStuck 亮黄),毫无收益。
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。 * ecat 6ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍, * 注意 6ms **不等于** 166Hz:单飞语义下上一拍完成才发下一拍,
* 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值 * 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是 * ≈ 1.8ms,所以 6ms 档实测约 160Hz —— 正好贴着本机 160Hz 屏的
* 绘制上限,再快只是多收一份 HTTP 过路费。填 6 的语义仍是
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示, * "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
* 见 UNTHROTTLED_MAX_MS。 * 见 UNTHROTTLED_MAX_MS。
*/ */
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 }; export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 6 };
/** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */ /** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */
export const REFRESH_MIN_MS = 50; export const REFRESH_MIN_MS = 50;
export const REFRESH_MAX_MS = 5000; export const REFRESH_MAX_MS = 5000;
+25 -10
View File
@@ -32,21 +32,22 @@
*/ */
"use strict"; "use strict";
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致) */ /** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致;
const MOT_SLOTS_PER_PDO = 7; * 固件 1.6.4 起槽长 56B = 8B 头 + 48B STATUS,段 2 的 280B 正好放 5 个) */
const MOT_SLOTS_PER_PDO = 5;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */ /** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */
const MOTOR_SLOT_COUNT = 27; const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/7⌉ = 4)。 /** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/5⌉ = 6)。
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 —— * 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 ——
* 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机 * 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */ * 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 4; const MOTOR_SCAN_CYCLES = 6;
/** /**
* 槽 seq 停止增长多久算离线(ms)。 * 槽 seq 停止增长多久算离线(ms)。
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失, * 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则 * 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。 * 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。
*/ */
const STALE_OFFLINE_MS = 2000; const STALE_OFFLINE_MS = 2000;
@@ -86,10 +87,10 @@ function fmtPeriod(ms) {
export function createEcatTransport(ctx) { export function createEcatTransport(ctx) {
const { recordSlow, isTransient } = ctx; const { recordSlow, isTransient } = ctx;
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226); /* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I210,2026-09 网卡 I226→I210);
* 存 localStorage 以便换网卡后不用每次重设。 */ * 存 localStorage 以便换网卡后不用每次重设。 */
const LS_ADAPTER = "dcu_ecat_adapter"; const LS_ADAPTER = "dcu_ecat_adapter";
let adapterSubstr = "I226"; let adapterSubstr = "I210";
try { try {
const saved = localStorage.getItem(LS_ADAPTER); const saved = localStorage.getItem(LS_ADAPTER);
if (saved && saved.trim()) adapterSubstr = saved.trim(); if (saved && saved.trim()) adapterSubstr = saved.trim();
@@ -119,8 +120,13 @@ export function createEcatTransport(ctx) {
const LS_SPIN = "dcu_ecat_spin"; const LS_SPIN = "dcu_ecat_spin";
/* 保存的 PDO 周期(ms,允许亚毫秒 float,如 0.25=4kHz)。用户把周期调到 /* 保存的 PDO 周期(ms,允许亚毫秒 float,如 0.25=4kHz)。用户把周期调到
* 内核级档位后存这儿,后端重启、循环重开时原样带回 —— 用户要的 * 内核级档位后存这儿,后端重启、循环重开时原样带回 —— 用户要的
* "后续随时可用"不该每次手敲一遍。恢复默认 = setPdoPeriod(4)。 */ * "后续随时可用"不该每次手敲一遍。恢复默认 = 删掉这个键,跟随后端默认。 */
const LS_PERIOD = "dcu_ecat_period_ms"; const LS_PERIOD = "dcu_ecat_period_ms";
/* LS_PERIOD 的一次性迁移标记:Python 时代默认 4ms 曾被存进来(检查脚本
* browser_check_period.mjs 收尾就写 "4"),native 后端默认已改小,
* 存量 "4" 会在每次 ensureStarted 把总线压回旧默认 4ms。只删一次,之后
* 再设 4 视为用户显式选择,原样保留。 */
const LS_PERIOD_MIG = "dcu_ecat_period_mig";
let started = false; // 后台循环是否已启动 let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端) let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
@@ -188,7 +194,7 @@ export function createEcatTransport(ctx) {
async function ensureOpen() { async function ensureOpen() {
const st = await api("status", {}, 6000); const st = await api("status", {}, 6000);
if (st && st.ok === true) return st; // 已连接 if (st && st.ok === true) return st; // 已连接
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226); /* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */ * 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
const r = await api("open", { substr: adapterSubstr }, 20000); const r = await api("open", { substr: adapterSubstr }, 20000);
if (!r || r.ok !== true) { if (!r || r.ok !== true) {
@@ -212,6 +218,12 @@ export function createEcatTransport(ctx) {
if (reqPeriodMs) body.period_ms = reqPeriodMs; if (reqPeriodMs) body.period_ms = reqPeriodMs;
else { else {
try { try {
if (!localStorage.getItem(LS_PERIOD_MIG)) {
if (parseFloat(localStorage.getItem(LS_PERIOD)) === 4) {
localStorage.removeItem(LS_PERIOD);
}
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD)); const v = parseFloat(localStorage.getItem(LS_PERIOD));
if (v > 0) body.period_ms = v; if (v > 0) body.period_ms = v;
} catch (_) { /* 隐私模式忽略 */ } } catch (_) { /* 隐私模式忽略 */ }
@@ -483,6 +495,9 @@ export function createEcatTransport(ctx) {
* 切走本传输:**故意不停后台 PDO 循环**。 * 切走本传输:**故意不停后台 PDO 循环**。
* 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B; * 从站 SM2 有 1000ms 过程数据看门狗,停超 1 秒会 OP→SAFEOP 掉线报 AL 0x001B;
* 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。 * 用户只是切到 WiFi 看看,不该把 EtherCAT 链路打断。
* 真正该停的时机是"整个 app 关掉"—— 由服务端的 app 租约负责:
* 最后一个 app 窗口断开后 esp32_server 会调 /api/op/stop 停循环并退回 INIT,
* 这里(页面内)什么都不用做。
*/ */
async teardown() { started = false; lastSnapshot = null; }, async teardown() { started = false; lastSnapshot = null; },
@@ -536,7 +551,7 @@ export function createEcatTransport(ctx) {
* 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。 * 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。
* *
* 取值存 localStorage:后端重启后 ensureStarted 会原样带回。 * 取值存 localStorage:后端重启后 ensureStarted 会原样带回。
* 恢复默认:tp.setPdoPeriod(4)。 * 恢复默认:删掉 dcu_ecat_period_ms,跟随后端默认(固件修好前别低于 1ms)。
*/ */
async setPdoPeriod(ms) { async setPdoPeriod(ms) {
const v = Number(ms); const v = Number(ms);
+17 -8
View File
@@ -3,7 +3,7 @@
* *
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改: * 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
* GET /system/status 探测 + 健康检查(lite/full 交替) * GET /system/status 探测 + 健康检查(lite/full 交替)
* GET /motor/state?bin=1 电机状态(二进制,~408B) * GET /motor/state?bin=1 电机状态(二进制,27 槽满 ~2508B)
* GET /can/feedback?lite=1 CAN 计数/总线状态 * GET /can/feedback?lite=1 CAN 计数/总线状态
* POST /can/send_batch 发送(唯一入口) * POST /can/send_batch 发送(唯一入口)
* POST /can/send_burst 突发同时发送(多轴) * POST /can/send_burst 突发同时发送(多轴)
@@ -52,6 +52,10 @@ export function createWifiTransport(ctx) {
} }
/* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。 /* /motor/state?bin=1 二进制解析:协议见 ESP32 motor_state_handler。
* 双版本兼容(设备固件与前端升级节奏不一定同步):
* ver=1 v0.70:entry 76,data 32(CONFIG 在 44/48/52)
* ver=2 v0.74:entry 92,data 48(字节 32~35 = InitStatus,CONFIG 后移到 60/64/68)
* data 32B 时 decodeStatus 高位补 0 → initStatus=0,上层无需感知版本。
* 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。 * 返回与原 JSON 等价的对象 {have_data, age_ms, active_count, motors[]}。
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */ * data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
function parseMotorStateBinary(ab) { function parseMotorStateBinary(ab) {
@@ -59,7 +63,12 @@ export function createWifiTransport(ctx) {
if (!ab || ab.byteLength < 12) return out; if (!ab || ab.byteLength < 12) return out;
const dv = new DataView(ab); const dv = new DataView(ab);
if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */ if (dv.getUint16(0, true) !== 0x4D53) return out; /* 'MS' */
if (dv.getUint8(2) !== 1) return out; /* ver */ const ver = dv.getUint8(2);
if (ver !== 1 && ver !== 2) return out;
const dataLen = (ver === 2) ? 48 : 32;
const entry = 12 + dataLen + 8 + 24; /* 92 / 76(4+4+24 = config 段) */
const cfgCntOff = 12 + dataLen; /* config_rx_count 相对 entry 起点 */
const cfgSeqOff = cfgCntOff + 4;
const flags = dv.getUint8(3); const flags = dv.getUint8(3);
const age = dv.getUint32(4, true); const age = dv.getUint32(4, true);
const active = dv.getUint8(8); const active = dv.getUint8(8);
@@ -77,7 +86,7 @@ export function createWifiTransport(ctx) {
for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase(); for (let i = 0; i < len; i++) s += u8[start + i].toString(16).padStart(2, "0").toUpperCase();
return s; return s;
}; };
for (let i = 0; i < count && off + 76 <= ab.byteLength; i++) { for (let i = 0; i < count && off + entry <= ab.byteLength; i++) {
out.motors.push({ out.motors.push({
node: u8[off], node: u8[off],
ch: u8[off + 1], ch: u8[off + 1],
@@ -85,12 +94,12 @@ export function createWifiTransport(ctx) {
dlc: u8[off + 3], dlc: u8[off + 3],
rx_count: dv.getUint32(off + 4, true), rx_count: dv.getUint32(off + 4, true),
seq: dv.getUint32(off + 8, true), seq: dv.getUint32(off + 8, true),
data: hexOf(off + 12, 32), data: hexOf(off + 12, dataLen),
config_rx_count: dv.getUint32(off + 44, true), config_rx_count: dv.getUint32(off + cfgCntOff, true),
config_seq: dv.getUint32(off + 48, true), config_seq: dv.getUint32(off + cfgSeqOff, true),
config_data: hexOf(off + 52, 24), config_data: hexOf(off + cfgSeqOff + 4, 24),
}); });
off += 76; off += entry;
} }
return out; return out;
} }
+94
View File
@@ -0,0 +1,94 @@
/**
* ecat_load.js - GD32(DCU_APP) CPU 负载率显示(EtherCAT 通道,仅 OTA 中继页使用)
*
* 数据来源:DCU_APP 1.5.61+ 把「中断 + 非延迟处理段」的占用率(1.5.63 收窄定义)写进
* EtherCAT 输入 PDO 段 5 诊断区(1s 滚动窗口,0.1% 分辨率),主站
* tools/ecat/ecat_native.exe 解码后由 /api/app/load 提供纯缓存读。
* 本模块经本服务器的同源代理 /ecat/api/* 调它(代理只收 POST,见 esp32_server
* _ecat_proxy),显示在「运行指标」卡的 #mGd32Load。
*
* 与 /system/status 轮询完全独立:ESP32 离线不影响本显示,反之亦然;
* 失败只静默退避,绝不计入 ESP32 的连接失败计数。
*
* 失效语义(都显示 '--'):
* - valid:false —— ECAT 后端没在跑 / 总线从未打开过 / 固件 < 1.5.61(段 5 恒 0);
* - age_ms > 3000 —— PDO 循环已停(没有页面持有 app 租约时 ecat_native 会
* 自动停循环退回 INIT),负载值不再滚动,视为过期;
* - 代理 503 —— ecat_native 没在跑。退避轮询(1s→5s→30s),避免不用 EtherCAT
* 的用户按 1Hz 反复触发 esp32_server 的自动拉起逻辑。
*/
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
var STALE_MS = 3000; /* 超过 3 个窗口没刷新即视为过期 */
var failCount = 0; /* 连续失败/过期次数,驱动退避 */
var timerId = null;
function el() { return document.getElementById('mGd32Load'); }
/* 写前比较:1Hz 轮询里大部分字段不变,无条件写会白白触发样式失效(同 stages.js) */
function setText(node, txt) {
if (node && node.textContent !== txt) node.textContent = txt;
}
function setCls(node, cls) {
if (node && node.className !== cls) node.className = cls;
}
function setTitle(node, title) {
if (node && node.title !== title) node.title = title;
}
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
var TIP_OFF = TIP_BASE + ';当前不可用:需要 ECAT 总线已打开且过程数据循环在跑'
+ '(如打开电机控制页选 EtherCAT 传输),且 DCU_APP 固件 >= 1.5.61。';
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
function render(d) {
var node = el();
if (!node) return false;
var fresh = !!d && d.valid === true
&& Number.isFinite(d.age_ms) && d.age_ms >= 0 && d.age_ms <= STALE_MS;
if (!fresh) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
return false;
}
var perm = Number(d.load_perm);
if (!Number.isFinite(perm)) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
return false;
}
var pct = perm / 10;
setText(node, pct.toFixed(1) + '%');
/* ≥85% 变琥珀提示余量吃紧;其余用正常强调色 */
setCls(node, pct >= 85 ? 'v warn' : 'v ok');
setTitle(node, TIP_BASE + ';窗口序号 ' + d.seq + ',' + d.age_ms + 'ms 前更新');
return true;
}
async function tick() {
var node = el();
if (!node) return; /* 页面上没有该指标就不再调度 */
var ok = false;
try {
var resp = await fetch('/ecat/api/app/load', { method: 'POST', body: '{}' });
if (resp.ok) ok = render(await resp.json());
} catch (_) { /* 代理/后端离线,走退避 */ }
if (!ok) {
failCount++;
} else {
failCount = 0;
}
var wait = POLL_MS;
if (failCount > 6) wait = 30000;
else if (failCount > 2) wait = 5000;
timerId = setTimeout(tick, wait);
}
/** @description 启动轮询。幂等;页面上没有 #mGd32Load 时不启动。 */
export function startEcatLoadPoller() {
if (timerId !== null || !el()) return;
timerId = setTimeout(tick, POLL_MS);
}
+45 -4
View File
@@ -199,8 +199,15 @@ export async function resumePersistedFirmwareWithGesture(chip) {
return false; return false;
} }
var file = await handle.getFile(); var file = await handle.getFile();
await applyRestoredFile(chip, handle, file, { silent: true }); var accepted = await applyRestoredFile(chip, handle, file, { silent: true });
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
if (!accepted) {
/* 文件内容没过 GD32 身份校验(applyRestoredFile 已清句柄/共享槽/IDB):
* 不得报成功,返回 false 让 pickFileViaFSAPI 继续弹文件选择框,
* 否则用户点了上传区只会反复恢复这个非法文件,永远选不了新文件 */
addLog('W', '文件', chip.toUpperCase() + ' 上次固件未通过校验,已放弃恢复,请重新选择文件');
return false;
}
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name); addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success'); showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
return true; return true;
@@ -221,13 +228,28 @@ function applyRestoredFile(chip, handle, file, options) {
if (window.sharedFirmwareFiles) { if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: file, handle: handle }; window.sharedFirmwareFiles[chip] = { file: file, handle: handle };
} }
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */ /* 等 SHA 写入对应槽后再 sync BLE,避免二次重算。
* GD32 文件可能被 processFile 身份校验拒绝(rejectGd32Identity 清掉 gd32File 但不抛异常):
* 此时恢复必须视为失败——清干净窗口内句柄/共享槽/IDB 持久化,
* 否则句柄会一直复活这个非法文件(sync 会从共享槽把它重新绑进 BLE 会话),
* 且调用方误报"恢复成功"挡住文件选择框。 */
return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {}))) return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
.then(function () { .then(function () {
var accepted = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
if (!accepted) {
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
}
persistFileHandle(chip, null);
}
if (typeof window.syncBleFileFromSelectedTarget === 'function') { if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget(); window.syncBleFileFromSelectedTarget();
} }
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
return accepted;
}); });
} }
@@ -275,9 +297,14 @@ export async function restorePersistedFirmwareSelection() {
var perm = await queryHandlePermission(handle); var perm = await queryHandlePermission(handle);
if (perm === 'granted') { if (perm === 'granted') {
var file = await handle.getFile(); var file = await handle.getFile();
await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true }); var accepted = await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name); if (accepted) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
} else {
addLog('W', '文件', chip.toUpperCase() + ' 上次固件「' + file.name +
'」未通过 GD32 校验,已拒绝恢复,请重新选择文件');
}
continue; continue;
} }
/* prompt/denied:不在此处 requestPermission(无用户手势会失败) */ /* prompt/denied:不在此处 requestPermission(无用户手势会失败) */
@@ -371,6 +398,16 @@ export async function pickFileViaFSAPI(chip) {
else window.gd32FileHandle = handle; else window.gd32FileHandle = handle;
window._pendingFwHandles[chip] = null; window._pendingFwHandles[chip] = null;
await processFile(file, chip); await processFile(file, chip);
/* GD32 身份校验拒绝时 processFile 只清文件不抛异常:
* 此时不能持久化句柄/元数据(否则刷新后 resume 会一直复活这个非法文件、
* 且 resume 返回 true 会挡住文件选择框),窗口内句柄也要清掉避免热重载读到它 */
var accepted = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
if (!accepted) {
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
persistFileHandle(chip, null);
return false;
}
persistFileHandle(chip, handle); persistFileHandle(chip, handle);
persistFileMeta(chip, file); persistFileMeta(chip, file);
persistFlashTarget(chip); persistFlashTarget(chip);
@@ -560,6 +597,9 @@ function rejectGd32Identity(fileName, kind) {
showToast(msg, 'error'); showToast(msg, 'error');
window.gd32File = null; window.gd32File = null;
window._gd32FileSha256 = null; window._gd32FileSha256 = null;
/* 元数据一并清掉:processFile 入口已把本次(被拒)文件写进 meta,
* 不清的话下次刷新会拿这个非法文件名当"上次固件"展示 */
persistFileMeta('gd32', null);
var info = document.getElementById('fileInfoGd32'); var info = document.getElementById('fileInfoGd32');
if (info) info.classList.remove('show'); if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
@@ -674,6 +714,7 @@ export function processFile(file, chip, options) {
if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error'); if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
window.gd32File = null; window.gd32File = null;
window._gd32FileSha256 = null; window._gd32FileSha256 = null;
persistFileMeta(chip, null);
var info = document.getElementById('fileInfo' + suffix); var info = document.getElementById('fileInfo' + suffix);
if (info) info.classList.remove('show'); if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel(); safeUpdateUploadButtonLabel();
+1
View File
@@ -0,0 +1 @@
ef593add975f1942dd45793f6fc39182cd669eafe32ff19c632d285c09d6a5ca
+1 -1
View File
@@ -7,7 +7,7 @@
main/monitor/spi_speed_test.cpp 文件头)。 main/monitor/spi_speed_test.cpp 文件头)。
用法:浏览器直接打开本文件,或经 serve.bat 访问 用法:浏览器直接打开本文件,或经 serve.bat 访问
http://localhost:8080/spi_test.html http://localhost:2625/spi_test.html
--> -->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
+3 -2
View File
@@ -344,7 +344,8 @@
<div class="card-body"> <div class="card-body">
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div> <div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div>
<div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div> <div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div>
<div class="metric"><div class="k">GD32 负载</div><div class="v" id="mGd32Load" title="DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口">--</div></div>
<div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div> <div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div>
<div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div> <div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div>
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App">--</div></div> <div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App">--</div></div>
@@ -499,7 +500,7 @@
<span id="toastMsg">操作成功</span> <span id="toastMsg">操作成功</span>
</div> </div>
<script type="module" src="../js/app.js?v=20260901d"></script> <script type="module" src="../js/app.js?v=20260905a"></script>
<script type="module" src="../js/ota/console-mode.js?v=20260901d"></script> <script type="module" src="../js/ota/console-mode.js?v=20260901d"></script>
<script type="module" src="../js/ble_ota.js?v=20260901d"></script> <script type="module" src="../js/ble_ota.js?v=20260901d"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked <!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
+7 -3
View File
@@ -36,8 +36,8 @@
<option value="ecat">EtherCAT</option> <option value="ecat">EtherCAT</option>
</select> </select>
<input type="text" id="ecatAdapter" class="refresh-input" style="width:74px;display:none" <input type="text" id="ecatAdapter" class="refresh-input" style="width:74px;display:none"
value="I226" aria-label="EtherCAT 网卡匹配子串" value="I210" aria-label="EtherCAT 网卡匹配子串"
title="EtherCAT 网卡描述匹配子串(默认 I226)。改这里会立即重连该网口,设置存本地。" /> title="EtherCAT 网卡描述匹配子串(默认 I210)。改这里会立即重连该网口,设置存本地。" />
</div> </div>
<div class="toolbar-sep"></div> <div class="toolbar-sep"></div>
<div class="conn-group"> <div class="conn-group">
@@ -49,7 +49,7 @@
</select> </select>
</div> </div>
<div class="toolbar-sep"></div> <div class="toolbar-sep"></div>
<div class="conn-group" title="电机状态刷新间隔(ms)。WiFi 与 EtherCAT 各记一份,切链路自动换(默认 WiFi 250ms / EtherCAT 50ms)。下限也按链路:WiFi 50ms,EtherCAT 可到 1ms(不限速,实际频率见 STATUS 面板)"> <div class="conn-group" title="电机状态刷新间隔(ms)。WiFi 与 EtherCAT 各记一份,切链路自动换(默认 WiFi 250ms / EtherCAT 50ms)。下限也按链路:WiFi 50ms,EtherCAT 最低 6ms(≈160Hz,再快就超过屏幕绘制上限,实际频率见 STATUS 面板)">
<label for="refreshRate" class="refresh-hint">刷新速度</label> <label for="refreshRate" class="refresh-hint">刷新速度</label>
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒,按链路分别记忆)" /> <input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒,按链路分别记忆)" />
<span class="refresh-hint">ms</span> <span class="refresh-hint">ms</span>
@@ -280,6 +280,7 @@
命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID); 命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID);
执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms), 执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms),
发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。 发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。
每步的绿色「▶」按钮为单发:只把该步全部动作下发一次,不循环、不推进步骤、不等待保持时间。
循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。 循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。
</p> </p>
</div> </div>
@@ -368,6 +369,7 @@
<div class="flag-item" id="flagItemMag"><span class="flag-led" id="flagMag"></span>magStatus 磁编码<span class="flag-state" id="flagMagState">—</span></div> <div class="flag-item" id="flagItemMag"><span class="flag-led" id="flagMag"></span>magStatus 磁编码<span class="flag-state" id="flagMagState">—</span></div>
<div class="flag-item" id="flagItemThermal"><span class="flag-led" id="flagThermal"></span>thermal 过温<span class="flag-state" id="flagThermalState">—</span></div> <div class="flag-item" id="flagItemThermal"><span class="flag-led" id="flagThermal"></span>thermal 过温<span class="flag-state" id="flagThermalState">—</span></div>
<div class="flag-item" id="flagItemEeprom"><span class="flag-led" id="flagEeprom"></span>EEPROM 存储<span class="flag-state" id="flagEepromState">—</span></div> <div class="flag-item" id="flagItemEeprom"><span class="flag-led" id="flagEeprom"></span>EEPROM 存储<span class="flag-state" id="flagEepromState">—</span></div>
<div class="flag-item" id="flagItemInit"><span class="flag-led" id="flagInit"></span>InitStatus 初始化<span class="flag-state" id="flagInitState">—</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -400,5 +402,7 @@
</div> </div>
<script type="module" src="../js/motor/main.js?v=20260905a"></script> <script type="module" src="../js/motor/main.js?v=20260905a"></script>
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
<script src="../js/common/app-lease.js"></script>
</body> </body>
</html> </html>
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""事后分析 rec_ecat_slots.py 落盘的 ECAT 原始流:相邻数据大跳变检测。
思路(按用户指定):不看协议判据,只看数值形态。同一台电机的 STATUS 是连续量,
相邻两帧(按 seq 排序)之间出现"跳出去又立刻跳回来"的孤立尖峰,就是这一帧的字节
被拼坏了 —— 真突变会保持住(下一帧仍在新值附近),不会一帧就回头。
指标:每个 (node,字段) 先取全体相邻差的稳健尺度 scale = median(|Δ|),
大跳变 : |v[i]-v[i-1]| > K*scale
孤立尖峰 : 大跳变 且 |v[i+1]-v[i-1]| <= 2*K*scale ← 撕裂签名
跳变保持 : 大跳变 但下一帧不回头的(真实突变/新参考值),只计数不算异常
--selftest 会把"前 j 字节取新帧、其余取上一帧"的混合帧注入实测数据,
逐 j 报告检出率:证明这套判据对撕裂本身有灵敏度(否则 0 报警没有意义)。
用法:
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8]
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv --selftest
"""
import csv
import statistics
import struct
import sys
# (名称, 偏移, 解码器)
FIELDS = [
("mcuTemp", 0, lambda b: struct.unpack_from("<h", b, 0)[0] * 0.01),
("motTemp", 2, lambda b: struct.unpack_from("<h", b, 2)[0] * 0.01),
("uCur", 4, lambda b: struct.unpack_from("<h", b, 4)[0]),
("vCur", 6, lambda b: struct.unpack_from("<h", b, 6)[0]),
("wCur", 8, lambda b: struct.unpack_from("<h", b, 8)[0]),
("flags", 10, lambda b: b[10]),
("pwr_V", 11, lambda b: struct.unpack_from("<H", b, 11)[0] * 0.001),
("rtAngle", 13, lambda b: struct.unpack_from("<f", b, 13)[0]),
("rtVel", 17, lambda b: struct.unpack_from("<f", b, 17)[0]),
("rtCur", 21, lambda b: struct.unpack_from("<f", b, 21)[0]),
("testF", 25, lambda b: struct.unpack_from("<f", b, 25)[0]),
("testU16", 29, lambda b: struct.unpack_from("<H", b, 29)[0]),
("cpu%", 31, lambda b: b[31]),
("initSt", 32, lambda b: struct.unpack_from("<I", b, 32)[0]),
]
def load(path):
"""返回 {node: [(seq, 36B bytes), ...]} 按 seq 升序。"""
per = {}
with open(path, "r", encoding="ascii", errors="ignore") as f:
for row in csv.reader(f):
if not row or row[0] != "M":
continue
try:
node, ch, seq = int(row[2]), int(row[3]), int(row[4])
payload = bytes.fromhex(row[7])
except (ValueError, IndexError):
continue
if node <= 0 or len(payload) < 36:
continue
per.setdefault(node, []).append((seq, payload))
for v in per.values():
v.sort(key=lambda x: x[0])
return per
def decode(payload):
out = {}
for name, off, fn in FIELDS:
try:
out[name] = float(fn(payload))
except Exception:
out[name] = None
return out
def analyze(per, k=8.0, verbose=True):
"""逐 (node,字段) 找孤立尖峰。返回总尖峰数与明细列表。"""
spikes, holds, details = 0, 0, []
tails = []
for node, rows in sorted(per.items()):
if len(rows) < 40:
continue
cols = [decode(p) for _, p in rows]
for name, _off, _fn in FIELDS:
v = [c[name] for c in cols]
finite = [x for x in v if x is not None and x == x and abs(x) != float("inf")]
if len(finite) < 40:
continue
deltas = [abs(v[i] - v[i - 1]) for i in range(1, len(v))
if v[i] is not None and v[i - 1] is not None
and v[i] == v[i] and v[i - 1] == v[i - 1]]
if not deltas:
continue
# 尺度取 |Δ| 的 p99 分位数(不是 median):量化字段(mcuTemp 这类)
# 的 median(|Δ|)=0,用 median 会让阈值塌到 eps,正常的 1LSB 抖动
# 全被判成尖峰。p99 代表"全体相邻差的尾部",阈值 = K×p99 才是
# "远超正常抖动尾巴"的意思。
ordered = sorted(deltas)
scale = max(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], 1e-9)
mx, mxctx = 0.0, None
for i in range(1, len(v) - 1):
a, b, c = v[i - 1], v[i], v[i + 1]
if None in (a, b, c) or any(x != x for x in (a, b, c)):
continue
d = abs(b - a)
if d > mx:
mx, mxctx = d, (a, b, c)
if d <= k * scale:
continue
back = abs(c - a) <= 2 * k * scale
if back:
spikes += 1
if len(details) < 25:
details.append((node, name, rows[i][0], scale, a, b, c,
rows[i][1].hex()))
else:
holds += 1
if mxctx and mx > 3 * scale and len(tails) < 60:
tails.append((node, name, scale, mx, mxctx))
if verbose:
print(f"孤立尖峰(撕裂签名) = {spikes} 跳变保持(真实突变) = {holds}")
for node, name, seq, scale, a, b, c, hexs in details[:12]:
print(f" node{node} {name}: seq={seq} 尺度={scale:.4g} "
f"{a:.6g} -> {b:.6g} -> {c:.6g}")
print(f" payload={hexs}")
if tails:
tails.sort(key=lambda x: -(x[3] / max(x[2], 1e-12)))
print("各字段最大相邻差(倍数 = 最大|Δ| / p99 尺度):")
for node, name, scale, mx, (a, b, c) in tails[:14]:
print(f" node{node} {name:9s} p99尺度={scale:.4g} 最大|Δ|={mx:.4g} "
f"({mx/max(scale,1e-12):.0f}×) {a:.6g} -> {b:.6g} -> {c:.6g}")
return spikes, holds, details
def selftest(per, k=8.0, n_inject=40):
"""注入混合帧,测各边界 j 的检出率(证明判据有灵敏度)。"""
import random
random.seed(7)
base_sp, _bh, _bd = analyze(per, k=k, verbose=False)
nodes = [n for n, r in per.items() if len(r) >= 60]
if not nodes:
print("自检跳过:数据太短(每节点需 >=60 帧)")
return
print(f"自检:基线孤立尖峰 = {base_sp};向实测流注入『新帧前缀 j 字节 + 上一帧后缀』"
f"混合帧,每节点各 {n_inject} 次")
print(" j(字节) 净增尖峰/注入总数 检出率")
for j in (1, 4, 8, 12, 13, 16, 17, 20, 21, 24, 25, 28, 31, 32, 35):
rnd = random.Random(j)
merged = {}
for node in nodes:
rows = per[node]
idxs = rnd.sample(range(2, len(rows) - 2), min(n_inject, len(rows) - 6))
new = list(rows)
for i in idxs:
cur, prev = rows[i][1], rows[i - 1][1]
new[i] = (rows[i][0], cur[:j] + prev[j:36])
merged[node] = new
sp, _h, det = analyze(merged, k=k, verbose=False)
inj_n = n_inject * len(nodes)
net = sp - base_sp
worst = max((abs(d[5] - d[4]) for d in det), default=0.0)
print(f" j={j:<4d} {net:5d}/{inj_n:<5d} {100.0*net/inj_n:5.1f}% "
f"最大偏离≈{worst:.4g}")
print("(j 越大越靠尾部字节,混合后数值偏离越小、越难检出 —— 这是形态判据的物理上限)")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print(__doc__)
return
path = args[0]
k = 8.0
if "--k" in sys.argv:
k = float(sys.argv[sys.argv.index("--k") + 1])
per = load(path)
tot = sum(len(v) for v in per.values())
print(f"读入 {path}: {tot} 帧,节点 {sorted(per)}")
for node, rows in sorted(per.items()):
print(f" node{node}: {len(rows)} 个不同 seq,"
f"seq 范围 {rows[0][0]}..{rows[-1][0]}")
if "--selftest" in sys.argv:
selftest(per, k=k)
return
analyze(per, k=k)
if __name__ == "__main__":
main()
@@ -1,10 +1,11 @@
/** /**
* 浏览器实测:ECAT 刷新速度 1ms(不限速档)。 * 浏览器实测:ECAT 刷新速度下限档 6ms(不限速档)。
* 单测只验了纯逻辑;这里验真机行为: * 单测只验了纯逻辑;这里验真机行为:
* - 输入框 min/step 随链路变(否则浏览器把合法的 1ms 标成 :invalid) * - 输入框 min/step 随链路变(否则浏览器把合法的 6ms 标成 :invalid)
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz * - 6ms 真的比 50ms 快,且拿到实测频率而不是理论 166Hz
* - 存量缓存里的旧 1ms 会被抬到 6ms(绘制上限之上没有消费者)
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为) * - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms) * - 切回 WiFi 时 6ms 不会被带过去(wifi 下限 50ms)
*/ */
const CDP = "http://127.0.0.1:9333"; const CDP = "http://127.0.0.1:9333";
const list = await (await fetch(CDP + "/json/list")).json(); const list = await (await fetch(CDP + "/json/list")).json();
@@ -23,8 +24,8 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } }; const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable"); await send("Runtime.enable"); await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */ /* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 8080); const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`; const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点,直接进 ECAT */ /* 干净起点,直接进 ECAT */
@@ -49,28 +50,35 @@ const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn:
out.push("\n[前置] " + JSON.stringify(pre)); out.push("\n[前置] " + JSON.stringify(pre));
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre)); check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 1"); out.push("\n[A] ECAT 下输入框的 min/step 应放开到 6/1");
let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`)); let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom)); check("min=6(否则浏览器校验会把 6ms 判成非法)", dom.min === "6", JSON.stringify(dom));
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom)); check("step=1(下限档要能逐 ms 微调)", dom.step === "1", JSON.stringify(dom));
out.push("\n[B] 基线:ECAT 默认 50ms"); out.push("\n[B] 基线:ECAT 默认 50ms");
const hz50 = await measure(3); const hz50 = await measure(3);
out.push(` 实测 ${hz50.toFixed(1)} Hz`); out.push(` 实测 ${hz50.toFixed(1)} Hz`);
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`); check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
out.push("\n[C] 设成 1ms —— 应被接受且明显更快"); out.push("\n[C] 设成 6ms —— 应被接受且明显更快");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`); await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="6"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(800); await sleep(800);
let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`)); let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`));
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st)); check("6ms 被接受(没被钳回 50)", st.eff === 6, JSON.stringify(st));
check("输入框显示 1", st.val === "1", JSON.stringify(st)); check("输入框显示 6", st.val === "6", JSON.stringify(st));
check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st)); check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st));
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 1 && st.map.wifi === 250, JSON.stringify(st)); check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 6 && st.map.wifi === 250, JSON.stringify(st));
const hz1 = await measure(4); const hz1 = await measure(4);
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`); out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,6ms 档约 160Hz 量级)`);
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`); check("6ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `6ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`); check("但达不到理论 166Hz 以上(受每拍 HTTP 往返限制)", hz1 < 200, `实测 ${hz1.toFixed(1)}`);
out.push("\n[C2] 硬填旧的 1ms —— 应被抬到下限 6ms");
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
check("旧 1ms 被抬到 6ms", st.eff === 6, JSON.stringify(st));
check("输入框回填 6", st.val === "6", JSON.stringify(st));
out.push("\n[D] 不限速档不应亮卡顿黄标"); out.push("\n[D] 不限速档不应亮卡顿黄标");
st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`)); st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`));
@@ -78,23 +86,23 @@ check("tickStuck 保持 0(每拍撞上在途请求是预期行为,不算卡
check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st)); check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st));
check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st)); check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st));
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 1000Hz"); out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 166Hz");
const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`); const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`);
out.push(` 面板显示: ${hzText}`); out.push(` 面板显示: ${hzText}`);
const m = String(hzText).match(/([\d.]+)\s*Hz/); const m = String(hzText).match(/([\d.]+)\s*Hz/);
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText); check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
if (m) { if (m) {
const shown = parseFloat(m[1]); const shown = parseFloat(m[1]);
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`); check("显示的不是谎报的理论值", shown < 200, `显示 ${shown}Hz`);
check("显示值与实测频率同量级(±60%)", check("显示值与实测频率同量级(±60%)",
Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`); Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`);
} }
out.push("\n[F] 切到 WiFi:1ms 不能带过去(wifi 下限 50ms)"); out.push("\n[F] 切到 WiFi:6ms 不能带过去(wifi 下限 50ms)");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`); await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(1500); await sleep(1500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`)); st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
check("WiFi 生效值 250(不是 ECAT 的 1)", st.eff === 250, JSON.stringify(st)); check("WiFi 生效值 250(不是 ECAT 的 6)", st.eff === 250, JSON.stringify(st));
check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st)); check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st));
check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st)); check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st));
/* 在 WiFi 下硬填 1ms,应被钳到 50 */ /* 在 WiFi 下硬填 1ms,应被钳到 50 */
@@ -103,16 +111,16 @@ await sleep(500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`)); st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st)); check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st));
out.push("\n[G] 切回 ECAT:仍记着自己的 1ms"); out.push("\n[G] 切回 ECAT:仍记着自己的 6ms");
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`); await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
await sleep(2500); await sleep(2500);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`)); st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`));
check("ECAT 生效值回到 1ms", st.eff === 1, JSON.stringify(st)); check("ECAT 生效值回到 6ms", st.eff === 6, JSON.stringify(st));
check("min 重新放开到 1", st.min === "1", JSON.stringify(st)); check("min 重新放开到 6", st.min === "6", JSON.stringify(st));
/* 刷新页面后 1ms 应持久化 */ /* 刷新页面后 6ms 应持久化 */
await send("Page.navigate", { url: PAGE }); await sleep(3000); await send("Page.navigate", { url: PAGE }); await sleep(3000);
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`)); st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`));
check("刷新页面后 ECAT 仍是 1ms(持久化 + 加载时不被钳高)", st.eff === 1, JSON.stringify(st)); check("刷新页面后 ECAT 仍是 6ms(持久化 + 加载时不被钳高)", st.eff === 6, JSON.stringify(st));
check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st)); check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st));
console.log(out.join("\n")); console.log(out.join("\n"));
+1 -1
View File
@@ -24,7 +24,7 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } }; const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable"); await send("Runtime.enable"); await send("Page.enable");
const PORT = Number(process.argv[2] || 8080); const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`; const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */ /* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */
+2 -2
View File
@@ -44,8 +44,8 @@ function check(name, cond, detail) {
await send("Runtime.enable"); await send("Runtime.enable");
await send("Page.enable"); await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */ /* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 8080); const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`; const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
async function goto(u) { async function goto(u) {
+2 -2
View File
@@ -20,8 +20,8 @@ let pass = 0, fail = 0; const out = [];
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } }; const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
await send("Runtime.enable"); await send("Page.enable"); await send("Runtime.enable"); await send("Page.enable");
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */ /* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
const PORT = Number(process.argv[2] || 8080); const PORT = Number(process.argv[2] || 2625);
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`; const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */ /* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
+59
View File
@@ -0,0 +1,59 @@
param([int]$Seconds = 8)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class W {
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string c, string t);
}
"@
$SW_RESTORE = 9
$SW_MINIMIZE = 6
$win = $null
Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -match 'ESP32' } | ForEach-Object {
$win = $_; Write-Output ("found window: pid={0} title='{1}'" -f $_.Id, $_.MainWindowTitle)
}
if (-not $win) { Write-Output 'NO ESP32 WINDOW FOUND'; exit 1 }
$hwnd = $win.MainWindowHandle
Write-Output ("before: minimized=" + [W]::IsIconic($hwnd))
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match 'msedgewebview2|ecat_native|pythonw' })) {
try { $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = [math]::Round($p.WorkingSet64/1MB,0) } } catch {}
}
return $h
}
function Measure-Phase($label, $dt) {
$a = Snap
Start-Sleep -Seconds $dt
$b = Snap
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{ Phase = $label; Pid = [int]$k; Name = $b[$k].name; Pct1Core = [math]::Round($d/$dt*100,1); WS_MB = $b[$k].ws }
}
}
}
return $rows
}
$r1 = Measure-Phase 'visible' $Seconds
Write-Output '--- minimize ---'
[void][W]::ShowWindow($hwnd, $SW_MINIMIZE)
Start-Sleep -Seconds 2
$r2 = Measure-Phase 'minimized' $Seconds
Write-Output '--- restore ---'
[void][W]::ShowWindow($hwnd, $SW_RESTORE)
Start-Sleep -Seconds 2
$r3 = Measure-Phase 'restored' $Seconds
$all = @() + $r1 + $r2 + $r3
$out = $all | Sort-Object Phase, Pct1Core -Descending
$out | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
foreach ($ph in @('visible','minimized','restored')) {
$s = ($out | Where-Object { $_.Phase -eq $ph } | Measure-Object -Property Pct1Core -Sum).Sum
Write-Output ("TOTAL {0,-10} = {1,7:N1} % of one core" -f $ph, $s)
}
+31
View File
@@ -0,0 +1,31 @@
$ErrorActionPreference = 'Stop'
$cpus = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
Write-Output "logical_cpus=$cpus"
$pat = 'msedgewebview2|ecat_native|pythonw|python'
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match $pat })) {
$h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = $p.WorkingSet64 }
}
return $h
}
$a = Snap
Start-Sleep -Seconds 8
$b = Snap
$dt = 8
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{
pid = [int]$k
name = $b[$k].name
pct = [math]::Round($d / $dt * 100, 1)
wsMB = [math]::Round($b[$k].ws / 1MB, 0)
}
}
}
}
$rows | Where-Object { $_.pct -ge 0.3 } | Sort-Object pct -Descending | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
$total = ($rows | Measure-Object -Property pct -Sum).Sum
Write-Output ("total_pct_of_one_core=" + [math]::Round($total,1))
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""GD32 固件经 ESP32 中继刷写(tools/flash_gd32.py 删除后的等价实现)。
流程(与 ESP32 侧 http_handlers.cpp 的 ota_upload_handler /
ota_relay_trigger_handler 契约一致):
1. Release 的 Intel HEX 展平成从 0x08048000 起的连续 BIN(间隙填 0xFF);
2. POST /ota/upload,请求体 = {"total_size":N} 紧跟 BIN 字节;
3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做
包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒);
4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是
期望版本、gd32_adc.uptime_sec 已归零;
5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。
用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0]
"""
import hashlib
import json
import os
import struct
import sys
import time
import urllib.error
import urllib.request
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
APP_BASE = 0x08048000
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
HEX = os.path.join(ROOT, "DCU_APP", "out", "GD32E508_DCU_APP", "GD32E508VE",
"Release", "GD32E508_DCU_APP.hex")
LEDGER = os.path.join(HERE, "..", "lab", "_last_gd32_sha.txt")
def hex_to_flat(path):
"""Intel HEX → 从 APP_BASE 起的连续字节。间隙填 0xFF(擦除态)。"""
mem = {}
base = 0
with open(path, "r", encoding="ascii", errors="ignore") as f:
for ln, line in enumerate(f, 1):
line = line.strip()
if not line.startswith(":"):
continue
raw = bytes.fromhex(line[1:])
cnt, addr, rtype = raw[0], struct.unpack(">H", raw[1:3])[0], raw[3]
data = raw[4:4 + cnt]
if len(raw) != cnt + 5 or (sum(raw) & 0xFF) != 0:
raise SystemExit(f"{path}:{ln} 校验和不对")
if rtype == 0x00:
for i, b in enumerate(data):
mem[base + addr + i] = b
elif rtype == 0x02:
base = struct.unpack(">H", data[:2])[0] << 4
elif rtype == 0x04:
base = struct.unpack(">H", data[:2])[0] << 16
elif rtype == 0x01:
break
if not mem:
raise SystemExit("HEX 里没有数据记录")
lo, hi = min(mem), max(mem)
if lo > APP_BASE:
raise SystemExit(f"镜像起点 0x{lo:08X} 高于 APP 基址 0x{APP_BASE:08X},不是 APP 包")
if lo < APP_BASE:
raise SystemExit(f"镜像含 0x{lo:08X} 的地址,落在 APP 区之外(疑似 BOOT 包)")
out = bytearray(b"\xFF" * (hi - APP_BASE + 1))
for a, b in mem.items():
out[a - APP_BASE] = b
return bytes(out)
def post(path, body, timeout=60):
req = urllib.request.Request(f"http://{ESP}{path}", data=body, method="POST")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def get(path, timeout=10):
with urllib.request.urlopen(f"http://{ESP}{path}", timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def main():
want = sys.argv[1] if len(sys.argv) > 1 else None
if not os.path.isfile(HEX):
raise SystemExit(f"找不到 {HEX}")
blob = hex_to_flat(HEX)
sha = hashlib.sha256(blob).hexdigest()
print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}")
hdr = json.dumps({"total_size": len(blob)}).encode("ascii")
t0 = time.time()
st, body = post("/ota/upload", hdr + blob, timeout=120)
print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)")
if st != 200:
raise SystemExit("上传失败")
t0 = time.time()
st, body = post("/ota/relay", json.dumps({"sha256": sha}).encode("ascii"), 60)
print(f"relay -> {st} {body[:300]} ({time.time()-t0:.1f}s)")
deadline = time.time() + 90
last = ""
while time.time() < deadline:
time.sleep(2)
try:
s = get("/system/status")
except Exception as e:
print(f" 等待中… status 读失败 {type(e).__name__}")
continue
o = s.get("ota", {})
line = (f" sys_state={o.get('sys_state')} gd32_app={o.get('gd32_app')} "
f"alive={o.get('gd32_app_alive')} uptime={s.get('uptime_sec')}")
if line != last:
print(line)
last = line
if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"):
ver = str(o["gd32_app"]).lstrip("v")
if want and ver != want:
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
with open(LEDGER, "w", encoding="ascii") as f:
f.write(sha)
print(f"完成: 板上 gd32_app={o['gd32_app']},台账已写 {os.path.normpath(LEDGER)}")
return
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ECAT 段2 撕裂 A/B 判据(修复前后的唯一对照指标)。
主判据(零假阳性,直接来自写侧契约):
固件 MotorSlot 的 seq 与 data 属同一次更新,seq 先自增、data 后 memcpy。
⇒ 同一 seq 在总线上只能对应唯一一份 48B payload。
若同一 seq 先后被读到两份不同 payload,只可能是消费者(PDI_Isr 的 buildInput)
在生产者 memcpy 中途取走了半成品 —— 即撕裂(含"seq 新、data 整体旧"的错拍型)。
1.6.5 的 seqlock 把整个更新段(seq 自增 + data 拷贝)都盖进奇数区间,
故修复后该不变量必须零例外。
功效条件:只有当该节点的 seq 在相邻两拍之间不前进时,同一 seq 才可能被读两次。
所以脚本同时报"同 seq 重复读"的覆盖率——重复率低则本次判定功效不足,需换工况重跑。
旁判据(与速率无关):字段值的"跳一下又回来"(median 偏离 >8× 局部抖动带且下一帧
回到带内)——真撕裂必然表现为这种形态。
"""
import json
import os
import socket
import statistics
import struct
import sys
import time
HOST = "127.0.0.1"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
DUR_S = float(sys.argv[2]) if len(sys.argv) > 2 else 150.0
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ecat_torn_hits.jsonl")
FLOATS = (("rtAngle", 13), ("rtVelocity", 17), ("rtCurrent", 21), ("testFloat", 25))
I16 = (("mcuTemp", 0), ("motTemp", 2), ("uCur", 4), ("vCur", 6), ("wCur", 8))
def once():
s = socket.create_connection((HOST, PORT))
try:
s.sendall(REQ)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
_, _, d = bytes(buf).partition(b"\r\n\r\n")
return json.loads(d.decode("utf-8", "replace"))
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
f"Content-Type: application/json\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\n{{}}").encode("ascii")
def main():
# per node: 当前 seq 的首见/末见 payload、上一 seq 的确定 payload、字段历史
st = {}
viol, spikes, polls, err = [], [], 0, 0
cov = 0
t0 = time.time()
lf = open(LOG, "w", encoding="utf-8")
while time.time() - t0 < DUR_S:
try:
snap = once()
except Exception:
err += 1
continue
polls += 1
for m in snap.get("motors") or []:
if not m.get("online") or not m.get("data"):
continue
nd, seq = m["node"], m["seq"]
d = bytes.fromhex(m["data"])
e = st.setdefault(nd, {"cur": None, "first": None, "last": None,
"done": None, "hist": {}, "seen": 0})
if e["cur"] == seq:
cov += 1 # 一次"同一 seq 被读第二遍"的判定机会
if e["first"] != d:
rec = {"t": round(time.time() - t0, 2), "node": nd, "seq": seq,
"first": e["first"].hex(), "later": d.hex(),
"prev_seq_data": (e["done"].hex() if e["done"] else None)}
# 分类:整体等于上一帧 = 错拍型;前缀/后缀混合 = 真混合
a, b, p = e["first"], d, e["done"]
rec["cls"] = ("STALE(错拍)" if (p is not None and a == p) else
"MATCH" if a == b else "VIOLATION")
if p is not None and a != p:
n = min(len(a), len(b), len(p))
rec["mix_j"] = next((j for j in range(n + 1)
if a[:j] == b[:j] and a[j:n] == p[j:n]), None)
viol.append(rec)
lf.write(json.dumps(rec, ensure_ascii=False) + "\n")
lf.flush()
e["last"] = d
else:
# seq 前进:上一 seq 的末见即其确定内容
if e["cur"] is not None and e["last"] is not None:
e["done"] = e["last"]
if len(e["last"]) >= 29: # 覆盖到 testFloat 末字节才做形态判据
for name, off in FLOATS:
e["hist"].setdefault(name, []).append(
struct.unpack_from("<f", e["last"], off)[0])
if len(e["hist"][name]) > 400:
e["hist"][name].pop(0)
# 形态判据:本帧各字段是否"跳出带又回来"需要下一帧,留到下次比较
for name, off in FLOATS:
h = e["hist"].get(name) or []
if len(h) >= 4:
a, b2 = h[-3], h[-2] # 上上帧 / 上一帧(刚确定)
# 检查上一帧相对其邻居是否孤立尖峰(其前其后由 hist 提供)
if len(h) >= 4:
pre, post = h[-4], h[-1]
band = max(abs(b2 - pre), abs(post - b2), 1e-6)
if abs(b2 - (pre + post) / 2) > 20 * band:
spikes.append({"t": round(time.time() - t0, 2),
"node": nd, "field": name,
"pre": pre, "spike": b2, "post": post})
e["cur"], e["first"], e["last"], e["seen"] = seq, d, d, 0
lf.close()
print(f"轮询 {polls} 次 / {DUR_S:.0f}s({polls/DUR_S:.0f} 次/s,连接错误 {err})")
print(f"判定功效:同一 seq 被读第二遍的机会 = {cov} 次")
print(f"主判据违规(同一 seq 出现两份不同 48B)= {len(viol)}")
for r in viol[:6]:
print(" ", r["cls"], f"t={r['t']}s node={r['node']} seq={r['seq']} mix_j={r.get('mix_j')}")
print(f" first={r['first'][:64]}")
print(f" later={r['later'][:64]}")
print(f"旁判据(孤立尖峰,形态学,可能有真突变混入)= {len(spikes)}")
for s in spikes[:4]:
print(f" t={s['t']}s node={s['node']} {s['field']}: {s['pre']:.6g} -> {s['spike']:.6g} -> {s['post']:.6g}")
print(f"明细: {LOG}")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More