From 2c6235d920e37b2c9657342d4113b2425ef67785 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Mon, 21 Sep 2026 02:11:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(ecat):=20PDO=20=E5=BF=AB=E5=8F=91=20ESF1?= =?UTF-8?q?=20=E6=A1=8C=E9=9D=A2=E5=BC=BA=E5=88=B6=E7=9B=B4=E9=80=9A?= =?UTF-8?q?=EF=BC=8C=E5=A0=B5=E6=AD=BB=E6=85=A2=E5=8F=91=E5=9B=9E=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP; 删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。 --- README.md | 9 +- docs/compose/spec/pdo-fast-send.md | 110 +++++++++ esp32_server.py | 206 ++++++++++++++++- js/common/desktop-bridge.js | 24 +- js/motor/main.js | 2 +- js/motor/transport-ecat.js | 50 +++- js/motor/transport.js | 2 +- pages/motor.html | 2 +- scripts/check_e2e_click_motion.py | 353 +++++++++++++++++++++++++++++ scripts/check_esf_latency.py | 118 ++++++++++ scripts/check_latency_breakdown.py | 331 +++++++++++++++++++++++++++ scripts/test_esf_pack.py | 60 +++++ scripts/test_motor_transport.mjs | 21 +- 13 files changed, 1267 insertions(+), 21 deletions(-) create mode 100644 docs/compose/spec/pdo-fast-send.md create mode 100644 scripts/check_e2e_click_motion.py create mode 100644 scripts/check_esf_latency.py create mode 100644 scripts/check_latency_breakdown.py create mode 100644 scripts/test_esf_pack.py diff --git a/README.md b/README.md index e94ccfc..55707f3 100644 --- a/README.md +++ b/README.md @@ -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` | -| `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`。 **业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控 ——互斥/退避/冷却/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 (无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501), 而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。 diff --git a/docs/compose/spec/pdo-fast-send.md b/docs/compose/spec/pdo-fast-send.md new file mode 100644 index 0000000..f94baaf --- /dev/null +++ b/docs/compose/spec/pdo-fast-send.md @@ -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) \ No newline at end of file diff --git a/esp32_server.py b/esp32_server.py index 74711a9..74d1b4e 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -44,6 +44,7 @@ import json import os import re import socket +import struct import subprocess import sys import threading @@ -189,6 +190,177 @@ g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已 g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释) 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(" 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(" dict: + if not resp8 or len(resp8) < 8: + return {"ok": False, "queued": 0, "backlog": 0, "log": ["FSOK 响应过短"]} + magic, ok, queued, backlog = struct.unpack_from(" 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: """把 ecat_native 提到 RealTime 进程优先级。 @@ -251,13 +423,34 @@ class _GuiApi: 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 { "ok": True, "mode": "desktop", "has_ecat_bridge": True, "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): """桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。 @@ -306,6 +499,7 @@ class _GuiApi: obj.setdefault("ok", status == 200) if status >= 400 and "log" not in obj: obj["log"] = [f"HTTP {status}"] + _note_fast_port(obj) return obj @@ -472,7 +666,7 @@ def _ecat_autostart(): 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)。 **不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms: @@ -496,7 +690,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None): payload = ( f"POST {path} HTTP/1.0\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" "Connection: close\r\n" "\r\n" @@ -525,7 +719,7 @@ def _ecat_post(port, path, body, timeout, connect_timeout=None): 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。 返回 (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; 不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。 + ctype: 可选 Content-Type(ESF1 二进制 body 用 application/octet-stream)。 """ global g_ecat_port # /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+)。 status, data = _ecat_post( 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 return status, data except Exception as e: # 连不上/超时 → 试下一个端口 @@ -572,7 +768,7 @@ def _ecat_proxy(path, body, timeout=6.0): start_logs = [f"autostart 异常: {type(e).__name__}: {e}"] if port: try: - status, data = _ecat_post(port, target, body, timeout) + status, data = _ecat_post(port, target, body, timeout, ctype=ctype) g_ecat_port = port return status, data except Exception as e: diff --git a/js/common/desktop-bridge.js b/js/common/desktop-bridge.js index bbd6d3c..93e7e36 100644 --- a/js/common/desktop-bridge.js +++ b/js/common/desktop-bridge.js @@ -8,6 +8,9 @@ * * 电机页在 tabs.html 的 iframe 内:pywebview 只注入顶层窗口,同源 iframe * 必须向上找 window.parent / window.top 上的 pywebview.api。 + * + * PDO 快发(pdo-fast-send):桌面壳且 native 带 fast_send_port 时,ECAT + * motor 发送优先 desktopEcatSendRaw(GUI 侧 ESF1 二进制直通入队)。 */ "use strict"; @@ -59,7 +62,26 @@ export async function desktopEcatPost(path, body, timeoutMs) { return api.ecat_api(path, body == null ? {} : body, timeoutMs || 4000); } +/** + * 桌面桥 PDO 快发(ESF1)。frames=[{ch,id,dlc,data(hex)}]。 + * @returns {Promise} {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 控制通道形态。 */ export function desktopBridgeLabel() { - return getDesktopEcatApi() ? "APP 原生桥" : "HTTP 代理"; + if (!getDesktopEcatApi()) return "HTTP 代理"; + return hasDesktopFastSend() ? "APP 二进制快发" : "APP 原生桥"; } diff --git a/js/motor/main.js b/js/motor/main.js index efdf366..ed70332 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -41,7 +41,7 @@ import { escapeHtml, flashBusyNow } from "../common/utils.js"; /* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */ import { createTransport, loadTransportId, saveTransportId, LS_TRANSPORT -} from "./transport.js?v=20260611c"; +} from "./transport.js?v=20260923i"; import { deviceLink } from "../common/device-link.js"; /* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移), * 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */ diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 5da425a..5ac3744 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -44,7 +44,8 @@ import { staleLimitMs } from "./refresh-rate.js"; import { deviceLink } from "../common/device-link.js"; import { desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi, -} from "../common/desktop-bridge.js?v=20260923g"; + desktopEcatSendRaw, hasDesktopFastSend, +} from "../common/desktop-bridge.js?v=20260923i"; /** 单个 PDO 周期回传的状态槽数。 * 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。 @@ -242,6 +243,41 @@ export function createEcatTransport(ctx) { 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)。 */ function unwrap(json, what) { if (!json || json.ok !== true) { @@ -533,7 +569,10 @@ export function createEcatTransport(ctx) { supportsPeriodSync: false, sendHint(ch) { - return desktopBridgeLabel() + " · motor/send · CH" + ch + + const fast = lastFastSendPath === "fast_tcp" + ? "二进制快发" + : desktopBridgeLabel(); + return fast + " · motor/send · CH" + ch + (periodMs ? " · PDO " + fmtPeriod(periodMs) : ""); }, @@ -706,6 +745,9 @@ export function createEcatTransport(ctx) { * 在**同一个 seq 沿**由从站一次性发出 —— 这是多轴同步下发的前提, * 逐帧调用会退化成一帧一个周期。 * 返回值补成 WiFi 的 results[] 形状(后端只回 queued/backlog)。 + * + * PDO 快发:桌面桥有 ecat_send_raw 时**只走** ESF1 二进制直通, + * 失败 ok:false、不回落 JSON(堵死慢发)。无桥(浏览器)才走 motor/send。 */ async sendFrames(frames, opts) { if (!started) await ensureStarted(); @@ -714,7 +756,7 @@ export function createEcatTransport(ctx) { 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) { const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败"; return frames.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg })); @@ -733,7 +775,7 @@ export function createEcatTransport(ctx) { ch: f.ch, })); if (!started) await ensureStarted(); - const r = await api("motor/send", { frames: built }, 6000); + const r = await sendViaFastOrJson({ frames: built }); if (!r || r.ok !== true) { const msg = (r && r.log && r.log.length) ? r.log.join(" / ") : "发送失败"; return built.map(() => ({ status: "error", code: -2, gd32_err: 0, desc: msg })); diff --git a/js/motor/transport.js b/js/motor/transport.js index 0298283..5c4e1d8 100644 --- a/js/motor/transport.js +++ b/js/motor/transport.js @@ -31,7 +31,7 @@ "use strict"; import { createWifiTransport } from "./transport-wifi.js"; -import { createEcatTransport } from "./transport-ecat.js?v=20260611c"; +import { createEcatTransport } from "./transport-ecat.js?v=20260923i"; /** localStorage key:记住上次选的传输,刷新页面后保持 */ export const LS_TRANSPORT = "dcu_motor_transport"; diff --git a/pages/motor.html b/pages/motor.html index f37d178..e23c12e 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -430,7 +430,7 @@ - + diff --git a/scripts/check_e2e_click_motion.py b/scripts/check_e2e_click_motion.py new file mode 100644 index 0000000..ae881da --- /dev/null +++ b/scripts/check_e2e_click_motion.py @@ -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(" 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()) diff --git a/scripts/check_esf_latency.py b/scripts/check_esf_latency.py new file mode 100644 index 0000000..0335b9f --- /dev/null +++ b/scripts/check_esf_latency.py @@ -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()) diff --git a/scripts/check_latency_breakdown.py b/scripts/check_latency_breakdown.py new file mode 100644 index 0000000..18b2359 --- /dev/null +++ b/scripts/check_latency_breakdown.py @@ -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(" 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(" new Promise((r) => setTimeout(r, ms)); 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 }]; 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: [] } }); await tp.fetchMotorState(); const v1 = await tp.fetchVersionNode(3); 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 }]; 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: [] } }); await tp.fetchMotorState(); const v2 = await tp.fetchVersionNode(3); - check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2.seq === v1.seq + 1, - `${v1.seq} → ${v2.seq}`); + check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2 && v1 && v2.seq === v1.seq + 1, + `${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" }]; 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: [] } });