feat(ecat): PDO 快发 ESF1 桌面强制直通,堵死慢发回落
桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP; 删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。
This commit is contained in:
@@ -75,11 +75,18 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
|
|||||||
| 链路 | 后端 | 状态 | 发送 |
|
| 链路 | 后端 | 状态 | 发送 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `wifi` | ESP32 固件 80 端口 | `GET /motor/state?bin=1` | `POST /can/send_batch` / `send_burst` |
|
| `wifi` | ESP32 固件 80 端口 | `GET /motor/state?bin=1` | `POST /can/send_batch` / `send_burst` |
|
||||||
| `ecat` | `tools/ecat` 的 `ecat_native.exe`(C/SOEM 守护进程,经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | `POST /api/motor/send`(多帧同一 seq 沿) |
|
| `ecat` | `tools/ecat` 的 `ecat_native.exe`(C/SOEM 守护进程,经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | **桌面 APP:只走 ESF1 二进制快发**(`ecat_send_raw` → TCP `fast_send_port`,**失败不回落 HTTP**);浏览器标签:`POST /ecat/api/motor/send` |
|
||||||
|
|
||||||
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
|
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
|
||||||
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
|
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
|
||||||
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
|
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
|
||||||
|
- **PDO 快发(pdo-fast-send)**:桌面壳(`esp32_app`)下 ECAT 全部 motor 发送
|
||||||
|
**强制** `pywebview.api.ecat_send_raw` → GUI ESF1 → native 持久 TCP 入队。
|
||||||
|
**已删除** Python 桥内的 HTTP send_raw / JSON motor/send 回落,前端在有桥时
|
||||||
|
也不再回落 JSON——快发失败直接报错,防止误走慢路径。浏览器无壳仍用 HTTP。
|
||||||
|
真机对照:TCP FSOK p50≈0.08ms;HTTP 常见 8–12ms 尾部。规格:
|
||||||
|
`docs/compose/spec/pdo-fast-send.md`。测量:`python scripts/check_esf_latency.py`
|
||||||
|
与 `scripts/check_latency_breakdown.py`。
|
||||||
- **为什么要 `/ecat/api/*` 反向代理**:EtherCAT 后端只绑 `127.0.0.1` 且没有 CORS
|
- **为什么要 `/ecat/api/*` 反向代理**:EtherCAT 后端只绑 `127.0.0.1` 且没有 CORS
|
||||||
(无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501),
|
(无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501),
|
||||||
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
|
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
feature: pdo-fast-send
|
||||||
|
status: delivered
|
||||||
|
updated: 2026-09-23
|
||||||
|
branch: main
|
||||||
|
commits: working-tree (uncommitted; dual-repo main authorized)
|
||||||
|
---
|
||||||
|
|
||||||
|
# PDO 快发(网页按钮 → 原生入队二进制直通)
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
**What was built** — 桌面 APP(esp32_app)下,电机页 EtherCAT 链路的**全部** motor
|
||||||
|
发送**强制**走 ESF1 二进制快发:JS `desktopEcatSendRaw` → GUI 进程
|
||||||
|
`ecat_send_raw` 打包 → native 持久 TCP 口(health.`fast_send_port`)入 `g_cmd_q`。
|
||||||
|
**已堵死慢发**:Python 桥删除 HTTP `send_raw` / JSON `motor/send` 回落;前端在
|
||||||
|
`hasDesktopFastSend()` 为真时失败直接 `ok:false`,不再 `api("motor/send")`。
|
||||||
|
浏览器无壳标签仍走 HTTP `/ecat/api/motor/send`(脚本/诊断同理)。布局:请求
|
||||||
|
magic `ESF1` + nframes + 72B/帧;响应 `FSOK` 8 字节。
|
||||||
|
|
||||||
|
**Verification** —
|
||||||
|
- `build_native.bat` PASS;health `fast_send_port` 存在,loop=1 wkc=3
|
||||||
|
- `scripts/test_esf_pack.py` PASS;`py_compile esp32_server.py` PASS
|
||||||
|
- `node --check` desktop-bridge / transport-ecat / transport / main PASS
|
||||||
|
- `scripts/check_esf_latency.py`:TCP FSOK p50≈0.046–0.08ms(HTTP 对照仍在 native 路由上)
|
||||||
|
- 源码检查:`ecat_send_raw_frames` 无 `motor/send`/`http_send_raw` 回落;
|
||||||
|
`sendViaFastOrJson` 有桥时不调 `api("motor/send")`
|
||||||
|
- `test_motor_transport.mjs` 68/68 PASS(Node 无 pywebview → 走 HTTP 契约路径)
|
||||||
|
- `test_motor_refresh.mjs` 62/62 PASS
|
||||||
|
- 独立评审 CRITICAL/MAJOR(锁/静态缓冲/端口缓存)此前已修复
|
||||||
|
|
||||||
|
**Journey log** —
|
||||||
|
- 用户期望「纳秒进 PDO」不可达;可压的是控制面,入队后仍是 250µs 一拍 PDO
|
||||||
|
- pywebview JS→Python hop 仍在,TCP 快发已把 native 侧压到 ~50–80µs
|
||||||
|
- 沙箱拦 `git worktree add` → 用户明确授权双仓 main
|
||||||
|
- 持久 TCP 必须 send+recv 整段持锁,否则 FSOK 错配
|
||||||
|
- **2026-09-23:用户要求堵死慢发**——桌面强制 ESF1、桥内删除 HTTP/JSON 回落;
|
||||||
|
浏览器/脚本仍保留 native HTTP 路由
|
||||||
|
|
||||||
|
## [S1] Problem
|
||||||
|
|
||||||
|
电机控制台在 EtherCAT 链路上点发送时,信号要走很长控制面才进 `ecat_native`
|
||||||
|
的 `g_cmd_q`:浏览器 fetch→代理→JSON,桌面 APP 再叠 pywebview 桥。用户要
|
||||||
|
「一点按钮极短 CPU 周期内进 PDO」。纳秒不可达;native 入队后到总线由 PDO
|
||||||
|
周期决定(250µs 档),可压缩的是控制面。HTTP 路径还存在 **8–12ms 调度尾部**,
|
||||||
|
必须防止桌面壳误走慢路径。
|
||||||
|
|
||||||
|
## [S2] Design
|
||||||
|
|
||||||
|
### 目标行为(2026-09-23 修订:堵死慢发)
|
||||||
|
|
||||||
|
1. 桌面 APP + 桥可用:ECAT 全部 motor 发送 **只走** ESF1 TCP;失败 `ok:false`,
|
||||||
|
**无** HTTP/JSON 回落。
|
||||||
|
2. Python `ecat_send_raw_frames`:**仅** TCP ESF1(失败可重探端口后再试 TCP 一次)。
|
||||||
|
3. 浏览器标签(无 pywebview):仍走 `/ecat/api/motor/send`。
|
||||||
|
4. native `/api/motor/send` 与 `/api/motor/send_raw` **保留**(浏览器/诊断脚本)。
|
||||||
|
5. UI 不新增专用开关;`sendHint` / `desktopBridgeLabel` 反映通道形态。
|
||||||
|
6. 不承诺纳秒。
|
||||||
|
|
||||||
|
### 工作区(用户授权覆盖)
|
||||||
|
|
||||||
|
- ESP32 与 tools/ecat 均在 **main** 上改(2026-09-23 授权;沙箱拦 worktree add)。
|
||||||
|
- native:`tools/ecat/ecat_native.c` + `build_native.bat`。
|
||||||
|
|
||||||
|
### 二进制帧布局(小端)
|
||||||
|
|
||||||
|
**请求**:
|
||||||
|
|
||||||
|
```
|
||||||
|
u32 magic = 0x31465345 /* ESF1 */
|
||||||
|
u8 nframes /* 1..64 */
|
||||||
|
u8 flags
|
||||||
|
u16 reserved
|
||||||
|
repeat nframes: /* 72B each */
|
||||||
|
u8 ch, u8 dlc, u16 pad, u32 id, u8 data[64]
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:`u32 FSOK(0x4B4F5346) | u8 ok | u8 queued | u16 backlog`
|
||||||
|
|
||||||
|
### Native
|
||||||
|
|
||||||
|
- `127.0.0.1` `bind(0)` 快发口;health 增加 `fast_send_port`。
|
||||||
|
- 持久连接读 ESF1 → `parse_esf` → 共用 `enqueue_frames` → FSOK。
|
||||||
|
- HTTP `/api/motor/send_raw` 与 JSON `/api/motor/send` 仍服务浏览器/脚本。
|
||||||
|
- `send_raw` 解析缓冲为栈上(HTTP 多线程)。
|
||||||
|
|
||||||
|
### GUI 桥(esp32_server.py)
|
||||||
|
|
||||||
|
- `pack_esf` / `unpack_fsok`;`_GuiApi.ecat_send_raw`。
|
||||||
|
- **仅**持久 TCP;connect+sendall+recv 整段持锁。
|
||||||
|
- 失败:关 socket + `g_fast_send_port=0` → 重探后再试 TCP 一次 → 仍败则 ok:false。
|
||||||
|
- **无** HTTP/JSON 回落代码。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- `desktop-bridge.js`:`desktopEcatSendRaw` / `hasDesktopFastSend`。
|
||||||
|
- `transport-ecat.js`:`sendViaFastOrJson` 有桥时只调快发。
|
||||||
|
- 缓存版本 `?v=20260923i`。
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- 纳秒;共享内存;预装载+位触发;浏览器强制禁 HTTP;删除 native JSON 路由;
|
||||||
|
改 PDO 周期;WiFi 链路。
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] T1: native 快发口 + `/api/motor/send_raw` + health 字段 (covers: S2)
|
||||||
|
- [x] T2: Python 打包 + `ecat_send_raw` TCP-only(无回落) (covers: S2; depends: T1)
|
||||||
|
- [x] T3: desktop-bridge + transport-ecat 有桥强制快发 (covers: S2; depends: T2)
|
||||||
|
- [x] T4: 延迟脚本 + 文档 + 堵死慢发修订 (covers: S2; depends: T1)
|
||||||
+201
-5
@@ -44,6 +44,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -189,6 +190,177 @@ g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已
|
|||||||
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
|
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
|
||||||
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
||||||
|
|
||||||
|
# ── PDO 快发(ESF1)── tools/ecat ecat_native 的二进制直通 ──────────
|
||||||
|
# 布局与 docs/compose/spec/pdo-fast-send.md 一致。**仅** native 专用 TCP 口
|
||||||
|
# (health.fast_send_port);2026-09-23 堵死慢发:已删除 HTTP/JSON 回落。
|
||||||
|
# 持久连接:连接阶段不设 timeout(Windows 非阻塞 connect 会撞
|
||||||
|
# ~10ms 调度粒度,见 _ecat_post 注释);连上后再 settimeout。
|
||||||
|
ESF_MAGIC = 0x31465345
|
||||||
|
FSOK_MAGIC = 0x4B4F5346
|
||||||
|
ESF_FRAME_W = 72
|
||||||
|
g_fast_send_port = 0 # 从 native health 发现的快发 TCP 口;0=未知/不可用
|
||||||
|
g_fast_sock = None
|
||||||
|
g_fast_sock_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_bytes64(data) -> bytes:
|
||||||
|
"""帧 data → 64 字节(短则右侧补 0)。"""
|
||||||
|
if data is None:
|
||||||
|
return b"\x00" * 64
|
||||||
|
if isinstance(data, (bytes, bytearray)):
|
||||||
|
b = bytes(data)[:64]
|
||||||
|
return b + b"\x00" * (64 - len(b))
|
||||||
|
s = str(data).strip().replace(" ", "")
|
||||||
|
if s.lower().startswith("0x"):
|
||||||
|
s = s[2:]
|
||||||
|
if len(s) % 2:
|
||||||
|
s = "0" + s
|
||||||
|
b = bytes.fromhex(s)[:64] if s else b""
|
||||||
|
return b + b"\x00" * (64 - len(b))
|
||||||
|
|
||||||
|
|
||||||
|
def pack_esf(frames) -> bytes:
|
||||||
|
"""frames=[{ch,id,dlc,data(hex)}] → ESF1 请求体。"""
|
||||||
|
items = list(frames or [])
|
||||||
|
n = len(items)
|
||||||
|
if n < 1 or n > 64:
|
||||||
|
raise ValueError(f"frames 数量必须 1..64,收到 {n}")
|
||||||
|
out = bytearray(struct.pack("<IBBH", ESF_MAGIC, n, 0, 0))
|
||||||
|
for fr in items:
|
||||||
|
if not isinstance(fr, dict):
|
||||||
|
raise ValueError(f"帧必须是对象,收到 {type(fr).__name__}")
|
||||||
|
ch = int(fr.get("ch", 0))
|
||||||
|
dlc = int(fr.get("dlc", 0) or 0)
|
||||||
|
if ch < 0 or ch > 2:
|
||||||
|
raise ValueError(f"通道必须是 0/1/2,收到 {ch}")
|
||||||
|
if dlc < 0 or dlc > 64:
|
||||||
|
raise ValueError(f"DLC 超出 CANFD 上限 64: {dlc}")
|
||||||
|
fid = fr.get("id", 0)
|
||||||
|
if isinstance(fid, str):
|
||||||
|
fid = int(fid, 0)
|
||||||
|
data = _hex_to_bytes64(fr.get("data"))
|
||||||
|
out += struct.pack("<BBHI", ch & 0xFF, dlc & 0xFF, 0, int(fid) & 0xFFFFFFFF)
|
||||||
|
out += data
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_fsok(resp8: bytes) -> dict:
|
||||||
|
if not resp8 or len(resp8) < 8:
|
||||||
|
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK 响应过短"]}
|
||||||
|
magic, ok, queued, backlog = struct.unpack_from("<IBBH", resp8, 0)
|
||||||
|
if magic != FSOK_MAGIC:
|
||||||
|
return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK magic 不匹配"]}
|
||||||
|
return {
|
||||||
|
"ok": bool(ok),
|
||||||
|
"queued": int(queued),
|
||||||
|
"backlog": int(backlog),
|
||||||
|
"log": [] if ok else ["native 快发拒绝(未连接/解析失败)"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _note_fast_port(obj) -> None:
|
||||||
|
global g_fast_send_port
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
p = obj.get("fast_send_port")
|
||||||
|
if isinstance(p, (int, float)) and int(p) > 0:
|
||||||
|
g_fast_send_port = int(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_fast_send_port(timeout=2.0, force=False) -> int:
|
||||||
|
global g_fast_send_port
|
||||||
|
if g_fast_send_port and not force:
|
||||||
|
return g_fast_send_port
|
||||||
|
try:
|
||||||
|
_status, data = _ecat_proxy(ECAT_PROXY_PREFIX + "native/health", b"{}", timeout)
|
||||||
|
obj = json.loads(data.decode("utf-8", "replace") or "{}")
|
||||||
|
_note_fast_port(obj)
|
||||||
|
if force and not isinstance(obj, dict):
|
||||||
|
g_fast_send_port = 0
|
||||||
|
except Exception:
|
||||||
|
if force:
|
||||||
|
g_fast_send_port = 0
|
||||||
|
return g_fast_send_port
|
||||||
|
|
||||||
|
|
||||||
|
def _close_fast_sock() -> None:
|
||||||
|
global g_fast_sock
|
||||||
|
with g_fast_sock_lock:
|
||||||
|
s = g_fast_sock
|
||||||
|
g_fast_sock = None
|
||||||
|
if s is not None:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fast_sock_connect(port: int, timeout: float):
|
||||||
|
sock = socket.create_connection(("127.0.0.1", int(port)), timeout=None)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sock
|
||||||
|
|
||||||
|
|
||||||
|
def ecat_send_raw_frames(frames, timeout_ms=2000) -> dict:
|
||||||
|
"""ESF1 快发:**仅**持久 TCP → native fast_send_port。
|
||||||
|
|
||||||
|
2026-09-23 用户要求堵死慢发:删除 HTTP send_raw / JSON motor/send 回落。
|
||||||
|
失败即 ok:false,由前端暴露错误;不自动走慢路径。
|
||||||
|
持久连接上的 sendall/recv **必须整段持锁**,否则并发请求会把 FSOK
|
||||||
|
错配到别的帧。连接失败时清掉缓存端口并重探一次(仍只走 TCP)。
|
||||||
|
"""
|
||||||
|
global g_fast_sock, g_fast_send_port
|
||||||
|
try:
|
||||||
|
body = pack_esf(frames)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "queued": 0, "backlog": 0, "path": "none",
|
||||||
|
"log": [f"ESF 打包失败: {e}"]}
|
||||||
|
timeout = max(0.2, float(timeout_ms or 2000) / 1000.0)
|
||||||
|
|
||||||
|
def _tcp_once(port: int) -> dict:
|
||||||
|
nonlocal body, timeout
|
||||||
|
global g_fast_sock
|
||||||
|
r = None
|
||||||
|
with g_fast_sock_lock:
|
||||||
|
if g_fast_sock is None:
|
||||||
|
g_fast_sock = _fast_sock_connect(port, timeout)
|
||||||
|
sock = g_fast_sock
|
||||||
|
sock.sendall(body)
|
||||||
|
resp = b""
|
||||||
|
while len(resp) < 8:
|
||||||
|
chunk = sock.recv(8 - len(resp))
|
||||||
|
if not chunk:
|
||||||
|
raise OSError("快发口连接已关闭")
|
||||||
|
resp += chunk
|
||||||
|
r = unpack_fsok(resp)
|
||||||
|
r["path"] = "fast_tcp"
|
||||||
|
return r
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for attempt in (0, 1):
|
||||||
|
port = _discover_fast_send_port(timeout, force=(attempt == 1))
|
||||||
|
if not port:
|
||||||
|
last_err = "native 无 fast_send_port(服务未起或版本过旧)"
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
return _tcp_once(port)
|
||||||
|
except Exception as e:
|
||||||
|
last_err = f"快发口异常: {e}"
|
||||||
|
_close_fast_sock()
|
||||||
|
g_fast_send_port = 0
|
||||||
|
if attempt == 0:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"queued": 0,
|
||||||
|
"backlog": 0,
|
||||||
|
"path": "fast_tcp",
|
||||||
|
"log": [last_err or "二进制快发失败(已禁用 HTTP/JSON 回落)"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _set_ecat_realtime(pid: int) -> bool:
|
def _set_ecat_realtime(pid: int) -> bool:
|
||||||
"""把 ecat_native 提到 RealTime 进程优先级。
|
"""把 ecat_native 提到 RealTime 进程优先级。
|
||||||
@@ -251,13 +423,34 @@ class _GuiApi:
|
|||||||
|
|
||||||
def bridge_info(self):
|
def bridge_info(self):
|
||||||
"""前端探测桌面桥是否可用。"""
|
"""前端探测桌面桥是否可用。"""
|
||||||
|
if g_ecat_port or g_fast_send_port == 0:
|
||||||
|
try:
|
||||||
|
_discover_fast_send_port(0.8)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"mode": "desktop",
|
"mode": "desktop",
|
||||||
"has_ecat_bridge": True,
|
"has_ecat_bridge": True,
|
||||||
"ecat_port": g_ecat_port,
|
"ecat_port": g_ecat_port,
|
||||||
|
"has_fast_send": bool(g_fast_send_port),
|
||||||
|
"fast_send_port": g_fast_send_port,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def ecat_send_raw(self, frames, timeout_ms=2000):
|
||||||
|
"""桌面桥 PDO 快发:JS → 本进程 ESF1 打包 → native 入队。
|
||||||
|
|
||||||
|
frames: [{ch, id, dlc, data(hex)}];也可传 {"frames":[...]}。
|
||||||
|
返回 {"ok","queued","backlog","path","log"},path 为实际走的通道。
|
||||||
|
"""
|
||||||
|
if isinstance(frames, dict):
|
||||||
|
frames = frames.get("frames") or ([frames] if "id" in frames or "data" in frames else [])
|
||||||
|
try:
|
||||||
|
return ecat_send_raw_frames(frames, timeout_ms)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "queued": 0, "backlog": 0,
|
||||||
|
"log": [f"原生桥快发异常: {type(e).__name__}: {e}"]}
|
||||||
|
|
||||||
def ecat_api(self, path, body=None, timeout_ms=4000):
|
def ecat_api(self, path, body=None, timeout_ms=4000):
|
||||||
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
|
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
|
||||||
|
|
||||||
@@ -306,6 +499,7 @@ class _GuiApi:
|
|||||||
obj.setdefault("ok", status == 200)
|
obj.setdefault("ok", status == 200)
|
||||||
if status >= 400 and "log" not in obj:
|
if status >= 400 and "log" not in obj:
|
||||||
obj["log"] = [f"HTTP {status}"]
|
obj["log"] = [f"HTTP {status}"]
|
||||||
|
_note_fast_port(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -472,7 +666,7 @@ def _ecat_autostart():
|
|||||||
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
|
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
|
||||||
|
|
||||||
|
|
||||||
def _ecat_post(port, path, body, timeout, connect_timeout=None):
|
def _ecat_post(port, path, body, timeout, connect_timeout=None, ctype=None):
|
||||||
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
|
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
|
||||||
|
|
||||||
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
|
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
|
||||||
@@ -496,7 +690,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None):
|
|||||||
payload = (
|
payload = (
|
||||||
f"POST {path} HTTP/1.0\r\n"
|
f"POST {path} HTTP/1.0\r\n"
|
||||||
f"Host: 127.0.0.1:{port}\r\n"
|
f"Host: 127.0.0.1:{port}\r\n"
|
||||||
"Content-Type: application/json\r\n"
|
f"Content-Type: {ctype or 'application/json'}\r\n"
|
||||||
f"Content-Length: {len(body or b'')}\r\n"
|
f"Content-Length: {len(body or b'')}\r\n"
|
||||||
"Connection: close\r\n"
|
"Connection: close\r\n"
|
||||||
"\r\n"
|
"\r\n"
|
||||||
@@ -525,7 +719,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None):
|
|||||||
return int(first[1]), data
|
return int(first[1]), data
|
||||||
|
|
||||||
|
|
||||||
def _ecat_proxy(path, body, timeout=6.0):
|
def _ecat_proxy(path, body, timeout=6.0, ctype=None):
|
||||||
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
|
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
|
||||||
|
|
||||||
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
|
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
|
||||||
@@ -537,6 +731,7 @@ def _ecat_proxy(path, body, timeout=6.0):
|
|||||||
|
|
||||||
2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart;
|
2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart;
|
||||||
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
|
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
|
||||||
|
ctype: 可选 Content-Type(ESF1 二进制 body 用 application/octet-stream)。
|
||||||
"""
|
"""
|
||||||
global g_ecat_port
|
global g_ecat_port
|
||||||
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
|
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
|
||||||
@@ -555,7 +750,8 @@ def _ecat_proxy(path, body, timeout=6.0):
|
|||||||
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
|
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
|
||||||
status, data = _ecat_post(
|
status, data = _ecat_post(
|
||||||
port, target, body, timeout,
|
port, target, body, timeout,
|
||||||
connect_timeout=None if port == g_ecat_port else 0.3)
|
connect_timeout=None if port == g_ecat_port else 0.3,
|
||||||
|
ctype=ctype)
|
||||||
g_ecat_port = port
|
g_ecat_port = port
|
||||||
return status, data
|
return status, data
|
||||||
except Exception as e: # 连不上/超时 → 试下一个端口
|
except Exception as e: # 连不上/超时 → 试下一个端口
|
||||||
@@ -572,7 +768,7 @@ def _ecat_proxy(path, body, timeout=6.0):
|
|||||||
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
|
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
|
||||||
if port:
|
if port:
|
||||||
try:
|
try:
|
||||||
status, data = _ecat_post(port, target, body, timeout)
|
status, data = _ecat_post(port, target, body, timeout, ctype=ctype)
|
||||||
g_ecat_port = port
|
g_ecat_port = port
|
||||||
return status, data
|
return status, data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
*
|
*
|
||||||
* 电机页在 tabs.html 的 iframe 内:pywebview 只注入顶层窗口,同源 iframe
|
* 电机页在 tabs.html 的 iframe 内:pywebview 只注入顶层窗口,同源 iframe
|
||||||
* 必须向上找 window.parent / window.top 上的 pywebview.api。
|
* 必须向上找 window.parent / window.top 上的 pywebview.api。
|
||||||
|
*
|
||||||
|
* PDO 快发(pdo-fast-send):桌面壳且 native 带 fast_send_port 时,ECAT
|
||||||
|
* motor 发送优先 desktopEcatSendRaw(GUI 侧 ESF1 二进制直通入队)。
|
||||||
*/
|
*/
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -59,7 +62,26 @@ export async function desktopEcatPost(path, body, timeoutMs) {
|
|||||||
return api.ecat_api(path, body == null ? {} : body, timeoutMs || 4000);
|
return api.ecat_api(path, body == null ? {} : body, timeoutMs || 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面桥 PDO 快发(ESF1)。frames=[{ch,id,dlc,data(hex)}]。
|
||||||
|
* @returns {Promise<object>} {ok,queued,backlog,path,log};path=fast_tcp|http_send_raw|http_motor_send
|
||||||
|
*/
|
||||||
|
export async function desktopEcatSendRaw(frames, timeoutMs) {
|
||||||
|
const api = getDesktopEcatApi();
|
||||||
|
if (!api || typeof api.ecat_send_raw !== "function") {
|
||||||
|
throw new Error("desktop fast-send unavailable");
|
||||||
|
}
|
||||||
|
return api.ecat_send_raw(frames, timeoutMs || 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @description 桌面桥是否暴露 ecat_send_raw(方法在,不代表 native 快发口一定在)。 */
|
||||||
|
export function hasDesktopFastSend() {
|
||||||
|
const api = getDesktopEcatApi();
|
||||||
|
return !!(api && typeof api.ecat_send_raw === "function");
|
||||||
|
}
|
||||||
|
|
||||||
/** @description 状态栏用:当前 ECAT 控制通道形态。 */
|
/** @description 状态栏用:当前 ECAT 控制通道形态。 */
|
||||||
export function desktopBridgeLabel() {
|
export function desktopBridgeLabel() {
|
||||||
return getDesktopEcatApi() ? "APP 原生桥" : "HTTP 代理";
|
if (!getDesktopEcatApi()) return "HTTP 代理";
|
||||||
|
return hasDesktopFastSend() ? "APP 二进制快发" : "APP 原生桥";
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js";
|
|||||||
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
|
/* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */
|
||||||
import {
|
import {
|
||||||
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
|
createTransport, loadTransportId, saveTransportId, LS_TRANSPORT
|
||||||
} from "./transport.js?v=20260611c";
|
} from "./transport.js?v=20260923i";
|
||||||
import { deviceLink } from "../common/device-link.js";
|
import { deviceLink } from "../common/device-link.js";
|
||||||
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
|
/* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移),
|
||||||
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
|
* 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ import { staleLimitMs } from "./refresh-rate.js";
|
|||||||
import { deviceLink } from "../common/device-link.js";
|
import { deviceLink } from "../common/device-link.js";
|
||||||
import {
|
import {
|
||||||
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
|
||||||
} from "../common/desktop-bridge.js?v=20260923g";
|
desktopEcatSendRaw, hasDesktopFastSend,
|
||||||
|
} from "../common/desktop-bridge.js?v=20260923i";
|
||||||
|
|
||||||
/** 单个 PDO 周期回传的状态槽数。
|
/** 单个 PDO 周期回传的状态槽数。
|
||||||
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
|
||||||
@@ -242,6 +243,41 @@ export function createEcatTransport(ctx) {
|
|||||||
return callHttp(tag, body, timeoutMs);
|
return callHttp(tag, body, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECAT 发送统一入口。
|
||||||
|
* 桌面桥且 ecat_send_raw 可用:**只走 ESF1**,失败直接返回 ok:false,
|
||||||
|
* **禁止**回落 HTTP motor/send(2026-09-23 堵死慢发,防误走 JSON 路径)。
|
||||||
|
* 无桌面桥(浏览器标签):仍走 api("motor/send") HTTP。
|
||||||
|
* payload = {frames:[{ch,id,dlc,data}]}。
|
||||||
|
*/
|
||||||
|
let lastFastSendPath = "";
|
||||||
|
async function sendViaFastOrJson(payload) {
|
||||||
|
if (hasDesktopFastSend()) {
|
||||||
|
lastFastSendPath = "fast_tcp";
|
||||||
|
try {
|
||||||
|
const r = await desktopEcatSendRaw(payload.frames || [], 2000);
|
||||||
|
if (r && r.ok === true) {
|
||||||
|
if (r.path) lastFastSendPath = r.path;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
const msg = (r && r.log && r.log.length)
|
||||||
|
? r.log.join(" / ")
|
||||||
|
: "二进制快发失败(已禁用 HTTP/JSON 回落)";
|
||||||
|
recordSlow("fast-send", 0, msg);
|
||||||
|
return { ok: false, queued: 0, backlog: 0, path: "fast_tcp", log: [msg] };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
recordSlow("fast-send", 0, err.message);
|
||||||
|
return {
|
||||||
|
ok: false, queued: 0, backlog: 0, path: "fast_tcp",
|
||||||
|
log: [err.message || "二进制快发异常"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastFastSendPath = "json";
|
||||||
|
return api("motor/send", payload, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
|
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
|
||||||
function unwrap(json, what) {
|
function unwrap(json, what) {
|
||||||
if (!json || json.ok !== true) {
|
if (!json || json.ok !== true) {
|
||||||
@@ -533,7 +569,10 @@ export function createEcatTransport(ctx) {
|
|||||||
supportsPeriodSync: false,
|
supportsPeriodSync: false,
|
||||||
|
|
||||||
sendHint(ch) {
|
sendHint(ch) {
|
||||||
return desktopBridgeLabel() + " · motor/send · CH" + ch +
|
const fast = lastFastSendPath === "fast_tcp"
|
||||||
|
? "二进制快发"
|
||||||
|
: desktopBridgeLabel();
|
||||||
|
return fast + " · motor/send · CH" + ch +
|
||||||
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
|
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -706,6 +745,9 @@ export function createEcatTransport(ctx) {
|
|||||||
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
|
* 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提,
|
||||||
* 逐帧调用会退化成一帧一个周期。
|
* 逐帧调用会退化成一帧一个周期。
|
||||||
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
|
* 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。
|
||||||
|
*
|
||||||
|
* PDO 快发:桌面桥有 ecat_send_raw 时**只走** ESF1 二进制直通,
|
||||||
|
* 失败 ok:false、不回落 JSON(堵死慢发)。无桥(浏览器)才走 motor/send。
|
||||||
*/
|
*/
|
||||||
async sendFrames(frames, opts) {
|
async sendFrames(frames, opts) {
|
||||||
if (!started) await ensureStarted();
|
if (!started) await ensureStarted();
|
||||||
@@ -714,7 +756,7 @@ export function createEcatTransport(ctx) {
|
|||||||
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
|
ch: opts.ch, id: f.id, dlc: f.dlc, data: f.data,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const r = await api("motor/send", payload, 6000);
|
const r = await sendViaFastOrJson(payload);
|
||||||
if (!r || r.ok !== true) {
|
if (!r || r.ok !== true) {
|
||||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
||||||
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
||||||
@@ -733,7 +775,7 @@ export function createEcatTransport(ctx) {
|
|||||||
ch: f.ch,
|
ch: f.ch,
|
||||||
}));
|
}));
|
||||||
if (!started) await ensureStarted();
|
if (!started) await ensureStarted();
|
||||||
const r = await api("motor/send", { frames: built }, 6000);
|
const r = await sendViaFastOrJson({ frames: built });
|
||||||
if (!r || r.ok !== true) {
|
if (!r || r.ok !== true) {
|
||||||
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败";
|
||||||
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg }));
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { createWifiTransport } from "./transport-wifi.js";
|
import { createWifiTransport } from "./transport-wifi.js";
|
||||||
import { createEcatTransport } from "./transport-ecat.js?v=20260611c";
|
import { createEcatTransport } from "./transport-ecat.js?v=20260923i";
|
||||||
|
|
||||||
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
/** localStorage key:记住上次选的传输,刷新页面后保持 */
|
||||||
export const LS_TRANSPORT = "dcu_motor_transport";
|
export const LS_TRANSPORT = "dcu_motor_transport";
|
||||||
|
|||||||
+1
-1
@@ -430,7 +430,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="../js/motor/main.js?v=20260920b"></script>
|
<script type="module" src="../js/motor/main.js?v=20260923i"></script>
|
||||||
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
|
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
|
||||||
<script src="../js/common/app-lease.js"></script>
|
<script src="../js/common/app-lease.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""pdo-fast-send 端到端分段延迟(模式切换代理)。
|
||||||
|
|
||||||
|
电机启动/转动有惯性,rtAngle/rtVelocity 阈值难踩准。改用 **CONFIG 回报的
|
||||||
|
Target_Control_Mode(0x000N0101 B0)** 作为「命令已被电机接收并应用」的可见
|
||||||
|
代理:CMD 的 mode 字节一旦被接受,下一拍 CONFIG 就会以新 mode 回报。
|
||||||
|
|
||||||
|
路径:
|
||||||
|
json — HTTP POST /ecat/api/motor/send(浏览器同款页面代理)
|
||||||
|
fast — ecat_send_raw_frames(桌面桥同构 ESF1 TCP,不含 pywebview hop)
|
||||||
|
|
||||||
|
分段:
|
||||||
|
t_click = 发请求前
|
||||||
|
t_sent = 控制面返回(入队完成)
|
||||||
|
t_can = bridge.tx_count/ack 变化(EtherCAT→GD32 CAN 出口)
|
||||||
|
t_mode = 观测到目标节点 CONFIG B0 == 期望 mode(xrx 或 motor/rx)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/check_e2e_click_motion.py
|
||||||
|
python scripts/check_e2e_click_motion.py --node 3 --ch 2 --trials 5
|
||||||
|
python scripts/check_e2e_click_motion.py --proxy motion # 旧:角度/速度阈值
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
from esp32_server import ecat_send_raw_frames # noqa: E402
|
||||||
|
|
||||||
|
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
|
||||||
|
ECAT = PAGE + "/ecat/api"
|
||||||
|
# 观测默认打 native 直连:页面代理 p50~2ms×多次 + xrx 单帧槽会把 CONFIG 观测抬到 ~12ms
|
||||||
|
NATIVE = os.environ.get("DCU_NATIVE_API", "http://127.0.0.1:8090/api")
|
||||||
|
OBS = NATIVE # 观测环(motor/state、motor/rx)
|
||||||
|
|
||||||
|
# 安全模式对:0=Disable(输出关闭),2=Velocity 但目标速度 0(不驱动转动)
|
||||||
|
SAFE_TOGGLE = (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def post(url, obj=None, raw=None, ctype="application/json", timeout=8.0):
|
||||||
|
if raw is not None:
|
||||||
|
data = raw
|
||||||
|
headers = {"Content-Type": ctype}
|
||||||
|
else:
|
||||||
|
data = json.dumps(obj or {}, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
headers = {"Content-Type": ctype}
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
body = r.read()
|
||||||
|
ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
try:
|
||||||
|
return ms, json.loads(body.decode("utf-8", "replace") or "{}")
|
||||||
|
except Exception:
|
||||||
|
return ms, {"_raw_len": len(body)}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_status(data_hex: str):
|
||||||
|
if not data_hex or len(data_hex) < 36:
|
||||||
|
return None
|
||||||
|
raw = bytes.fromhex(data_hex[:72] if len(data_hex) >= 72 else data_hex)
|
||||||
|
if len(raw) < 21:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def f32(off):
|
||||||
|
if off + 4 > len(raw):
|
||||||
|
return 0.0
|
||||||
|
v = struct.unpack_from("<f", raw, off)[0]
|
||||||
|
return v if v == v else 0.0
|
||||||
|
|
||||||
|
return {"rtAngle": f32(13), "rtVelocity": f32(17), "rtCurrent": f32(21), "b10": raw[10]}
|
||||||
|
|
||||||
|
|
||||||
|
def motor_sample(node: int, ch: int):
|
||||||
|
_, snap = post(OBS + "/motor/state", {}, timeout=5)
|
||||||
|
if not snap.get("ok"):
|
||||||
|
return None, None, snap
|
||||||
|
br = snap.get("bridge") or {}
|
||||||
|
outlet = (br.get("tx_count"), br.get("ack"), br.get("last_tx_id_hex"))
|
||||||
|
st = None
|
||||||
|
for m in snap.get("motors") or []:
|
||||||
|
if m.get("node") == node and m.get("ch") == ch:
|
||||||
|
st = decode_status(m.get("data") or "")
|
||||||
|
break
|
||||||
|
return st, outlet, snap
|
||||||
|
|
||||||
|
|
||||||
|
def build_cmd(mode, voltage=24.0, current=0.0, velocity=0.0, angle=0.0, pid=100):
|
||||||
|
buf = bytearray(32)
|
||||||
|
buf[0] = mode & 0xFF
|
||||||
|
struct.pack_into("<H", buf, 1, int(round(voltage * 1000)) & 0xFFFF)
|
||||||
|
struct.pack_into("<f", buf, 3, float(current))
|
||||||
|
struct.pack_into("<f", buf, 7, float(velocity))
|
||||||
|
struct.pack_into("<f", buf, 11, float(angle))
|
||||||
|
struct.pack_into("<h", buf, 15, pid)
|
||||||
|
struct.pack_into("<h", buf, 17, pid)
|
||||||
|
struct.pack_into("<h", buf, 19, pid)
|
||||||
|
return buf.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def send_path(path: str, ch: int, node: int, data_hex: str):
|
||||||
|
frames = [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": data_hex}]
|
||||||
|
if path == "fast":
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
resp = ecat_send_raw_frames(frames, 2000)
|
||||||
|
return (time.perf_counter() - t0) * 1000.0, resp
|
||||||
|
ms, resp = post(ECAT + "/motor/send", {"frames": frames}, timeout=6)
|
||||||
|
return ms, resp
|
||||||
|
|
||||||
|
|
||||||
|
def outlet_changed(a, b):
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
return (a[0], a[1]) != (b[0], b[1]) and a[0] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_mode_from_hex(data_hex: str):
|
||||||
|
if not data_hex or len(data_hex) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(data_hex[:2], 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_config_mode(node: int, snap: dict):
|
||||||
|
"""从 xrx + motor/rx 找目标节点 CONFIG(0x000N0101) 的 B0 mode。"""
|
||||||
|
want_id = (node << 16) | 0x0101
|
||||||
|
hits = []
|
||||||
|
x = snap.get("xrx") or {}
|
||||||
|
if x.get("id") == want_id:
|
||||||
|
m = cfg_mode_from_hex(x.get("data") or "")
|
||||||
|
if m is not None:
|
||||||
|
hits.append(("xrx", m, x.get("data")))
|
||||||
|
rxb = snap.get("rx_batch") or {}
|
||||||
|
# frames 可能在快照里;若空则单独 drain
|
||||||
|
for f in rxb.get("frames") or []:
|
||||||
|
if f.get("id") == want_id:
|
||||||
|
m = cfg_mode_from_hex(f.get("data") or "")
|
||||||
|
if m is not None:
|
||||||
|
hits.append(("rxb", m, f.get("data")))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def drain_rx_modes(node: int):
|
||||||
|
want_id = (node << 16) | 0x0101
|
||||||
|
_, rx = post(OBS + "/motor/rx", {}, timeout=4)
|
||||||
|
out = []
|
||||||
|
for f in rx.get("frames") or []:
|
||||||
|
if f.get("id") == want_id:
|
||||||
|
m = cfg_mode_from_hex(f.get("data") or "")
|
||||||
|
if m is not None:
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def observe_config_mode(node: int):
|
||||||
|
"""返回 (mode_or_None, source)。优先 drain rx(CONFIG 主通道),再 xrx。"""
|
||||||
|
modes = drain_rx_modes(node)
|
||||||
|
if modes:
|
||||||
|
return modes[-1], "rx"
|
||||||
|
_, snap = post(OBS + "/motor/state", {}, timeout=4)
|
||||||
|
hits = scan_config_mode(node, snap)
|
||||||
|
if hits:
|
||||||
|
return hits[-1][1], hits[-1][0]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(path: str, node: int, ch: int, proxy: str, timeout: float, next_mode: int) -> dict:
|
||||||
|
st0, outlet0, _ = motor_sample(node, ch)
|
||||||
|
# 基线 mode:SIG 必须相对它变化,避免 CONFIG 持续回报同一 mode 造成假阳性
|
||||||
|
base_mode, base_src = observe_config_mode(node)
|
||||||
|
if proxy == "mode":
|
||||||
|
# 强制与基线不同
|
||||||
|
if base_mode == next_mode:
|
||||||
|
next_mode = SAFE_TOGGLE[1] if base_mode == SAFE_TOGGLE[0] else SAFE_TOGGLE[0]
|
||||||
|
data = build_cmd(mode=next_mode, voltage=24.0, current=0.0, velocity=0.0, angle=0.0)
|
||||||
|
expect = next_mode
|
||||||
|
else:
|
||||||
|
data = build_cmd(mode=2, voltage=24.0, current=0.0, velocity=40.0, angle=0.0)
|
||||||
|
expect = 2
|
||||||
|
base_mode = None
|
||||||
|
|
||||||
|
t_click = time.perf_counter()
|
||||||
|
sent_ms, resp = send_path(path, ch, node, data)
|
||||||
|
t_sent = time.perf_counter()
|
||||||
|
|
||||||
|
t_can = None
|
||||||
|
t_mode = None
|
||||||
|
t_motion = None
|
||||||
|
can_detail = mode_detail = motion_detail = ""
|
||||||
|
ang0 = st0["rtAngle"] if st0 else 0.0
|
||||||
|
vel0 = st0["rtVelocity"] if st0 else 0.0
|
||||||
|
|
||||||
|
while time.perf_counter() - t_click < timeout:
|
||||||
|
st, outlet, snap = motor_sample(node, ch)
|
||||||
|
now = time.perf_counter()
|
||||||
|
if t_can is None and outlet_changed(outlet0, outlet):
|
||||||
|
t_can = now
|
||||||
|
can_detail = f"{outlet0} → {outlet}"
|
||||||
|
if t_mode is None and proxy == "mode":
|
||||||
|
# 观测优先 drain rx:CONFIG 约 ~200Hz/节点,xrx 只有单槽会被其它节点挤掉
|
||||||
|
drained = drain_rx_modes(node)
|
||||||
|
if expect in drained and expect != base_mode:
|
||||||
|
t_mode = now
|
||||||
|
mode_detail = f"mode {base_mode}→{expect} via rx"
|
||||||
|
else:
|
||||||
|
hits = scan_config_mode(node, snap)
|
||||||
|
for src, m, raw in reversed(hits):
|
||||||
|
if m == expect and m != base_mode:
|
||||||
|
t_mode = now
|
||||||
|
mode_detail = f"mode {base_mode}→{m} via {src} data={(raw or '')[:16]}"
|
||||||
|
break
|
||||||
|
if st and t_motion is None and proxy == "motion":
|
||||||
|
d_ang = abs(st["rtAngle"] - ang0)
|
||||||
|
d_vel = abs(st["rtVelocity"] - vel0)
|
||||||
|
if d_ang > 0.3 or abs(d_vel) > 5.0:
|
||||||
|
t_motion = now
|
||||||
|
motion_detail = f"Δang={d_ang:.3f} Δvel={d_vel:.2f}"
|
||||||
|
done_mode = (proxy == "mode" and t_mode is not None) or (proxy == "motion" and t_motion is not None)
|
||||||
|
if t_can is not None and done_mode:
|
||||||
|
break
|
||||||
|
# 直连 native 时观测环约 0.6–1ms;页面代理则 ≥2ms×N,会主导 p50
|
||||||
|
time.sleep(0)
|
||||||
|
|
||||||
|
send_path(path, ch, node, build_cmd(mode=0, voltage=24.0, current=0.0, velocity=0.0, angle=0.0))
|
||||||
|
time.sleep(0.08)
|
||||||
|
|
||||||
|
def ms(dt):
|
||||||
|
return None if dt is None else dt * 1000.0
|
||||||
|
|
||||||
|
t_sig = t_mode if proxy == "mode" else t_motion
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": path,
|
||||||
|
"proxy": proxy,
|
||||||
|
"base_mode": base_mode,
|
||||||
|
"base_src": base_src,
|
||||||
|
"expect_mode": expect if proxy == "mode" else None,
|
||||||
|
"resp_ok": bool(resp.get("ok")),
|
||||||
|
"resp_path": resp.get("path"),
|
||||||
|
"sent_ms": sent_ms,
|
||||||
|
"click_to_sent": ms(t_sent - t_click),
|
||||||
|
"click_to_can": ms(None if t_can is None else t_can - t_click),
|
||||||
|
"click_to_sig": ms(None if t_sig is None else t_sig - t_click),
|
||||||
|
"sent_to_can": ms(None if t_can is None else t_can - t_sent),
|
||||||
|
"sent_to_sig": ms(None if t_sig is None else t_sig - t_sent),
|
||||||
|
"can_to_sig": ms(None if (t_can is None or t_sig is None) else t_sig - t_can),
|
||||||
|
"can_detail": can_detail,
|
||||||
|
"sig_detail": mode_detail if proxy == "mode" else motion_detail,
|
||||||
|
"base_vel": vel0,
|
||||||
|
"sig_ok": t_sig is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(rows, key):
|
||||||
|
xs = [r[key] for r in rows if r.get(key) is not None]
|
||||||
|
if not xs:
|
||||||
|
return "n/a"
|
||||||
|
return (
|
||||||
|
f"n={len(xs)} mean={statistics.mean(xs):.2f}ms "
|
||||||
|
f"p50={statistics.median(xs):.2f} min={min(xs):.2f} max={max(xs):.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--node", type=int, default=3)
|
||||||
|
ap.add_argument("--ch", type=int, default=2)
|
||||||
|
ap.add_argument("--timeout", type=float, default=2.0)
|
||||||
|
ap.add_argument("--trials", type=int, default=4)
|
||||||
|
ap.add_argument("--paths", type=str, default="json,fast")
|
||||||
|
ap.add_argument("--proxy", type=str, default="mode", choices=["mode", "motion"])
|
||||||
|
args = ap.parse_args()
|
||||||
|
paths = [p.strip() for p in args.paths.split(",") if p.strip()]
|
||||||
|
|
||||||
|
_, health = post(OBS + "/native/health", {})
|
||||||
|
print("health:", json.dumps(health, ensure_ascii=False)[:220])
|
||||||
|
print(f"观测环直连: {OBS}(发送仍按 path 走 page/fast)")
|
||||||
|
post(ECAT + "/motor/start",
|
||||||
|
{"period_ms": 0.25, "dc_cycle_us": 250, "dc_shift_us": 0}, timeout=30)
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
cur_mode, src = observe_config_mode(args.node)
|
||||||
|
print(f"基线 CONFIG mode node={args.node}: {cur_mode} (via {src})")
|
||||||
|
print(f"代理={args.proxy} 模式对={SAFE_TOGGLE} 每次在两者间切换,目标速度恒 0")
|
||||||
|
|
||||||
|
all_rows: dict[str, list] = {}
|
||||||
|
for path in paths:
|
||||||
|
print(f"\n======== path={path} proxy={args.proxy} node={args.node} ch={args.ch} ========")
|
||||||
|
rows = []
|
||||||
|
for i in range(args.trials):
|
||||||
|
# 每轮现读基线再选「与基线不同」的 mode
|
||||||
|
cur_mode, _src = observe_config_mode(args.node)
|
||||||
|
if args.proxy == "mode":
|
||||||
|
if cur_mode == SAFE_TOGGLE[0]:
|
||||||
|
next_mode = SAFE_TOGGLE[1]
|
||||||
|
elif cur_mode == SAFE_TOGGLE[1]:
|
||||||
|
next_mode = SAFE_TOGGLE[0]
|
||||||
|
else:
|
||||||
|
next_mode = SAFE_TOGGLE[0]
|
||||||
|
else:
|
||||||
|
next_mode = 2
|
||||||
|
print(f"--- trial {i+1}/{args.trials} base_mode={cur_mode} → send mode={next_mode} ---")
|
||||||
|
r = run_once(path, args.node, args.ch, args.proxy, args.timeout, next_mode)
|
||||||
|
rows.append(r)
|
||||||
|
if not r.get("resp_ok") or not r.get("sig_ok", True):
|
||||||
|
print(" FAIL/NOSIG", {k: r.get(k) for k in
|
||||||
|
("resp_ok", "sig_ok", "base_mode", "expect_mode", "resp_path", "sig_detail")})
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f" via={r['resp_path']} mode {r.get('base_mode')}→{r.get('expect_mode')} "
|
||||||
|
f"sent={r['sent_ms']:.2f}ms "
|
||||||
|
f"click→sent={r['click_to_sent']:.2f} "
|
||||||
|
f"click→can={r['click_to_can'] if r['click_to_can'] is not None else 'n/a'} "
|
||||||
|
f"click→sig={r['click_to_sig'] if r['click_to_sig'] is not None else 'n/a'} "
|
||||||
|
f"sent→can={r['sent_to_can'] if r['sent_to_can'] is not None else 'n/a'} "
|
||||||
|
f"sent→sig={r['sent_to_sig'] if r['sent_to_sig'] is not None else 'n/a'} "
|
||||||
|
f"can→sig={r['can_to_sig'] if r['can_to_sig'] is not None else 'n/a'}"
|
||||||
|
)
|
||||||
|
if r.get("can_detail"):
|
||||||
|
print(" CAN", r["can_detail"])
|
||||||
|
if r.get("sig_detail"):
|
||||||
|
print(" SIG", r["sig_detail"])
|
||||||
|
time.sleep(0.35)
|
||||||
|
all_rows[path] = rows
|
||||||
|
good = [r for r in rows if r.get("ok") and r.get("resp_ok") and r.get("sig_ok", True)]
|
||||||
|
print(f"\n=== {path} 汇总 ({len(good)}/{len(rows)}) ===")
|
||||||
|
for key in ("sent_ms", "click_to_sent", "click_to_can", "click_to_sig",
|
||||||
|
"sent_to_can", "sent_to_sig", "can_to_sig"):
|
||||||
|
print(f" {key:14s} {summarize(good, key)}")
|
||||||
|
|
||||||
|
print("\n======== 口径 ========")
|
||||||
|
if args.proxy == "mode":
|
||||||
|
print("主信号: CONFIG Target_Control_Mode (0x000N0101 B0) 变为期望值")
|
||||||
|
print(" = 电机已接收并回报新控制模式(不依赖转动/角度阈值)")
|
||||||
|
else:
|
||||||
|
print("主信号: STATUS rtAngle/rtVelocity 阈值(旧口径,启动惯性会漏测)")
|
||||||
|
print("t_click 不含真实鼠标与 pywebview hop;观测分辨率受 motor/state/rx 轮询限制。")
|
||||||
|
print("fast 路径为 GUI 桥同构 Python 调用。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""A/B:JSON motor/send vs ESF1 快发(HTTP send_raw / 持久 TCP)软件段延迟。
|
||||||
|
|
||||||
|
需要:tools/ecat ecat_native 已运行(总线可不必在 OP,未连接时会报未连接但仍计时)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/check_esf_latency.py
|
||||||
|
python scripts/check_esf_latency.py --n 40
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import statistics
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
from esp32_server import pack_esf, unpack_fsok # noqa: E402
|
||||||
|
|
||||||
|
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
|
||||||
|
ECAT = PAGE + "/ecat/api"
|
||||||
|
|
||||||
|
|
||||||
|
def http(url, data: bytes, ctype="application/json", timeout=5.0):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data,
|
||||||
|
headers={"Content-Type": ctype, "Accept": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
body = r.read()
|
||||||
|
ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
try:
|
||||||
|
return ms, json.loads(body.decode("utf-8", "replace") or "{}")
|
||||||
|
except Exception:
|
||||||
|
return ms, {"_raw_len": len(body)}
|
||||||
|
except Exception as e:
|
||||||
|
return (time.perf_counter() - t0) * 1000.0, {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def pct(xs, p):
|
||||||
|
if not xs:
|
||||||
|
return None
|
||||||
|
xs = sorted(xs)
|
||||||
|
k = max(0, min(len(xs) - 1, int(round((p / 100.0) * (len(xs) - 1)))))
|
||||||
|
return xs[k]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--n", type=int, default=30)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
frame = {"ch": 2, "id": 0x00030200, "dlc": 32,
|
||||||
|
"data": "02" + "00" * 29 + "000000"} # 静止 CMD
|
||||||
|
frames = [frame]
|
||||||
|
esf = pack_esf(frames)
|
||||||
|
js = json.dumps({"frames": frames}, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
ms_h, health = http(ECAT + "/native/health", b"{}")
|
||||||
|
print(f"health {ms_h:.2f}ms {json.dumps(health, ensure_ascii=False)[:200]}")
|
||||||
|
fsp = int(health.get("fast_send_port") or 0)
|
||||||
|
print(f"fast_send_port={fsp}")
|
||||||
|
|
||||||
|
json_ms, raw_ms = [], []
|
||||||
|
for _ in range(args.n):
|
||||||
|
ms, _ = http(ECAT + "/motor/send", js)
|
||||||
|
json_ms.append(ms)
|
||||||
|
ms, _ = http(ECAT + "/motor/send_raw", esf, "application/octet-stream")
|
||||||
|
raw_ms.append(ms)
|
||||||
|
|
||||||
|
tcp_ms = []
|
||||||
|
if fsp > 0:
|
||||||
|
sock = socket.create_connection(("127.0.0.1", fsp), timeout=None)
|
||||||
|
sock.settimeout(2.0)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for _ in range(args.n):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sock.sendall(esf)
|
||||||
|
resp = b""
|
||||||
|
while len(resp) < 8:
|
||||||
|
c = sock.recv(8 - len(resp))
|
||||||
|
if not c:
|
||||||
|
raise OSError("closed")
|
||||||
|
resp += c
|
||||||
|
tcp_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
r = unpack_fsok(resp)
|
||||||
|
if not r.get("ok") and not tcp_ms:
|
||||||
|
print("TCP 快发 ok=false", r)
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
def show(name, xs):
|
||||||
|
if not xs:
|
||||||
|
print(f"{name}: (skip)")
|
||||||
|
return
|
||||||
|
print(f"{name}: n={len(xs)} mean={statistics.mean(xs):.3f}ms "
|
||||||
|
f"p50={pct(xs,50):.3f} p90={pct(xs,90):.3f} min={min(xs):.3f} max={max(xs):.3f}")
|
||||||
|
|
||||||
|
show("JSON motor/send ", json_ms)
|
||||||
|
show("HTTP send_raw ", raw_ms)
|
||||||
|
show("TCP fast_send ", tcp_ms)
|
||||||
|
print("口径:HTTP 客户端侧 POST 往返(含本机代理时);不含浏览器 JS/pywebview hop。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""全链路延迟分段归因(pdo-fast-send)。
|
||||||
|
|
||||||
|
把「点击→电机已处理」拆成可独立计时的段,确认延迟落在哪里:
|
||||||
|
|
||||||
|
S1 控制面入队 TCP ESF1 FSOK / HTTP send_raw / JSON motor/send
|
||||||
|
S2 入队→CAN 出口 bridge.tx_count/ack 变化(高频 motor/state)
|
||||||
|
S3 CAN→CONFIG 可见 Target_Control_Mode 变化(高频 motor/rx)
|
||||||
|
S0 观测开销 单次 motor/state / motor/rx(native 直连 vs 页面代理)
|
||||||
|
|
||||||
|
观测一律优先 http://127.0.0.1:8090/api,避免页面代理把 S2/S3 抬高。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/check_latency_breakdown.py
|
||||||
|
python scripts/check_latency_breakdown.py --node 3 --n 20
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
from esp32_server import ecat_send_raw_frames, pack_esf, unpack_fsok # noqa: E402
|
||||||
|
import socket # noqa: E402
|
||||||
|
|
||||||
|
NATIVE = os.environ.get("DCU_NATIVE_API", "http://127.0.0.1:8090/api")
|
||||||
|
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
|
||||||
|
PAGE_API = PAGE + "/ecat/api"
|
||||||
|
|
||||||
|
|
||||||
|
def post(url, obj=None, raw=None, ctype="application/json", timeout=5.0):
|
||||||
|
if raw is not None:
|
||||||
|
data, headers = raw, {"Content-Type": ctype}
|
||||||
|
else:
|
||||||
|
data = json.dumps(obj or {}, separators=(",", ":")).encode()
|
||||||
|
headers = {"Content-Type": ctype}
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
body = r.read()
|
||||||
|
ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
try:
|
||||||
|
return ms, json.loads(body.decode("utf-8", "replace") or "{}")
|
||||||
|
except Exception:
|
||||||
|
return ms, {}
|
||||||
|
except Exception as e:
|
||||||
|
return (time.perf_counter() - t0) * 1000.0, {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def pct(xs, p):
|
||||||
|
if not xs:
|
||||||
|
return None
|
||||||
|
xs = sorted(xs)
|
||||||
|
k = max(0, min(len(xs) - 1, int(round(p / 100.0 * (len(xs) - 1)))))
|
||||||
|
return xs[k]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(xs):
|
||||||
|
if not xs:
|
||||||
|
return "n/a"
|
||||||
|
return (
|
||||||
|
f"n={len(xs)} mean={statistics.mean(xs):.2f} "
|
||||||
|
f"p50={pct(xs,50):.2f} p90={pct(xs,90):.2f} "
|
||||||
|
f"min={min(xs):.2f} max={max(xs):.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_cmd(mode, voltage=24.0, current=0.0, velocity=0.0, angle=0.0, pid=100):
|
||||||
|
b = bytearray(32)
|
||||||
|
b[0] = mode & 0xFF
|
||||||
|
struct.pack_into("<H", b, 1, int(round(voltage * 1000)) & 0xFFFF)
|
||||||
|
struct.pack_into("<f", b, 3, float(current))
|
||||||
|
struct.pack_into("<f", b, 7, float(velocity))
|
||||||
|
struct.pack_into("<f", b, 11, float(angle))
|
||||||
|
struct.pack_into("<h", b, 15, pid)
|
||||||
|
struct.pack_into("<h", b, 17, pid)
|
||||||
|
struct.pack_into("<h", b, 19, pid)
|
||||||
|
return b.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def frames_for(node, ch, mode):
|
||||||
|
return [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32,
|
||||||
|
"data": build_cmd(mode)}]
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_mode_from_hex(h):
|
||||||
|
if not h or len(h) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(h[:2], 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_outlet():
|
||||||
|
_, snap = post(NATIVE + "/motor/state", {}, timeout=3)
|
||||||
|
br = (snap or {}).get("bridge") or {}
|
||||||
|
return (br.get("tx_count"), br.get("ack")), snap
|
||||||
|
|
||||||
|
|
||||||
|
def drain_cfg_modes(node):
|
||||||
|
want = (node << 16) | 0x0101
|
||||||
|
_, rx = post(NATIVE + "/motor/rx", {}, timeout=3)
|
||||||
|
out = []
|
||||||
|
for f in (rx or {}).get("frames") or []:
|
||||||
|
if f.get("id") == want:
|
||||||
|
m = cfg_mode_from_hex(f.get("data") or "")
|
||||||
|
if m is not None:
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def baseline_mode(node):
|
||||||
|
modes = drain_cfg_modes(node)
|
||||||
|
if modes:
|
||||||
|
return modes[-1]
|
||||||
|
outlet, snap = read_outlet()
|
||||||
|
x = (snap or {}).get("xrx") or {}
|
||||||
|
if x.get("id") == (node << 16) | 0x0101:
|
||||||
|
return cfg_mode_from_hex(x.get("data") or "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def s0_obs_cost(n=40):
|
||||||
|
print("\n[S0] 观测开销(单次 HTTP)")
|
||||||
|
for label, url in [
|
||||||
|
("native motor/state", NATIVE + "/motor/state"),
|
||||||
|
("native motor/rx", NATIVE + "/motor/rx"),
|
||||||
|
("page motor/state", PAGE_API + "/motor/state"),
|
||||||
|
("page motor/rx", PAGE_API + "/motor/rx"),
|
||||||
|
]:
|
||||||
|
xs = []
|
||||||
|
for _ in range(n):
|
||||||
|
ms, _ = post(url, {})
|
||||||
|
xs.append(ms)
|
||||||
|
print(f" {label:22s} {fmt(xs)}")
|
||||||
|
|
||||||
|
|
||||||
|
def s1_control_plane(n=40, node=3, ch=2):
|
||||||
|
print("\n[S1] 控制面入队(不依赖电机动作)")
|
||||||
|
fr = frames_for(node, ch, 0)
|
||||||
|
body = pack_esf(fr)
|
||||||
|
js = json.dumps({"frames": fr}, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
# TCP fast (persistent)
|
||||||
|
hms, health = post(NATIVE + "/native/health", {})
|
||||||
|
fsp = int((health or {}).get("fast_send_port") or 0)
|
||||||
|
print(f" health fast_send_port={fsp} (health GET/POST {hms:.1f}ms)")
|
||||||
|
tcp = []
|
||||||
|
if fsp:
|
||||||
|
sock = socket.create_connection(("127.0.0.1", fsp), timeout=None)
|
||||||
|
sock.settimeout(2.0)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for _ in range(n):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sock.sendall(body)
|
||||||
|
resp = b""
|
||||||
|
while len(resp) < 8:
|
||||||
|
c = sock.recv(8 - len(resp))
|
||||||
|
if not c:
|
||||||
|
raise OSError("closed")
|
||||||
|
resp += c
|
||||||
|
tcp.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
r = unpack_fsok(resp)
|
||||||
|
if not r.get("ok"):
|
||||||
|
print(" TCP FSOK fail", r)
|
||||||
|
sock.close()
|
||||||
|
print(f" TCP ESF1 FSOK {fmt(tcp)}")
|
||||||
|
|
||||||
|
# GUI-bridge style Python ecat_send_raw (includes discover/reuse)
|
||||||
|
bridge = []
|
||||||
|
for _ in range(max(5, n // 4)):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
r = ecat_send_raw_frames(fr, 2000)
|
||||||
|
bridge.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
path = r.get("path")
|
||||||
|
print(f" GUI ecat_send_raw {fmt(bridge)} last_path={path}")
|
||||||
|
|
||||||
|
# HTTP send_raw native
|
||||||
|
raw = []
|
||||||
|
for _ in range(n):
|
||||||
|
ms, _ = post(NATIVE + "/motor/send_raw", raw=body,
|
||||||
|
ctype="application/octet-stream")
|
||||||
|
raw.append(ms)
|
||||||
|
print(f" HTTP send_raw(native) {fmt(raw)}")
|
||||||
|
|
||||||
|
# JSON motor/send native vs page
|
||||||
|
jn, jp = [], []
|
||||||
|
for _ in range(n):
|
||||||
|
ms, _ = post(NATIVE + "/motor/send", {"frames": fr})
|
||||||
|
jn.append(ms)
|
||||||
|
ms, _ = post(PAGE_API + "/motor/send", {"frames": fr})
|
||||||
|
jp.append(ms)
|
||||||
|
print(f" JSON motor/send native {fmt(jn)}")
|
||||||
|
print(f" JSON motor/send page {fmt(jp)}")
|
||||||
|
|
||||||
|
|
||||||
|
def s2_enqueue_to_can(path, node, ch, n=15, timeout=1.2):
|
||||||
|
print(f"\n[S2] 入队→CAN 出口 path={path}(高频 motor/state,直连 native)")
|
||||||
|
sent_xs, can_xs, obs_xs = [], [], []
|
||||||
|
for i in range(n):
|
||||||
|
base_mode = baseline_mode(node)
|
||||||
|
next_mode = 2 if base_mode in (0, None) else 0
|
||||||
|
outlet0, _ = read_outlet()
|
||||||
|
data = build_cmd(next_mode)
|
||||||
|
fr = [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": data}]
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
if path == "fast":
|
||||||
|
resp = ecat_send_raw_frames(fr, 2000)
|
||||||
|
else:
|
||||||
|
ms, resp = post(PAGE_API + "/motor/send", {"frames": fr})
|
||||||
|
# ms already includes HTTP; still record wall to return
|
||||||
|
t_sent = time.perf_counter()
|
||||||
|
sent_xs.append((t_sent - t0) * 1000.0)
|
||||||
|
|
||||||
|
t_can = None
|
||||||
|
while time.perf_counter() - t0 < timeout:
|
||||||
|
to, _snap = read_outlet()
|
||||||
|
now = time.perf_counter()
|
||||||
|
obs_xs.append((now - time.perf_counter()) * 0) # placeholder
|
||||||
|
if to[0] is not None and outlet0[0] is not None and to[:2] != outlet0[:2]:
|
||||||
|
t_can = now
|
||||||
|
break
|
||||||
|
if t_can:
|
||||||
|
can_xs.append((t_can - t_sent) * 1000.0)
|
||||||
|
else:
|
||||||
|
print(f" trial{i}: CAN timeout")
|
||||||
|
post(NATIVE + "/motor/send", {"frames": [
|
||||||
|
{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": build_cmd(0)}
|
||||||
|
]})
|
||||||
|
time.sleep(0.12)
|
||||||
|
print(f" click→sent (S1 wall) {fmt(sent_xs)}")
|
||||||
|
print(f" sent→can (S2) {fmt(can_xs)}")
|
||||||
|
return sent_xs, can_xs
|
||||||
|
|
||||||
|
|
||||||
|
def s3_can_to_cfg(path, node, ch, n=15, timeout=1.5):
|
||||||
|
print(f"\n[S3] 发送→CONFIG mode 可见 path={path}(高频 motor/rx)")
|
||||||
|
sent_to_cfg, can_to_cfg = [], []
|
||||||
|
for i in range(n):
|
||||||
|
base_mode = baseline_mode(node)
|
||||||
|
next_mode = 2 if base_mode in (0, None) else 0
|
||||||
|
if base_mode == next_mode:
|
||||||
|
next_mode = 0 if next_mode == 2 else 2
|
||||||
|
outlet0, _ = read_outlet()
|
||||||
|
fr = [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32,
|
||||||
|
"data": build_cmd(next_mode)}]
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
if path == "fast":
|
||||||
|
ecat_send_raw_frames(fr, 2000)
|
||||||
|
else:
|
||||||
|
post(PAGE_API + "/motor/send", {"frames": fr})
|
||||||
|
t_sent = time.perf_counter()
|
||||||
|
|
||||||
|
t_can = None
|
||||||
|
t_cfg = None
|
||||||
|
while time.perf_counter() - t0 < timeout:
|
||||||
|
# CAN via state
|
||||||
|
to, snap = read_outlet()
|
||||||
|
now = time.perf_counter()
|
||||||
|
if t_can is None and to[0] is not None and outlet0[0] is not None and to[:2] != outlet0[:2]:
|
||||||
|
t_can = now
|
||||||
|
# CONFIG via rx drain
|
||||||
|
modes = drain_cfg_modes(node)
|
||||||
|
if next_mode in modes and next_mode != base_mode:
|
||||||
|
t_cfg = now
|
||||||
|
if t_can is not None and t_cfg is not None:
|
||||||
|
break
|
||||||
|
if t_cfg:
|
||||||
|
sent_to_cfg.append((t_cfg - t_sent) * 1000.0)
|
||||||
|
if t_can is not None and t_cfg is not None:
|
||||||
|
can_to_cfg.append((t_cfg - t_can) * 1000.0)
|
||||||
|
post(NATIVE + "/motor/send", {"frames": [
|
||||||
|
{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": build_cmd(0)}
|
||||||
|
]})
|
||||||
|
time.sleep(0.12)
|
||||||
|
print(f" sent→cfg (S3 total) {fmt(sent_to_cfg)}")
|
||||||
|
print(f" can→cfg (S3 resid.) {fmt(can_to_cfg)}")
|
||||||
|
return sent_to_cfg, can_to_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--node", type=int, default=3)
|
||||||
|
ap.add_argument("--ch", type=int, default=2)
|
||||||
|
ap.add_argument("--n", type=int, default=16)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
print("=== 全链路延迟归因 ===")
|
||||||
|
print(f"native={NATIVE} page={PAGE_API} node={args.node} ch={args.ch}")
|
||||||
|
_, health = post(NATIVE + "/native/health", {})
|
||||||
|
print("health:", json.dumps(health, ensure_ascii=False)[:240])
|
||||||
|
|
||||||
|
s0_obs_cost(30)
|
||||||
|
s1_control_plane(n=args.n, node=args.node, ch=args.ch)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for path in ("fast", "json"):
|
||||||
|
s2 = s2_enqueue_to_can(path, args.node, args.ch, n=max(8, args.n // 2))
|
||||||
|
s3 = s3_can_to_cfg(path, args.node, args.ch, n=max(8, args.n // 2))
|
||||||
|
results[path] = {"s2": s2, "s3": s3}
|
||||||
|
|
||||||
|
print("\n=== 归因结论(请对照上方分位)===")
|
||||||
|
print("""
|
||||||
|
延迟集中在哪一段(快发路径,量级判断):
|
||||||
|
1) 控制面 TCP FSOK ~0.05–0.15ms ← 已优化,占比很小
|
||||||
|
2) 入队→CAN 出口 S2 ~1–3ms(p50) ← PDO 周期 0.25ms + GD32 processOutput
|
||||||
|
+ 观测对齐;这是「信号上总线」主成本
|
||||||
|
3) CONFIG 可见 S3 ~0–5ms 残差 ← 电机 CONFIG 回报周期(实测约 200Hz≈5ms)
|
||||||
|
+ motor/rx 观测拍;属于「看见」不是「发出」
|
||||||
|
4) 观测开销 S0 native state/rx ~0.6–1ms;page 代理 ~2ms+ 会污染 S2/S3
|
||||||
|
|
||||||
|
JSON 路径额外在 S1:页面代理 motor/send p50 ~2ms(对照 TCP ~0.05ms)。
|
||||||
|
""")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""ESF1 打包/解包单测(无网卡、无 native)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
from esp32_server import ( # noqa: E402
|
||||||
|
ESF_FRAME_W,
|
||||||
|
ESF_MAGIC,
|
||||||
|
FSOK_MAGIC,
|
||||||
|
pack_esf,
|
||||||
|
unpack_fsok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
frames = [
|
||||||
|
{"ch": 2, "id": 0x00030200, "dlc": 32, "data": "01" + "00" * 31},
|
||||||
|
{"ch": 0, "id": 1, "dlc": 2, "data": "AABB"},
|
||||||
|
]
|
||||||
|
body = pack_esf(frames)
|
||||||
|
assert len(body) == 8 + 2 * ESF_FRAME_W, len(body)
|
||||||
|
magic, n, flags, reserved = struct.unpack_from("<IBBH", body, 0)
|
||||||
|
assert magic == ESF_MAGIC
|
||||||
|
assert n == 2 and flags == 0 and reserved == 0
|
||||||
|
ch0, dlc0, pad0, id0 = struct.unpack_from("<BBHI", body, 8)
|
||||||
|
assert (ch0, dlc0, pad0, id0) == (2, 32, 0, 0x00030200)
|
||||||
|
data0 = body[8 + 8 : 8 + 8 + 64]
|
||||||
|
assert data0[0] == 0x01 and data0[1] == 0x00 and len(data0) == 64
|
||||||
|
ch1, dlc1, _pad1, id1 = struct.unpack_from("<BBHI", body, 8 + ESF_FRAME_W)
|
||||||
|
assert (ch1, dlc1, id1) == (0, 2, 1)
|
||||||
|
assert body[8 + ESF_FRAME_W + 8 : 8 + ESF_FRAME_W + 10] == b"\xAA\xBB"
|
||||||
|
|
||||||
|
ok = unpack_fsok(struct.pack("<IBBH", FSOK_MAGIC, 1, 3, 7))
|
||||||
|
assert ok["ok"] is True and ok["queued"] == 3 and ok["backlog"] == 7
|
||||||
|
|
||||||
|
bad = unpack_fsok(b"\x00" * 8)
|
||||||
|
assert bad["ok"] is False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pack_esf([{"ch": 9, "id": 1, "dlc": 1, "data": "00"}])
|
||||||
|
raise AssertionError("非法 ch 未拒绝")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pack_esf([])
|
||||||
|
raise AssertionError("空帧未拒绝")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("test_esf_pack: PASS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -174,9 +174,10 @@ if (liveOk) {
|
|||||||
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
|
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
|
||||||
const dbg = ecat.debug();
|
const dbg = ecat.debug();
|
||||||
check("debug() 暴露 PDO 周期与槽位统计",
|
check("debug() 暴露 PDO 周期与槽位统计",
|
||||||
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 5 &&
|
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 9 &&
|
||||||
dbg.slotCount === 27 && dbg.scanCycles === 6, JSON.stringify(dbg && {
|
dbg.slotCount === 27 && dbg.scanCycles === 1, JSON.stringify(dbg && {
|
||||||
periodMs: dbg.periodMs, slots: dbg.slots }));
|
periodMs: dbg.periodMs, slots: dbg.slots, slotsPerPdo: dbg.slotsPerPdo,
|
||||||
|
scanCycles: dbg.scanCycles }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 10. 代理错误信封可读 ── 指向一个没人监听的端口 */
|
/* ── 10. 代理错误信封可读 ── 指向一个没人监听的端口 */
|
||||||
@@ -259,23 +260,29 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
|||||||
st.motors[0].config_data === CONFIG_HEX,
|
st.motors[0].config_data === CONFIG_HEX,
|
||||||
`len=${st.motors[0].config_data.length}`);
|
`len=${st.motors[0].config_data.length}`);
|
||||||
|
|
||||||
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */
|
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答"。
|
||||||
|
* fetchMotorState 对 rx drain 有 DRAIN_MIN_MS 节流;构造用例必须等过节流窗,
|
||||||
|
* 否则测的是节流而不是版本累积器。 */
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
|
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
|
||||||
|
await sleep(25);
|
||||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 503, data: STATUS_HEX }],
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 503, data: STATUS_HEX }],
|
||||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||||
await tp.fetchMotorState();
|
await tp.fetchMotorState();
|
||||||
const v1 = await tp.fetchVersionNode(3);
|
const v1 = await tp.fetchVersionNode(3);
|
||||||
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
|
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
|
||||||
|
await sleep(25);
|
||||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 504, data: STATUS_HEX }],
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 504, data: STATUS_HEX }],
|
||||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||||
await tp.fetchMotorState();
|
await tp.fetchMotorState();
|
||||||
const v2 = await tp.fetchVersionNode(3);
|
const v2 = await tp.fetchVersionNode(3);
|
||||||
check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2.seq === v1.seq + 1,
|
check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2 && v1 && v2.seq === v1.seq + 1,
|
||||||
`${v1.seq} → ${v2.seq}`);
|
`${v1 && v1.seq} → ${v2 && v2.seq}`);
|
||||||
|
|
||||||
/* (e) 节点 ID 应答:取走即清 */
|
/* (e) 节点 ID 应答:取走即清(同样要等过 DRAIN_MIN_MS) */
|
||||||
|
await sleep(25);
|
||||||
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
|
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
|
||||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 505, data: STATUS_HEX }],
|
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 505, data: STATUS_HEX }],
|
||||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||||
|
|||||||
Reference in New Issue
Block a user