docs: 新增 .qoder Repowiki 知识库文档
This commit is contained in:
@@ -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 集成。
|
||||
+26
@@ -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)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Web Bluetooth API(navigator.bluetooth.requestDevice / GATT Server + Characteristic + notify),配合 File System Access API(showOpenFilePicker)支持热重载;通过 0xA1 ACK + 64 窗口 ACK_WINDOW 实现流控。
|
||||
+1
@@ -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 等)协作,确保界面一致性。
|
||||
+1
@@ -0,0 +1 @@
|
||||
基于 Web Bluetooth API 实现 ESP32/GD32 双目标设备的 BLE 固件在线升级,负责 GATT 连接、分包传输、ACK 窗口与 SHA-256 校验。
|
||||
+1
@@ -0,0 +1 @@
|
||||
BLE 连接必须在用户手势内触发(click handler 中使用 `e.preventDefault()` 保证 navigator.bluetooth.requestDevice 合法);需浏览器启用 `web-bluetooth` 权限并过滤 namePrefix 为 'ESP32-OTA'。
|
||||
+6
@@ -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 被意外重置。
|
||||
+27
@@ -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 解析、报文发送与接收监控
|
||||
+1
@@ -0,0 +1 @@
|
||||
纯浏览器端 JavaScript(ES Module),使用 TextDecoder('utf-8' | 'gb18030') 解析 DBC 文件、localStorage 会话存储、AbortController+setTimeout 双超时控制 fetch、Clipboard API 降级 execCommand 回退。
|
||||
+8
@@ -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 与状态副本。
|
||||
+1
@@ -0,0 +1 @@
|
||||
实现浏览器端 CAN 调试工具的 DBC 文件解析、报文过滤/信号编辑、批量帧发送与会话持久化,并提供独立页的 CAN 通道反馈轮询监控。
|
||||
+1
@@ -0,0 +1 @@
|
||||
无需构建步骤;直接由网页加载 ES Module。`readDbcText` 支持 UTF-8 优先、GB18030 回退并去除 BOM,用于本地拖入的 .dbc 文件;`parseDbc` 自行解析 BO_/SG_/CM_/VAL_ 标准 DBC 片段。
|
||||
+6
@@ -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 控制台集合
|
||||
+28
@@ -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 刷写会话与进度管理
|
||||
+1
@@ -0,0 +1 @@
|
||||
纯浏览器端 JavaScript (ES Module),使用 IndexedDB 持久化 FileSystemFileHandle 实现刷新后热重载恢复;XHR+fetch 混合进行文件分块上传;通过 ES import 引入 ../common/*(log、toast、utils、connection)。
|
||||
+5
@@ -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,无任何反向引用。
|
||||
+1
@@ -0,0 +1 @@
|
||||
为 ESP32/GD32/DPU 提供浏览器侧的固件选择、分块上传、校验、中继触发与会话状态机,并渲染阶段节点与进度条。
|
||||
+1
@@ -0,0 +1 @@
|
||||
无构建脚本——模块由 HTML 页面直接按模块加载;EtherCAT 负载显示需主站 esp32_server 打开 ECAT native 循环并提供 /api/app/load,本模块仅做 100ms 退避轮询(失败→5s→30s),不强制拉起后端。
|
||||
+6
@@ -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,避免重复调度。
|
||||
+22
@@ -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)
|
||||
+1
@@ -0,0 +1 @@
|
||||
ES Module (`export/import`);HTTP 轮询使用原生 `fetch` + `AbortController` 实现超时取消;WebSocket 长连接由 `app-lease.js` 自持;设备事件持久化走 IndexedDB (`IDBFactory.open('esp_dream_heap_low')`,版本 1);DOM 高频更新采用 `requestAnimationFrame` 合并写入与字符串对比去变更。
|
||||
+9
@@ -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 反向引用用于失败诊断,避免连接态被业务直接操作。
|
||||
+1
@@ -0,0 +1 @@
|
||||
为 ESP32 DCU Web 管理端提供跨页面共享的连接状态、系统轮询、日志缓冲、提示框、IndexedDB 历史与基础 URL/格式化等通用能力。
|
||||
+6
@@ -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/无痕模式环境下仍可工作。
|
||||
+30
@@ -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 诊断)
|
||||
+1
@@ -0,0 +1 @@
|
||||
ES Module (type="module") + Fetch API + Web Bluetooth (GATT) + DBC/CAN 报文解析
|
||||
+5
@@ -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/*`,无横向耦合。
|
||||
+1
@@ -0,0 +1 @@
|
||||
汇总 ESP32 WiFi OTA、BLE OTA 与 CAN 诊断三个控制台页面的入口脚本,通过 ES Module import 组装子模块并将函数挂载到 window,提供统一的事件绑定与 UI 初始化。
|
||||
+1
@@ -0,0 +1 @@
|
||||
模块内 import 路径禁止加 `?v=` 版本号,版本戳仅出现在 HTML `<script src>` 标签上,以避免同一模块因不同 querystring 产生双份实例(破坏 state/toast 单例);CAN 页面中向特定文件追加硬编码 `?v=...` 以强制重新加载已发布过的模块。
|
||||
+4
@@ -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 入口聚合装配。
|
||||
+27
@@ -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: 电机控制台多传输模块
|
||||
+1
@@ -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 双版本兼容)。
|
||||
+9
@@ -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)完全不感知底层链路差异。
|
||||
+1
@@ -0,0 +1 @@
|
||||
提供电机控制台的业务逻辑入口,通过可插拔的 WiFi/HTTP 与 EtherCAT 传输层抽象,驱动状态轮询、帧发送、命令序列与波形等共享逻辑。
|
||||
+5
@@ -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 封装进行周期性数据推送,而非自行实现定时器
|
||||
+27
@@ -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」)及删除方式,便于后续清理时对照固件端点一并移除
|
||||
@@ -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 触发)。
|
||||
@@ -1,49 +0,0 @@
|
||||
# 更新日志 (CHANGELOG)
|
||||
|
||||
按日期倒序记录面向使用者的变更;实现细节见 README 对应章节与提交说明。
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
### 新增:app 窗口租约 —— 关掉 app 必停从站
|
||||
|
||||
- 每个 app 页面与本地页面服务器保持一条 WebSocket(`js/common/app-lease.js` →
|
||||
`GET /ws/app-lease`);**最后一条**租约断开(所有 app 窗口关闭)后,服务端
|
||||
等 3s 宽限自动调后端 `/api/op/stop`:停 PDO 循环、从站退回 INIT。
|
||||
覆盖两种 native `--parent-pid` 看门狗够不着的离开方式:浏览器模式关窗后
|
||||
服务还活着、窗口模式复用现成 ecat_native 实例。
|
||||
- 断开感知靠 TCP FIN / Close 帧回显而非心跳计时器,后台标签页定时器节流
|
||||
不会误停;3s 宽限吸收 F5 刷新的断连→重连;外壳内切标签不停(外壳自己
|
||||
持有租约);只停"本服务经代理确认跑过"的循环,ecat 控制台(:8090)
|
||||
手工起的循环不受影响;服务进程退出路径(`/api/shutdown`、Ctrl+C)同步补停。
|
||||
- 接入点:`tabs.html`(外壳)与 `pages/motor.html`(单独打开电机页)。
|
||||
- 新增自测:`python scripts/test_app_lease.py`(服务端逻辑,裸 TCP 全路径)
|
||||
+ `node scripts/test_app_lease_e2e.mjs`(真 Edge headless + 假后端 8091,
|
||||
不碰真总线)。
|
||||
- 代价说明:每次重开 app 都要重走 PREOP→SAFEOP→OP,有概率撞 SSC 邮箱楔死
|
||||
——这是"关闭必须停"这个要求本身的代价。设计细节见 README"关掉 app 必停
|
||||
从站"一节。
|
||||
|
||||
### 新增:首页运行指标卡显示 GD32 CPU 负载率
|
||||
|
||||
- 新指标 `GD32 负载`(`#mGd32Load`):DCU_APP 固件 ≥ 1.5.61 把"中断 +
|
||||
非延迟处理段"占用率写进 EtherCAT 输入 PDO 段 5(1s 滚动窗口,0.1%
|
||||
分辨率),前端经 `/ecat/api/app/load` 轮询显示(`js/ota/ecat_load.js`)。
|
||||
- 与 `/system/status` 轮询完全独立:ESP32 离线互不影响;失败静默退避
|
||||
(1s→5s→30s),不触发页面服务器的自动拉起刷屏;≥85% 琥珀色提示。
|
||||
- 无效语义(显示 `--`):ECAT 总线未打开 / 循环未跑 / 固件 < 1.5.61 /
|
||||
负载值超 3s 未滚动(PDO 循环已停)。
|
||||
|
||||
### 变更:PDO 周期缓存一次性迁移,恢复默认改为"跟随后端"
|
||||
|
||||
- 存量 localStorage `dcu_ecat_period_ms=4`(Python 时代默认,检查脚本
|
||||
收尾也会写入)会在下次启动时自动清除一次(迁移标记
|
||||
`dcu_ecat_period_mig`),避免把 native 已调小的总线默认周期压回 4ms;
|
||||
迁移后用户显式设 4 会被原样保留。
|
||||
- `setPdoPeriod` 的"恢复默认"从 `setPdoPeriod(4)` 改为删除该键、跟随后端
|
||||
默认(固件修好前别低于 1ms)。
|
||||
|
||||
### 修复:电机传输测试对齐生产代码的 `pcore_mask`
|
||||
|
||||
- 自旋节拍钉核显示此前已由"钉 E 核"改为"钉 P 核"并改用快照字段
|
||||
`pcore_mask`,`scripts/test_motor_transport.mjs` 仍是旧字段与旧措辞,
|
||||
本次对齐(含跨 32 位掩码、不连续核号区间等用例)。
|
||||
@@ -101,21 +101,27 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
| 链路 | 默认 | 下限 | 为什么 |
|
||||
|------|------|------|--------|
|
||||
| `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 请求排队
|
||||
(`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
|
||||
旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的,
|
||||
不该套到 ECAT 上。
|
||||
- **ECAT 的 1ms 语义是「不限速」而不是 1000Hz**(`UNTHROTTLED_MAX_MS = 20`)。
|
||||
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_server` → 回来),
|
||||
浏览器里 1ms 档实测约 **125 Hz**。
|
||||
- **ECAT 的 6ms 语义是「不限速」而不是 166Hz**(`UNTHROTTLED_MAX_MS = 20`)。
|
||||
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_native` → 回来),
|
||||
实测均值 ≈1.8ms → 6ms 档约 **160 Hz**,正好贴着本机 160Hz 屏的绘制上限。
|
||||
所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎:
|
||||
1. `tickStuck` 卡顿黄标 —— 该档每拍都会撞上"上一拍还在飞",这是设计行为,
|
||||
再报"连续 N 拍未按时完成"就是误报;
|
||||
2. 「≈N Hz」理论值 —— 1ms 硬算是 1000Hz。这时只显示 `statusHzText`
|
||||
1. `tickStuck` 卡顿黄标 —— 该档贴着往返耗时,尾部抖动就会撞上"上一拍还在飞",
|
||||
这是设计行为,再报"连续 N 拍未按时完成"就是误报;
|
||||
2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText`
|
||||
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 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 会让 Python 把它切成非阻塞模式,而 **Windows 上非阻塞
|
||||
connect 要靠 select 轮询完成,于是撞上约 10ms 的系统调度粒度**。
|
||||
@@ -131,7 +137,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
排查时很容易误判成"EtherCAT 就这么快"。现在 `_ecat_post` 手写最小 HTTP/1.0
|
||||
客户端:`create_connection` 不传 timeout(本机回环要么立刻成功要么立刻
|
||||
`ECONNREFUSED`,不会挂住),连上之后才 `settimeout` 管收发。
|
||||
这一改让 1ms 档从 62 Hz 提到 125 Hz。
|
||||
这一改让当时的 1ms 档从 62 Hz 提到 125 Hz(该档现已按绘制上限抬到 6ms 下限)。
|
||||
- **别把 EtherCAT 当以太网用:PDO 窗口要自适应**(`ecat_server._cover_bases`,已踩过)。
|
||||
从站一个 PDO 只带 7 个状态槽,而槽表有 27 个(9 节点 × 3 通道是**容量**,
|
||||
不是实际接入数)。旧实现每周期把窗口 `+7` 无脑轮扫 4 个窗口,于是
|
||||
@@ -343,17 +349,17 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
||||
其覆盖结论记录在 `tools/ecat/MIGRATION.md`。
|
||||
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
|
||||
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
|
||||
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(53 项,
|
||||
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(55 项,
|
||||
纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、
|
||||
两链路互不污染、不限速谓词边界)。
|
||||
存量 1ms 钳到 6ms、两链路互不污染、不限速谓词边界)。
|
||||
- 浏览器实测(需 headless Chrome/Edge 开 `--remote-debugging-port=9333`,
|
||||
页面服务端口可作第一个参数传入,默认 8080):
|
||||
`node scripts/browser_check_refresh.mjs [port]` 验 DOM 回填与
|
||||
跨刷新持久化(22 项);`node scripts/browser_check_tick.mjs [port]` 数真实请求频率,
|
||||
确认切链路后**实际节拍**跟着变而不只是 state 变了(9 项,实测 20.0/2.0 Hz);
|
||||
`node scripts/browser_check_1ms.mjs [port]` 验 ECAT 1ms 档(24 项:`min`/`step` 同步、
|
||||
通过浏览器原生校验、实测 124 Hz、无卡顿黄标、面板 Hz 不谎报、
|
||||
切 WiFi 时 1ms 被钳到 50)。
|
||||
`node scripts/browser_check_floor.mjs [port]` 验 ECAT 下限档 6ms(26 项:
|
||||
`min`/`step` 同步、通过浏览器原生校验、实测 ≈160 Hz、无卡顿黄标、
|
||||
面板 Hz 不谎报、硬填旧的 1ms 被抬到 6ms、切 WiFi 时 6ms 被钳到 50)。
|
||||
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
|
||||
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
|
||||
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
|
||||
|
||||
+660034
File diff suppressed because it is too large
Load Diff
+651042
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -122,13 +122,13 @@ g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务
|
||||
# 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /api/motor/*,
|
||||
# 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin,
|
||||
# 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON,
|
||||
# 属非简单请求, 浏览器必发预检 → 从本页面(:8080)直接 fetch 它(:8090)必然失败。
|
||||
# 属非简单请求, 浏览器必发预检 → 从本页面(:2625)直接 fetch 它(:8090)必然失败。
|
||||
#
|
||||
# 三种解法里选反向代理:
|
||||
# 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改;
|
||||
# 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让
|
||||
# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求;
|
||||
# 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。
|
||||
# 3) 本服务代理 —— 页面永远同源(:2625), 两个仓库都不用动。
|
||||
# 端口自动探测: EtherCAT 服务(含 native)会在 8090..8095 间漂移。
|
||||
ECAT_PROXY_PREFIX = "/ecat/api/"
|
||||
ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095)
|
||||
@@ -228,10 +228,13 @@ def _ecat_autostart():
|
||||
try:
|
||||
# --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后
|
||||
# 自我了断(不留孤儿占 Npcap/端口)。
|
||||
# --mask 0x10: PDO 自旋线程钉 LP4(i7-14700K P核2), 避开 DPC 集中的
|
||||
# 核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。不钉的话
|
||||
# 自旋线程随调度器漂移, 1ms 档周期抖动明显放大。
|
||||
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
|
||||
g_ecat_child = subprocess.Popen(
|
||||
[ECAT_NATIVE_EXE, "--substr", "I226", "--web", "src/web",
|
||||
"--parent-pid", str(os.getpid())],
|
||||
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10",
|
||||
"--web", "src/web", "--parent-pid", str(os.getpid())],
|
||||
cwd=ECAT_DIR,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
@@ -957,7 +960,7 @@ def _gui_bringToFront():
|
||||
|
||||
def main():
|
||||
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("--window", action="store_true",
|
||||
help="pywebview 桌面窗口模式")
|
||||
|
||||
+177
-94
@@ -332,8 +332,8 @@ function showModal(title, desc) {
|
||||
$("#modalCancel").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(false); };
|
||||
$("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); if (modalResolve) modalResolve(true); };
|
||||
|
||||
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.70) ═══════════════════════
|
||||
* BO_ STATUS DLC=32,信号起止与字节(Intel @1,除非注明 Motorola @0):
|
||||
/* ═══════════════════════ STATUS 帧解码(对齐 DBC Motor_CAN_FD v0.74) ═══════════════════════
|
||||
* BO_ STATUS DLC=48,信号起止与字节(Intel @1,除非注明 Motorola @0):
|
||||
* MCU_temp 0|16@1- ×0.01 → B0-1
|
||||
* 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
|
||||
@@ -348,15 +348,18 @@ $("#modalConfirm").onclick = () => { $("#modalMask").classList.remove("show"); i
|
||||
* test_float 200|32@1- float → B25-28
|
||||
* test_U16 232|16@1+ → B29-30
|
||||
* CPU_Usage 248|8@1+ % → B31
|
||||
* InitStatus 256|32@1+ → B32-35 初始化错误码位图(0=全部正常)
|
||||
*
|
||||
* 旧实现误把 Power 当 B12、float 当 B16/20/24/28,导致角度/速度/电流整体错位乱码。
|
||||
*/
|
||||
function decodeStatus(bytes) {
|
||||
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 copy = new Uint8Array(32);
|
||||
copy.set(u8.subarray(0, 32));
|
||||
const copy = new Uint8Array(36);
|
||||
copy.set(u8.subarray(0, Math.min(36, u8.length)));
|
||||
const dv = new DataView(copy.buffer);
|
||||
const b10 = copy[10];
|
||||
|
||||
@@ -383,6 +386,7 @@ function decodeStatus(bytes) {
|
||||
testFloat: f32(25), /* bit200 → B25 */
|
||||
testU16: dv.getUint16(29, true), /* bit232 → B29 */
|
||||
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 故障位简写(节点卡片右上角,空间紧凑)。
|
||||
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit)。
|
||||
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3"。
|
||||
* 来源 B10:motStatus/magStatus/thermal + EEPROMStatus(3bit),及 InitStatus(B32-35)。
|
||||
* 无故障返回 "";有故障用 · 连接,如 "电机·磁编" / "过温" / "EE3" / "初始化"。
|
||||
*/
|
||||
function faultShortParts(d) {
|
||||
if (!d) return [];
|
||||
@@ -426,6 +430,7 @@ function faultShortParts(d) {
|
||||
if (d.magStatus) parts.push("磁编");
|
||||
if (d.thermalStatus) parts.push("过温");
|
||||
if (d.eepromStatus) parts.push("EE" + d.eepromStatus);
|
||||
if (d.initStatus) parts.push("初始化");
|
||||
return parts;
|
||||
}
|
||||
function faultShortLabel(d) {
|
||||
@@ -766,14 +771,14 @@ function applyTransportRefresh() {
|
||||
state.refreshIntervalMs = v;
|
||||
/* 输入框无条件回填:初始化时 state 恰好与 HTML 默认值相同也要写,
|
||||
* 否则输入框会停在 HTML 里的 250 而实际链路用的是别的值。
|
||||
* min/step 也随链路走:ECAT 允许到 1ms(读本机快照,见 refresh-rate.js),
|
||||
* min/step 也随链路走:ECAT 允许到 6ms(读本机快照,见 refresh-rate.js),
|
||||
* WiFi 仍是 50ms 起。不同步 min 的话,浏览器自带的数字校验会把
|
||||
* 合法的 1ms 标成 :invalid,微调箭头也会停在错误的档位上。 */
|
||||
* 合法值标成 :invalid,微调箭头也会停在错误的档位上。 */
|
||||
const el = $("#refreshRate");
|
||||
if (el) {
|
||||
const lo = minRefreshFor(state.tpId);
|
||||
el.min = String(lo);
|
||||
el.step = lo <= 1 ? "1" : "50"; /* 1ms 档用 1 步进,否则 50 步进太粗 */
|
||||
el.step = isUnthrottled(lo) ? "1" : "50"; /* 不限速档要能逐 ms 微调 */
|
||||
el.value = v;
|
||||
}
|
||||
if (!changed) return v; /* 值没变就别白重排节拍 */
|
||||
@@ -1191,10 +1196,10 @@ function tickHandler() {
|
||||
if (!state.connected || state.probing) return;
|
||||
if (state.tickInFlight) {
|
||||
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
|
||||
* 「不限速」档(如 ECAT 的 1ms)下这是**预期行为**:设定间隔本就小于
|
||||
* 单次往返耗时,节拍源必然比请求快,每拍都会撞上。此时不计卡顿,
|
||||
* 否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",而用户要的
|
||||
* 恰恰就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
* 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
|
||||
* 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
|
||||
* 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
|
||||
* 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
|
||||
if (!isUnthrottled(state.refreshIntervalMs)) {
|
||||
state.tickStuck = (state.tickStuck || 0) + 1;
|
||||
updateTickStuckUi();
|
||||
@@ -1602,8 +1607,9 @@ const RECV_HZ_WINDOW = 10;
|
||||
function statusHzText() {
|
||||
const ts = state.recvTs;
|
||||
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
|
||||
* 「不限速」档要按实测往返而非设定值算这个窗口:1ms × 2 × 10 = 20ms,
|
||||
* 会把刚到的样本全判成"旧数据"清空,Hz 就永远退回理论值(谎报 1000Hz)。
|
||||
* 「不限速」档要按实测往返而非设定值算这个窗口:6ms × 2 × 10 = 120ms,
|
||||
* 一次尾部抖动(>120ms)就把刚到的样本全判成"旧数据"清空,
|
||||
* Hz 会永远退回理论值(谎报 166Hz)。
|
||||
* 这里给不限速档一个固定的 2s 窗口,足够攒满 10 个样本算出真实频率。 */
|
||||
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
|
||||
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";
|
||||
const hz = 1000 / iv;
|
||||
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
|
||||
@@ -1640,10 +1646,27 @@ function updateNodeLabels() {
|
||||
}
|
||||
|
||||
/* ═══════════════════════ 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) {
|
||||
if (!el) return;
|
||||
el._flashUntil = nowMs() + 350;
|
||||
if (el.classList.contains("flash")) return;
|
||||
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() {
|
||||
@@ -1661,81 +1684,95 @@ function renderStatusPanel() {
|
||||
$("#statusLive").classList.toggle("hide", offline);
|
||||
if (offline) {
|
||||
if (!state.connected) {
|
||||
$("#offlineTitle").textContent = "设备未连接";
|
||||
$("#offlineSub").textContent = "本页共用 OTA 主页 IP,请到主页配置并测试连接";
|
||||
setTextIfChanged($("#offlineTitle"), "设备未连接");
|
||||
setTextIfChanged($("#offlineSub"), "本页共用 OTA 主页 IP,请到主页配置并测试连接");
|
||||
} else if (onOtherCh) {
|
||||
const chs = others.map((o) => "CH" + o.ch).sort().join(" / ");
|
||||
$("#offlineTitle").textContent = `节点 ${n} 在 ${chs} 有信号`;
|
||||
$("#offlineSub").textContent = `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`;
|
||||
setTextIfChanged($("#offlineTitle"), `节点 ${n} 在 ${chs} 有信号`);
|
||||
setTextIfChanged($("#offlineSub"), `当前通道 CH${state.ch} 无此节点,请切换顶部通道查看`);
|
||||
} else if (!motor || !motor.online) {
|
||||
$("#offlineTitle").textContent = n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)";
|
||||
$("#offlineSub").textContent = "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写";
|
||||
setTextIfChanged($("#offlineTitle"), n != null ? `节点 ${n} 离线(仍可刷写)` : "节点离线(仍可刷写)");
|
||||
setTextIfChanged($("#offlineSub"), "无 STATUS 属正常(电机在 BOOT / 未上电)。左侧 N1–N9 可直接选中刷写");
|
||||
} else {
|
||||
$("#offlineTitle").textContent = `节点 ${n} 暂无解码数据`;
|
||||
$("#offlineSub").textContent = "等待 STATUS 帧;OTA 刷写不受影响";
|
||||
setTextIfChanged($("#offlineTitle"), `节点 ${n} 暂无解码数据`);
|
||||
setTextIfChanged($("#offlineSub"), "等待 STATUS 帧;OTA 刷写不受影响");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$("#rtAngle").textContent = d.rtAngle.toFixed(2);
|
||||
$("#rtVelocity").textContent = d.rtVelocity.toFixed(1);
|
||||
$("#rtCurrent").textContent = d.rtCurrent.toFixed(0);
|
||||
$("#mcuTemp").textContent = d.mcuTemp.toFixed(2) + " °C";
|
||||
$("#motTemp").textContent = d.motTemp.toFixed(2) + " °C";
|
||||
setTextIfChanged($("#rtAngle"), d.rtAngle.toFixed(2));
|
||||
setTextIfChanged($("#rtVelocity"), d.rtVelocity.toFixed(1));
|
||||
setTextIfChanged($("#rtCurrent"), d.rtCurrent.toFixed(0));
|
||||
setTextIfChanged($("#mcuTemp"), d.mcuTemp.toFixed(2) + " °C");
|
||||
setTextIfChanged($("#motTemp"), d.motTemp.toFixed(2) + " °C");
|
||||
|
||||
/* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host),
|
||||
* 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */
|
||||
const cfg = state.decodedConfig.get(motorKey(motor));
|
||||
if (cfg) {
|
||||
$("#tgtAngle").textContent = cfg.targetAngle.toFixed(1);
|
||||
$("#tgtVelocity").textContent = cfg.targetVelocity.toFixed(0);
|
||||
$("#tgtCurrent").textContent = cfg.targetCurrent.toFixed(0);
|
||||
setTextIfChanged($("#tgtAngle"), cfg.targetAngle.toFixed(1));
|
||||
setTextIfChanged($("#tgtVelocity"), cfg.targetVelocity.toFixed(0));
|
||||
setTextIfChanged($("#tgtCurrent"), cfg.targetCurrent.toFixed(0));
|
||||
} else {
|
||||
const tgt = state.lastSentTargets.get(n);
|
||||
if (tgt) {
|
||||
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 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("tgtVelocity", tgt.velocity.toFixed(0), active.includes("velocity"));
|
||||
fmtTgt("tgtCurrent", tgt.current.toFixed(0), active.includes("current"));
|
||||
} else {
|
||||
$("#tgtAngle").textContent = "--";
|
||||
$("#tgtVelocity").textContent = "--";
|
||||
$("#tgtCurrent").textContent = "--";
|
||||
setTextIfChanged($("#tgtAngle"), "--");
|
||||
setTextIfChanged($("#tgtVelocity"), "--");
|
||||
setTextIfChanged($("#tgtCurrent"), "--");
|
||||
}
|
||||
}
|
||||
|
||||
const mcuPct = clampNum(d.mcuTemp, 0, 100);
|
||||
const motPct = clampNum(d.motTemp, 0, 100);
|
||||
$("#mcuTempBar").style.width = mcuPct + "%";
|
||||
$("#mcuTempBar").className = "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : "");
|
||||
$("#motTempBar").style.width = motPct + "%";
|
||||
$("#motTempBar").className = "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : "");
|
||||
setStyleIfChanged($("#mcuTempBar"), "width", mcuPct + "%");
|
||||
setClassIfChanged($("#mcuTempBar"), "temp-fill" + (d.mcuTemp > 70 ? " hot" : d.mcuTemp > 50 ? " warm" : ""));
|
||||
setStyleIfChanged($("#motTempBar"), "width", motPct + "%");
|
||||
setClassIfChanged($("#motTempBar"), "temp-fill" + (d.motTemp > 70 ? " hot" : d.motTemp > 50 ? " warm" : ""));
|
||||
|
||||
$("#valU").textContent = `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`;
|
||||
$("#valV").textContent = `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`;
|
||||
$("#valW").textContent = `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`;
|
||||
setTextIfChanged($("#valU"), `${d.uCurrent >= 0 ? "+" : ""}${d.uCurrent}`);
|
||||
setTextIfChanged($("#valV"), `${d.vCurrent >= 0 ? "+" : ""}${d.vCurrent}`);
|
||||
setTextIfChanged($("#valW"), `${d.wCurrent >= 0 ? "+" : ""}${d.wCurrent}`);
|
||||
|
||||
$("#pwrVolt").textContent = d.powerVoltage.toFixed(3) + " V";
|
||||
$("#testFloat").textContent = d.testFloat.toFixed(3);
|
||||
$("#testU16").textContent = String(d.testU16);
|
||||
$("#cpuPct").textContent = Math.round(d.cpuUsage) + "%";
|
||||
$("#cpuSub").textContent = `seq ${motor.seq} · rx ${motor.rx_count}`;
|
||||
setTextIfChanged($("#pwrVolt"), d.powerVoltage.toFixed(3) + " V");
|
||||
setTextIfChanged($("#testFloat"), d.testFloat.toFixed(3));
|
||||
setTextIfChanged($("#testU16"), String(d.testU16));
|
||||
setTextIfChanged($("#cpuPct"), Math.round(d.cpuUsage) + "%");
|
||||
setTextIfChanged($("#cpuSub"), `seq ${motor.seq} · rx ${motor.rx_count}`);
|
||||
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) => {
|
||||
$("#" + itemId).className = "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : "");
|
||||
$("#" + ledId).className = "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : "");
|
||||
$("#" + stateId).textContent = active ? (isFault ? faultText : okText) : "—";
|
||||
setClassIfChanged($("#" + itemId), "flag-item " + (active ? (isFault ? "is-fault" : "is-ok") : ""));
|
||||
setClassIfChanged($("#" + ledId), "flag-led" + (active ? (isFault ? " on-err" : " on-ok") : ""));
|
||||
setTextIfChanged($("#" + stateId), active ? (isFault ? faultText : okText) : "—");
|
||||
};
|
||||
/* motStatus:DBC 0=正常 / 1=报错(不是使能位) */
|
||||
setFlag("flagItemMot", "flagMot", "flagMotState", true, d.motStatus === 1, "NORMAL", "FAULT");
|
||||
setFlag("flagItemMag", "flagMag", "flagMagState", true, d.magStatus === 1, "NORMAL", "FAULT");
|
||||
setFlag("flagItemThermal", "flagThermal", "flagThermalState", true, d.thermalStatus === 1, "NORMAL", "过温!");
|
||||
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"));
|
||||
}
|
||||
@@ -1776,16 +1813,16 @@ function updateChSelectOptions() {
|
||||
const en = state.chEnabled[i];
|
||||
const cnt = state.chOnline[i] || 0;
|
||||
const base = "CH" + i;
|
||||
opt.textContent = !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base);
|
||||
opt.style.color = !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : "");
|
||||
setTextIfChanged(opt, !en ? base + " (禁用)" : (cnt > 0 ? `${base} ●${cnt}` : base));
|
||||
setStyleIfChanged(opt, "color", !en ? "var(--fg-dim,#666)" : (cnt > 0 ? "var(--ok,#3ddc97)" : ""));
|
||||
}
|
||||
/* 收起态的选框文本用的是 select 自身颜色(option 内联色仅展开时生效),
|
||||
* 同步按当前选中通道的状态着色,避免选中有信号通道后绿色变回默认白。
|
||||
* 卡顿优先:轮询卡顿时显示黄色,覆盖在线绿色(updateTickStuckUi 触发本函数同步) */
|
||||
const curEn = state.chEnabled[state.ch];
|
||||
const curCnt = state.chOnline[state.ch] || 0;
|
||||
sel.style.color = (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
|
||||
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : ""));
|
||||
setStyleIfChanged(sel, "color", (state.tickStuck || 0) >= TICK_STUCK_WARN ? "var(--warn,#f59e0b)"
|
||||
: (!curEn ? "var(--fg-dim,#666)" : (curCnt > 0 ? "var(--ok,#3ddc97)" : "")));
|
||||
}
|
||||
|
||||
function onFeedback(data) {
|
||||
@@ -3272,7 +3309,7 @@ async function sendMotorPeriod(ms) {
|
||||
}
|
||||
function applyRefreshRate() {
|
||||
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);
|
||||
refreshRateInput.value = v;
|
||||
state.refreshIntervalMs = v;
|
||||
@@ -3289,8 +3326,8 @@ function applyRefreshRate() {
|
||||
schedulePollLoop(state.pollGen, 0); /* Worker 模式只更新间隔;兜底模式立即重排 */
|
||||
sendMotorPeriod(v); /* 同步 GD32 0xC5 推送周期(复用 /spi/send,命令码 0xC5 + 子命令 0xFE) */
|
||||
}
|
||||
/* 极小间隔别报"≈1000 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
|
||||
* (ECAT 每拍一次 HTTP,浏览器里实测约 125Hz)。这时只说"不限速",
|
||||
/* 极小间隔别报"≈166 Hz"那种做不到的数:单飞语义下真实频率由往返耗时决定
|
||||
* (ECAT 每拍一次 HTTP,6ms 档实测约 160Hz,正好贴着屏幕绘制上限)。这时只说"不限速",
|
||||
* 实际到达频率看 STATUS 面板右上角那个按数据到达时间算出来的 Hz。 */
|
||||
const hz = 1000 / v;
|
||||
toast(isUnthrottled(v)
|
||||
@@ -3329,16 +3366,44 @@ const scopeSys = (() => {
|
||||
let C = colors();
|
||||
setInterval(() => { C = colors(); }, 2000);
|
||||
|
||||
function fit(canvas) {
|
||||
const dpr = dprOf();
|
||||
/* 尺寸缓存:fit 每帧调一次 getBoundingClientRect = 每秒 160 次强制同步布局。
|
||||
* 改成 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();
|
||||
if (r.width === 0) return false;
|
||||
if (canvas.width !== Math.round(r.width * dpr) || canvas.height !== Math.round(r.height * dpr)) {
|
||||
canvas.width = Math.round(r.width * dpr);
|
||||
canvas.height = Math.round(r.height * dpr);
|
||||
s = { w: r.width, h: r.height };
|
||||
sizes.set(canvas, s);
|
||||
if (!sizeObs) {
|
||||
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);
|
||||
return true;
|
||||
sizeObs.observe(canvas);
|
||||
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) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.05)";
|
||||
@@ -3351,34 +3416,40 @@ const scopeSys = (() => {
|
||||
/* 分段描线: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) {
|
||||
const pts = [];
|
||||
for (let i = 0; i < BUF; i++) {
|
||||
const idx = (head + i) % BUF;
|
||||
pts.push({ x: i / (BUF - 1) * w, y: yOf(buf[idx]), ok: valid[idx] === 1 });
|
||||
}
|
||||
const xOf = (i) => i / (BUF - 1) * w;
|
||||
for (let i = 0; i < BUF; i++) scratchY[i] = yOf(buf[(head + i) % BUF]);
|
||||
const linePath = (a, b) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[a].x, pts[a].y);
|
||||
for (let i = a + 1; i <= b; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.moveTo(xOf(a), scratchY[a]);
|
||||
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;
|
||||
while (start < BUF - 1) {
|
||||
const solid = pts[start].ok && pts[start + 1].ok;
|
||||
const solid = okAt(start) && okAt(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) {
|
||||
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.fill();
|
||||
}
|
||||
linePath(start, end);
|
||||
ctx.setLineDash(solid ? [] : [5, 4]);
|
||||
ctx.shadowColor = color; ctx.shadowBlur = solid ? 5 : 0;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
/* 发光:同一条路径先描一道宽而淡的底。原来的 shadowBlur 是逐像素模糊,
|
||||
* 视觉上两者等价,成本差一个量级。 */
|
||||
if (solid) {
|
||||
ctx.globalAlpha = 0.16; ctx.lineWidth = lw * 3.2; ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.lineWidth = lw; ctx.stroke();
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
@@ -3395,8 +3466,9 @@ const scopeSys = (() => {
|
||||
const big = $("#scopeCanvas");
|
||||
const bigCtx = big.getContext("2d");
|
||||
function drawBig() {
|
||||
if (!fit(big)) return;
|
||||
const w = big.width / dprOf(), h = big.height / dprOf();
|
||||
const s = fit(big, bigCtx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
bigCtx.clearRect(0, 0, w, h);
|
||||
grid(bigCtx, w, h);
|
||||
trace(bigCtx, hist.u, C.u, 5000, w, h, 1.6, false);
|
||||
@@ -3415,7 +3487,13 @@ const scopeSys = (() => {
|
||||
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 },
|
||||
};
|
||||
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 fmtTick = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "k" : String(v));
|
||||
@@ -3492,8 +3570,9 @@ const scopeSys = (() => {
|
||||
}
|
||||
|
||||
function drawMini(m) {
|
||||
if (!fit(m.el)) return;
|
||||
const w = m.el.width / dprOf(), h = m.el.height / dprOf();
|
||||
const s = fit(m.el, m.ctx);
|
||||
if (!s) return;
|
||||
const w = s.w, h = s.h;
|
||||
/* auto 模式:每帧根据 buf 实际数据重算 Y 轴范围 + ticks */
|
||||
if (m.auto) {
|
||||
const sc = autoScale(m.buf);
|
||||
@@ -3546,13 +3625,17 @@ const scopeSys = (() => {
|
||||
}
|
||||
|
||||
let lastSample = 0;
|
||||
/* 只有写入了新样本才重绘:采样节流在 33ms(≈30Hz),而 rAF 跟着显示器跑到
|
||||
* 160Hz —— 中间那 ~130 帧画的是同一份数据,像素一模一样,纯属白烧 GPU 进程。 */
|
||||
let dirty = true;
|
||||
function loop(ts) {
|
||||
/* 采样节流到 ~30Hz,避免 1Hz 数据下过度密集 */
|
||||
if (ts - lastSample > 33) { sample(); lastSample = ts; }
|
||||
drawBig();
|
||||
for (const [sig, m] of Object.entries(MINIS)) {
|
||||
const card = document.querySelector(`[data-signal="${sig}"]`);
|
||||
if (card && card.classList.contains("open")) drawMini(m);
|
||||
if (ts - lastSample > 33) { sample(); lastSample = ts; dirty = true; }
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
drawBig();
|
||||
for (const m of MINI_LIST) {
|
||||
if (m.card && m.card.classList.contains("open")) drawMini(m);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
*
|
||||
* 为什么两条链路不能共用一个值:往返代价差一个数量级。
|
||||
* wifi 250ms(下限 50ms)—— 走无线 + ESP32 HTTP,还要与 GD32 0xC5 主动推送周期对齐;
|
||||
* ecat 50ms(下限 1ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
|
||||
* ecat 50ms(下限 6ms)—— 只是从本机后台 PDO 快照里读一次(经代理实测均值 ≈ 1.4ms),
|
||||
* 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 请求排队
|
||||
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
|
||||
*/
|
||||
@@ -22,14 +26,15 @@ export const REFRESH_DEFAULTS = { wifi: 250, ecat: 50 };
|
||||
* 每条链路允许的**最小**间隔(ms)。这一项按链路分开,是因为下限的成因不同:
|
||||
* wifi 50ms —— 下限来自链路本身:每拍是一次无线 HTTP 往返,再快只会让请求
|
||||
* 排队积压(tickStuck 亮黄),毫无收益。
|
||||
* ecat 1ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
|
||||
* 注意 1ms **不等于** 1000Hz:单飞语义下上一拍完成才发下一拍,
|
||||
* ecat 6ms —— 读的是本机后台 PDO 快照,没有总线代价,允许"全速跑"。
|
||||
* 注意 6ms **不等于** 166Hz:单飞语义下上一拍完成才发下一拍,
|
||||
* 而每拍要过一次 HTTP(代理 → ecat_native → 回来),实测均值
|
||||
* ≈ 1.4ms,浏览器里 1ms 档实测约 125Hz。填 1 的语义是
|
||||
* ≈ 1.8ms,所以 6ms 档实测约 160Hz —— 正好贴着本机 160Hz 屏的
|
||||
* 绘制上限,再快只是多收一份 HTTP 过路费。填 6 的语义仍是
|
||||
* "不要人为限速",相应地要关掉卡顿黄标与理论 Hz 显示,
|
||||
* 见 UNTHROTTLED_MAX_MS。
|
||||
*/
|
||||
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 1 };
|
||||
export const REFRESH_MIN_BY_TP = { wifi: 50, ecat: 6 };
|
||||
/** 兜底最小值:未知链路 id 时用(防御,正常不该出现)。 */
|
||||
export const REFRESH_MIN_MS = 50;
|
||||
export const REFRESH_MAX_MS = 5000;
|
||||
|
||||
@@ -32,21 +32,22 @@
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致) */
|
||||
const MOT_SLOTS_PER_PDO = 7;
|
||||
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致;
|
||||
* 固件 1.6.4 起槽长 56B = 8B 头 + 48B STATUS,段 2 的 280B 正好放 5 个) */
|
||||
const MOT_SLOTS_PER_PDO = 5;
|
||||
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */
|
||||
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。 */
|
||||
const MOTOR_SCAN_CYCLES = 4;
|
||||
const MOTOR_SCAN_CYCLES = 6;
|
||||
|
||||
/**
|
||||
* 槽 seq 停止增长多久算离线(ms)。
|
||||
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
|
||||
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 4 周期 = 40ms,取 2s 有充足余量。
|
||||
* 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。
|
||||
*/
|
||||
const STALE_OFFLINE_MS = 2000;
|
||||
|
||||
@@ -86,10 +87,10 @@ function fmtPeriod(ms) {
|
||||
export function createEcatTransport(ctx) {
|
||||
const { recordSlow, isTransient } = ctx;
|
||||
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I226);
|
||||
/* 网卡匹配子串。与 ecat.html 的输入框默认值一致(I210,2026-09 网卡 I226→I210);
|
||||
* 存 localStorage 以便换网卡后不用每次重设。 */
|
||||
const LS_ADAPTER = "dcu_ecat_adapter";
|
||||
let adapterSubstr = "I226";
|
||||
let adapterSubstr = "I210";
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_ADAPTER);
|
||||
if (saved && saved.trim()) adapterSubstr = saved.trim();
|
||||
@@ -193,7 +194,7 @@ export function createEcatTransport(ctx) {
|
||||
async function ensureOpen() {
|
||||
const st = await api("status", {}, 6000);
|
||||
if (st && st.ok === true) return st; // 已连接
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I226);
|
||||
/* 未连接 → 打开网卡。substr 与 ecat.html 的默认值一致(I210);
|
||||
* 换网卡的场合由 setAdapter() 覆盖,存 localStorage 供下次复用。 */
|
||||
const r = await api("open", { substr: adapterSubstr }, 20000);
|
||||
if (!r || r.ok !== true) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 从 motor/main.js 里原地抽出的**原有**链路,行为未改:
|
||||
* GET /system/status 探测 + 健康检查(lite/full 交替)
|
||||
* GET /motor/state?bin=1 电机状态(二进制,~408B)
|
||||
* GET /motor/state?bin=1 电机状态(二进制,27 槽满 ~2508B)
|
||||
* GET /can/feedback?lite=1 CAN 计数/总线状态
|
||||
* POST /can/send_batch 发送(唯一入口)
|
||||
* POST /can/send_burst 突发同时发送(多轴)
|
||||
@@ -52,6 +52,10 @@ export function createWifiTransport(ctx) {
|
||||
}
|
||||
|
||||
/* /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[]}。
|
||||
* data / config_data 保持 hex 字符串,让上层 hexToBytes 直接解析。 */
|
||||
function parseMotorStateBinary(ab) {
|
||||
@@ -59,7 +63,12 @@ export function createWifiTransport(ctx) {
|
||||
if (!ab || ab.byteLength < 12) return out;
|
||||
const dv = new DataView(ab);
|
||||
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 age = dv.getUint32(4, true);
|
||||
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();
|
||||
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({
|
||||
node: u8[off],
|
||||
ch: u8[off + 1],
|
||||
@@ -85,12 +94,12 @@ export function createWifiTransport(ctx) {
|
||||
dlc: u8[off + 3],
|
||||
rx_count: dv.getUint32(off + 4, true),
|
||||
seq: dv.getUint32(off + 8, true),
|
||||
data: hexOf(off + 12, 32),
|
||||
config_rx_count: dv.getUint32(off + 44, true),
|
||||
config_seq: dv.getUint32(off + 48, true),
|
||||
config_data: hexOf(off + 52, 24),
|
||||
data: hexOf(off + 12, dataLen),
|
||||
config_rx_count: dv.getUint32(off + cfgCntOff, true),
|
||||
config_seq: dv.getUint32(off + cfgSeqOff, true),
|
||||
config_data: hexOf(off + cfgSeqOff + 4, 24),
|
||||
});
|
||||
off += 76;
|
||||
off += entry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+45
-4
@@ -199,8 +199,15 @@ export async function resumePersistedFirmwareWithGesture(chip) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
if (!accepted) {
|
||||
/* 文件内容没过 GD32 身份校验(applyRestoredFile 已清句柄/共享槽/IDB):
|
||||
* 不得报成功,返回 false 让 pickFileViaFSAPI 继续弹文件选择框,
|
||||
* 否则用户点了上传区只会反复恢复这个非法文件,永远选不了新文件 */
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 上次固件未通过校验,已放弃恢复,请重新选择文件');
|
||||
return false;
|
||||
}
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
|
||||
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
|
||||
return true;
|
||||
@@ -221,13 +228,28 @@ function applyRestoredFile(chip, handle, file, options) {
|
||||
if (window.sharedFirmwareFiles) {
|
||||
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 || {})))
|
||||
.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') {
|
||||
window.syncBleFileFromSelectedTarget();
|
||||
}
|
||||
safeUpdateUploadButtonLabel();
|
||||
return accepted;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,9 +297,14 @@ export async function restorePersistedFirmwareSelection() {
|
||||
var perm = await queryHandlePermission(handle);
|
||||
if (perm === 'granted') {
|
||||
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;
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
|
||||
if (accepted) {
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
|
||||
} else {
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 上次固件「' + file.name +
|
||||
'」未通过 GD32 校验,已拒绝恢复,请重新选择文件');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
/* prompt/denied:不在此处 requestPermission(无用户手势会失败) */
|
||||
@@ -371,6 +398,16 @@ export async function pickFileViaFSAPI(chip) {
|
||||
else window.gd32FileHandle = handle;
|
||||
window._pendingFwHandles[chip] = null;
|
||||
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);
|
||||
persistFileMeta(chip, file);
|
||||
persistFlashTarget(chip);
|
||||
@@ -560,6 +597,9 @@ function rejectGd32Identity(fileName, kind) {
|
||||
showToast(msg, 'error');
|
||||
window.gd32File = null;
|
||||
window._gd32FileSha256 = null;
|
||||
/* 元数据一并清掉:processFile 入口已把本次(被拒)文件写进 meta,
|
||||
* 不清的话下次刷新会拿这个非法文件名当"上次固件"展示 */
|
||||
persistFileMeta('gd32', null);
|
||||
var info = document.getElementById('fileInfoGd32');
|
||||
if (info) info.classList.remove('show');
|
||||
safeUpdateUploadButtonLabel();
|
||||
@@ -674,6 +714,7 @@ export function processFile(file, chip, options) {
|
||||
if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
|
||||
window.gd32File = null;
|
||||
window._gd32FileSha256 = null;
|
||||
persistFileMeta(chip, null);
|
||||
var info = document.getElementById('fileInfo' + suffix);
|
||||
if (info) info.classList.remove('show');
|
||||
safeUpdateUploadButtonLabel();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
5246fff769cad8c1356ddbeebe2082e0e8bce592f19c38ad9629189132a1674b
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
main/monitor/spi_speed_test.cpp 文件头)。
|
||||
|
||||
用法:浏览器直接打开本文件,或经 serve.bat 访问
|
||||
http://localhost:8080/spi_test.html
|
||||
http://localhost:2625/spi_test.html
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
|
||||
+4
-3
@@ -36,8 +36,8 @@
|
||||
<option value="ecat">EtherCAT</option>
|
||||
</select>
|
||||
<input type="text" id="ecatAdapter" class="refresh-input" style="width:74px;display:none"
|
||||
value="I226" aria-label="EtherCAT 网卡匹配子串"
|
||||
title="EtherCAT 网卡描述匹配子串(默认 I226)。改这里会立即重连该网口,设置存本地。" />
|
||||
value="I210" aria-label="EtherCAT 网卡匹配子串"
|
||||
title="EtherCAT 网卡描述匹配子串(默认 I210)。改这里会立即重连该网口,设置存本地。" />
|
||||
</div>
|
||||
<div class="toolbar-sep"></div>
|
||||
<div class="conn-group">
|
||||
@@ -49,7 +49,7 @@
|
||||
</select>
|
||||
</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>
|
||||
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒,按链路分别记忆)" />
|
||||
<span class="refresh-hint">ms</span>
|
||||
@@ -368,6 +368,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="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="flagItemInit"><span class="flag-led" id="flagInit"></span>InitStatus 初始化<span class="flag-state" id="flagInitState">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz
|
||||
* - 输入框 min/step 随链路变(否则浏览器把合法的 6ms 标成 :invalid)
|
||||
* - 6ms 真的比 50ms 快,且拿到实测频率而不是理论 166Hz
|
||||
* - 存量缓存里的旧 1ms 会被抬到 6ms(绘制上限之上没有消费者)
|
||||
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
|
||||
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms)
|
||||
* - 切回 WiFi 时 6ms 不会被带过去(wifi 下限 50ms)
|
||||
*/
|
||||
const CDP = "http://127.0.0.1:9333";
|
||||
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 : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT */
|
||||
@@ -49,28 +50,35 @@ const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn:
|
||||
out.push("\n[前置] " + 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})`));
|
||||
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom));
|
||||
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom));
|
||||
check("min=6(否则浏览器校验会把 6ms 判成非法)", dom.min === "6", JSON.stringify(dom));
|
||||
check("step=1(下限档要能逐 ms 微调)", dom.step === "1", JSON.stringify(dom));
|
||||
|
||||
out.push("\n[B] 基线:ECAT 默认 50ms");
|
||||
const hz50 = await measure(3);
|
||||
out.push(` 实测 ${hz50.toFixed(1)} Hz`);
|
||||
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
|
||||
|
||||
out.push("\n[C] 设成 1ms —— 应被接受且明显更快");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
out.push("\n[C] 设成 6ms —— 应被接受且明显更快");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="6"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
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})`));
|
||||
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st));
|
||||
check("输入框显示 1", st.val === "1", JSON.stringify(st));
|
||||
check("6ms 被接受(没被钳回 50)", st.eff === 6, JSON.stringify(st));
|
||||
check("输入框显示 6", st.val === "6", 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);
|
||||
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`);
|
||||
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
|
||||
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`);
|
||||
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,6ms 档约 160Hz 量级)`);
|
||||
check("6ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `6ms=${hz1.toFixed(1)} vs 50ms=${hz50.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] 不限速档不应亮卡顿黄标");
|
||||
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("没有误导性的卡顿提示文案", !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`);
|
||||
out.push(` 面板显示: ${hzText}`);
|
||||
const m = String(hzText).match(/([\d.]+)\s*Hz/);
|
||||
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
|
||||
if (m) {
|
||||
const shown = parseFloat(m[1]);
|
||||
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`);
|
||||
check("显示的不是谎报的理论值", shown < 200, `显示 ${shown}Hz`);
|
||||
check("显示值与实测频率同量级(±60%)",
|
||||
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 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})`));
|
||||
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 下 step 收回 50", st.step === "50", JSON.stringify(st));
|
||||
/* 在 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})`));
|
||||
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 sleep(2500);
|
||||
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("min 重新放开到 1", st.min === "1", JSON.stringify(st));
|
||||
/* 刷新页面后 1ms 应持久化 */
|
||||
check("ECAT 生效值回到 6ms", st.eff === 6, JSON.stringify(st));
|
||||
check("min 重新放开到 6", st.min === "6", JSON.stringify(st));
|
||||
/* 刷新页面后 6ms 应持久化 */
|
||||
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})`));
|
||||
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));
|
||||
|
||||
console.log(out.join("\n"));
|
||||
@@ -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 : "")); } };
|
||||
|
||||
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`;
|
||||
|
||||
/* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */
|
||||
|
||||
@@ -44,8 +44,8 @@ function check(name, cond, detail) {
|
||||
await send("Runtime.enable");
|
||||
await send("Page.enable");
|
||||
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
async function goto(u) {
|
||||
|
||||
@@ -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 : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ECAT 与 WiFi 两条链路对同一 seq 的 48B STATUS 做逐字节对照。
|
||||
|
||||
两条链路读的是 GD32 里同一张 MotorState 表,但读取上下文不同:
|
||||
* ECAT 段2 由 buildInput 取表,跑在 PDI_Isr —— 会抢占主循环的写侧(本次修复点);
|
||||
* WiFi 0xC5/buildPayload 与写侧同在主循环 —— 结构上不可能读到中间态。
|
||||
所以 WiFi 侧可当参照:同一 seq 在两边必须逐字节相同(seq 由 global_seq 唯一确定,
|
||||
一次更新只产生一个 seq)。不等 ⇒ ECAT 侧那一份是没写完的半成品。
|
||||
|
||||
用法: python scripts/probe_ecat_vs_wifi.py [秒数]
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8090
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
|
||||
ESP = "192.168.31.127"
|
||||
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
|
||||
"Content-Type: application/json\r\nContent-Length: 2\r\n"
|
||||
"Connection: close\r\n\r\n{}").encode("ascii")
|
||||
|
||||
|
||||
def ecat_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"))
|
||||
|
||||
|
||||
def wifi_once():
|
||||
"""返回 {node: (seq, 48B)}。协议 ver=2:entry 92B,data 在 +12。"""
|
||||
b = urllib.request.urlopen(f"http://{ESP}/motor/state?bin=1", timeout=3).read()
|
||||
if len(b) < 12 or struct.unpack_from("<H", b, 0)[0] != 0x4D53 or b[2] != 2:
|
||||
return {}
|
||||
out, off = {}, 12
|
||||
for _ in range(b[9]):
|
||||
if off + 92 > len(b):
|
||||
break
|
||||
out[b[off]] = (struct.unpack_from("<I", b, off + 8)[0],
|
||||
b[off + 12:off + 60])
|
||||
off += 92
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cache = {} # node -> {seq: payload}(ECAT 侧见过的每一帧)
|
||||
matched = mism = polls = wpolls = 0
|
||||
bad = []
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < DUR_S:
|
||||
try:
|
||||
snap = ecat_once()
|
||||
polls += 1
|
||||
for m in snap.get("motors") or []:
|
||||
if m.get("online") and m.get("data"):
|
||||
cache.setdefault(m["node"], {})[m["seq"]] = bytes.fromhex(m["data"])
|
||||
if len(cache[m["node"]]) > 4000:
|
||||
for k in sorted(cache[m["node"]])[:2000]:
|
||||
del cache[m["node"]][k]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
w = wifi_once()
|
||||
wpolls += 1
|
||||
except Exception:
|
||||
continue
|
||||
for node, (seq, payload) in w.items():
|
||||
d = cache.get(node, {}).get(seq)
|
||||
if d is None:
|
||||
continue # 该 seq 未被 ECAT 侧采到,跳过
|
||||
matched += 1
|
||||
if d != payload:
|
||||
mism += 1
|
||||
bad.append((round(time.time() - t0, 2), node, seq, d.hex(), payload.hex()))
|
||||
print(f"轮询 ECAT {polls} 次 / WiFi {wpolls} 次 / {DUR_S:.0f}s")
|
||||
print(f"两侧同 seq 可比对 = {matched} 逐字节不一致 = {mism}")
|
||||
for t, node, seq, e, w in bad[:6]:
|
||||
j = next((i for i in range(48) if e[i] != w[i]), 48)
|
||||
print(f" t={t}s node={node} seq={seq} 首字节差={j}")
|
||||
print(f" ECAT={e}")
|
||||
print(f" WiFi={w}")
|
||||
if not matched:
|
||||
print("功效不足:ECAT 帧率远高于 WiFi 采样,同 seq 交集为 0,本次判定不作数。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 ECAT 侧收到的每一拍信号全量落盘,供事后分析(不在线判优劣)。
|
||||
|
||||
去重规则只有一条:同一 (node,seq) 或同一 (id,data) 帧只在第一次出现时写一行 ——
|
||||
重复采样不携带新信息,去掉后可以把时长拉长。其余一律保留。
|
||||
|
||||
行格式(CSV,第 1 列是相对起始的秒):
|
||||
M,t,node,ch,seq,online,dlc,hex36 段2 电机槽(STATUS 前 36B,含 cpu%@31/init@32-35)
|
||||
F,t,kind,ch,dlc,id,hex36 段3 xrx / 段4 rx_batch 的帧
|
||||
H,t,cycles,wkc,gseq,win_base,win_count,active,rx,tx,drop 每 20 拍一条头部与桥计数
|
||||
|
||||
用法: python scripts/rec_ecat_slots.py [秒数=120] [端口=8090] [输出文件]
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 120.0
|
||||
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
|
||||
OUT = sys.argv[3] if len(sys.argv) > 3 else "ecat_slots_raw.csv"
|
||||
|
||||
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
|
||||
"Content-Type: application/json\r\nContent-Length: 2\r\n"
|
||||
"Connection: close\r\n\r\n{}").encode("ascii")
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
def main():
|
||||
seen_m, seen_f = set(), set()
|
||||
polls = rows = 0
|
||||
t0 = time.time()
|
||||
with open(OUT, "w", newline="", encoding="ascii") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["#kind", "t", "a", "b", "c", "d", "e", "f"])
|
||||
while True:
|
||||
dt = time.time() - t0
|
||||
if dt >= DUR_S:
|
||||
break
|
||||
try:
|
||||
snap = once()
|
||||
except Exception:
|
||||
continue
|
||||
polls += 1
|
||||
h = snap.get("head") or {}
|
||||
b = snap.get("bridge") or {}
|
||||
if polls % 20 == 1:
|
||||
w.writerow(["H", f"{dt:.6f}", snap.get("cycles"), snap.get("wkc"),
|
||||
h.get("global_seq"), h.get("win_base"), h.get("win_count"),
|
||||
h.get("active"), b.get("rx_count"), b.get("tx_count"),
|
||||
b.get("drop")])
|
||||
for m in snap.get("motors") or []:
|
||||
if not m.get("data"):
|
||||
continue
|
||||
key = (m["node"], m["ch"], m["seq"])
|
||||
if key in seen_m:
|
||||
continue
|
||||
seen_m.add(key)
|
||||
if len(seen_m) > 400000: # 去重集合本身的内存上限
|
||||
seen_m = set(list(seen_m)[-200000:])
|
||||
d = bytes.fromhex(m["data"])[:36]
|
||||
w.writerow(["M", f"{dt:.6f}", m["node"], m["ch"], m["seq"],
|
||||
int(bool(m.get("online"))), m.get("dlc"), d.hex()])
|
||||
rows += 1
|
||||
def emit_frames(fr, kind):
|
||||
if not fr or not fr.get("data"):
|
||||
return
|
||||
d = bytes.fromhex(fr["data"])[:36]
|
||||
key = (fr.get("id"), d.hex())
|
||||
if key in seen_f:
|
||||
return
|
||||
seen_f.add(key)
|
||||
w.writerow([kind, f"{dt:.6f}", fr.get("ch"), fr.get("dlc"),
|
||||
fr.get("id"), "", "", d.hex()])
|
||||
|
||||
emit_frames(snap.get("xrx"), "X")
|
||||
for x in ((snap.get("rx_batch") or {}).get("frames") or []):
|
||||
emit_frames(x, "B")
|
||||
if len(seen_f) > 400000:
|
||||
seen_f = set(list(seen_f)[-200000:])
|
||||
print(f"落盘 {OUT}: 轮询 {polls} 次 / {DUR_S:.0f}s,写入 {rows} 行 "
|
||||
f"(电机槽 {len(seen_m)} 帧, 接收帧 {len(seen_f)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user