diff --git a/css/motor.css b/css/motor.css index d58f05a..389f110 100644 --- a/css/motor.css +++ b/css/motor.css @@ -723,6 +723,10 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); } border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent); background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root)); } +.seq-step.sending { + border-color: color-mix(in srgb, var(--ok) 55%, transparent); + background: color-mix(in srgb, var(--ok) 5%, var(--bg-root)); +} .seq-locked .seq-step { opacity: .55; pointer-events: none; } .seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); } .seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; } @@ -754,6 +758,8 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); } } .seq-ops button svg { flex-shrink: 0; } .seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); } +.seq-ops button.seq-send-one { color: var(--ok); } +.seq-ops button.seq-send-one:hover:not(:disabled) { background: var(--ok-dim); color: var(--ok); } .seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); } .seq-ops button:disabled { opacity: .25; cursor: default; } .seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); } diff --git a/js/motor/main.js b/js/motor/main.js index d49599e..048a632 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -1997,6 +1997,7 @@ const seq = { round: 1, /* 当前轮(1 起) */ stepIdx: 0, /* 当前步索引 */ statusText: "未运行", + singleIdx: null, /* 正在单发的步骤索引,null = 无单发在途 */ }; const seqListEl = $("#seqList"); const seqStartBtn = $("#seqStartBtn"); @@ -2069,6 +2070,9 @@ function renderSeqList() { ms
+ @@ -2118,8 +2122,10 @@ function renderSeqList() { function updateSeqUi() { const running = seq.running; + const sendingIdx = seq.singleIdx; seqStartBtn.textContent = running ? "■ 停止序列" : "▶ 开始序列"; seqStartBtn.classList.toggle("btn-primary", running); + seqStartBtn.disabled = !running && sendingIdx != null; const st = $("#seqStatus"); if (st) { if (running) { @@ -2127,6 +2133,9 @@ function updateSeqUi() { const cnt = cs ? cs.actions.length : 0; st.textContent = `运行中 · 步骤 ${seq.stepIdx + 1}/${seq.steps.length} (${cnt} 电机) · 第 ${seq.round} 轮`; st.classList.add("on"); + } else if (sendingIdx != null) { + st.textContent = `单发步骤 ${sendingIdx + 1}…`; + st.classList.add("on"); } else { st.textContent = seq.statusText || `未运行 · ${seq.steps.length} 步`; st.classList.remove("on"); @@ -2134,7 +2143,9 @@ function updateSeqUi() { } seqListEl.classList.toggle("seq-locked", running); seqListEl.querySelectorAll(".seq-step").forEach((row) => { - row.classList.toggle("current", running && parseInt(row.dataset.idx, 10) === seq.stepIdx); + const idx = parseInt(row.dataset.idx, 10); + row.classList.toggle("current", running && idx === seq.stepIdx); + row.classList.toggle("sending", !running && sendingIdx === idx); }); seqLoopsInput.disabled = running; $("#seqAddBtn").disabled = running; @@ -2179,6 +2190,10 @@ seqListEl.addEventListener("click", (e) => { const step = seq.steps[i]; if (!step) return; const op = btn.dataset.op; + if (op === "sendOne") { + sendSeqStepOnce(i); + return; + } if (op === "up" && i > 0) { const t = seq.steps[i]; seq.steps[i] = seq.steps[i - 1]; seq.steps[i - 1] = t; } else if (op === "down" && i < seq.steps.length - 1) { @@ -2231,6 +2246,46 @@ seqStartBtn.addEventListener("click", () => { startSeq(); }); +/* 单发一步:只把该步的全部动作经一次 sendBurst 近同时下发,不循环、不推进步骤、不等 holdMs。 + * 与序列运行互斥(运行中按钮被 .seq-locked 屏蔽),同一时刻只允许一个单发在途。 */ +async function sendSeqStepOnce(i) { + if (seq.running || seq.singleIdx != null) return; + const step = seq.steps[i]; + if (!step || !step.actions.length) return; + if (!state.connected) { toast("设备未连接", "err"); return; } + if (inBusCooldown()) return; + const chBad = step.actions.find((a) => !state.chEnabled[a.ch]); + if (chBad) { toast(`步骤 ${i + 1} 的通道 CH${chBad.ch} 已禁用`, "err"); return; } + + seq.singleIdx = i; + updateSeqUi(); + const targets = step.actions.map((a) => `CH${a.ch}·N${a.node}`).join(" "); + const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + $("#sbLastSend").textContent = `最近发送: 单发步${i + 1} → ${step.actions.length} 电机 [${targets}] ${time}`; + try { + const res = await sendBurst(step.actions); + if (res.busy) { toast("发送通道忙,请稍后重试", "warn"); return; } + if (!res.ok) { toast(`步骤 ${i + 1} 发送失败:${res.desc || "未知错误"}`, "err"); return; } + for (const a of step.actions) { + state.lastSentMode.set(a.node, parseInt(a.mode, 10) || 0); + state.lastSentTargets.set(a.node, { + mode: parseInt(a.mode, 10) || 0, + angle: parseFloat(a.angle) || 0, + velocity: parseFloat(a.velocity) || 0, + current: parseFloat(a.current) || 0, + voltage: parseFloat(a.voltage) || 0, + }); + } + state.txCount++; + renderNodeList(); + seq.statusText = `已单发步骤 ${i + 1}`; + toast(`步骤 ${i + 1} 已单发 · ${step.actions.length} 电机 [${targets}]`, "ok"); + } finally { + seq.singleIdx = null; + updateSeqUi(); + } +} + function startSeq() { if (!state.connected) { toast("设备未连接", "err"); return; } if (seq.steps.length === 0) { toast("请先添加至少一个步骤", "warn"); return; } diff --git a/lab/_last_gd32_sha.txt b/lab/_last_gd32_sha.txt index 418be79..368f54a 100644 --- a/lab/_last_gd32_sha.txt +++ b/lab/_last_gd32_sha.txt @@ -1 +1 @@ -5246fff769cad8c1356ddbeebe2082e0e8bce592f19c38ad9629189132a1674b \ No newline at end of file +ef593add975f1942dd45793f6fc39182cd669eafe32ff19c632d285c09d6a5ca \ No newline at end of file diff --git a/pages/motor.html b/pages/motor.html index 57face6..c5b7db0 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -280,6 +280,7 @@ 命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID); 执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms), 发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。 + 每步的绿色「▶」按钮为单发:只把该步全部动作下发一次,不循环、不推进步骤、不等待保持时间。 循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。

diff --git a/scripts/repro_cmdload.py b/scripts/repro_cmdload.py new file mode 100644 index 0000000..a1dbcf1 --- /dev/null +++ b/scripts/repro_cmdload.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""复现实验:高频回发 CMD(与电机当前目标一致的 no-op)+ 全拍监控。 + +用户反馈:数据错乱要"频繁发指令"才出现。本脚本把 TX 压力拉起来,同时 +段2(全拍)与段4(全拍)双路监控,命中即记录 dlc/完整48B/当时CONFIG/时刻, +用于判定残余拼接的方向与机制。 + +安全性:CMD 载荷 = 电机当前自报 CONFIG 的回声(mode/limitV/cur/vel/ang) ++ 页面默认 PID(100/100/100),不改变电机行为。 + +用法: python scripts/repro_cmdload.py [秒数=90] [发送Hz=500] +""" +import json +import math +import socket +import struct +import sys +import threading +import time + +HOST, PORT = "127.0.0.1", 8090 +DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 90.0 +SEND_HZ = float(sys.argv[2]) if len(sys.argv) > 2 else 500.0 + + +def post(path, body=b"{}"): + req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n" + f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n" + "Connection: close\r\n\r\n").encode("ascii") + body + s = socket.create_connection((HOST, PORT)) + try: + s.settimeout(5) + s.sendall(req) + buf = bytearray() + while True: + c = s.recv(65536) + if not c: + break + buf.extend(c) + finally: + s.close() + return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace")) + + +def build_cmd(cfg, ang, vel): + """CMD 帧:mode3 速度+位置,limitV 回声当前 CONFIG,角度=正弦轨迹。 + vel 按节点取 200/150 —— 给 CONFIG 回显的 B7-10 速度字段打节点指纹。""" + b = bytearray(32) + b[0] = 3 # mode: Velocity_Angle + b[1:3] = cfg[1:3] # limitV u16 回声 + b[3:7] = struct.pack(" len(raw): + break + dlc = raw[off + 1] + fid = struct.unpack_from("> 16) & 0xFF] = raw[off + 8: off + 8 + w] + off += 8 + w + except Exception: + time.sleep(0.05) + continue + if len(cfgs) >= 2: + # 判别实验:两电机反相正弦 + 不同速度限,给帧内容打节点指纹。 + # node2: ang=1000+200sin, vel=200 node3: ang=1000-200sin, vel=150 + s = math.sin(2.0 * math.pi * (time.time() - stats["t0"]) / 10.0) + frames = [] + for n, c in sorted(cfgs.items()): + ang = 1000.0 + (200.0 * s if n == 2 else -200.0 * s) + vel = 200.0 if n == 2 else 150.0 + frames.append({"ch": 2, "id": hex((n << 16) | 0x0200), "dlc": 32, + "data": build_cmd(c, ang, vel)}) + try: + post("/api/motor/send", json.dumps({"frames": frames}).encode()) + with lock: + stats["sends"] += 1 + except Exception: + with lock: + stats["send_err"] += 1 + dt = interval - (time.time() - t0) + if dt > 0: + time.sleep(dt) + + +def is_cfg_head(d, cfg): + """段2槽头 8B 是否等于该节点当前 CONFIG 头(污染判据,比量程更准)。""" + return cfg and len(d) >= 8 and d[:8] == cfg[:8] and d[0] == 0x03 + + +def monitor(): + seen_s, seen_r = set(), set() + cfgs = {} + while not stop.is_set(): + try: + st = post("/api/motor/state") + except Exception: + time.sleep(0.01) + continue + cyc = st.get("cycles") + if cyc in seen_s: + continue + seen_s.add(cyc) + if len(seen_s) > 400000: + seen_s.clear() + for m in st.get("motors") or []: + if not m.get("online") or len(m.get("data") or "") < 96: + continue + d = bytes.fromhex(m["data"]) + mcu = struct.unpack_from(" 400000: + seen_r.clear() + raw = bytes.fromhex(rr.get("raw") or "") + off = 0 + for _ in range(rr.get("count") or 0): + if off + 8 > len(raw): + break + dlc = raw[off + 1] + fid = struct.unpack_from("> 16) & 0xFF + if (fid & 0xFFFF) == 0x0101: + cfgs[node] = d + if any(d[15:24]): + with lock: + if len(batch_events) < 40: + batch_events.append((round(time.time() - stats["t0"], 2), + node, d.hex())) + off += 8 + w + + +def main(): + try: + r = post("/api/app/load") + torn_base[0] = r.get("rx_torn") or 0 + except Exception: + pass + ts = threading.Thread(target=sender, daemon=True) + tm = threading.Thread(target=monitor, daemon=True) + ts.start(); tm.start() + time.sleep(DUR_S) + stop.set(); ts.join(3); tm.join(3) + try: + r = post("/api/app/load") + torn_now = r.get("rx_torn") or 0 + except Exception: + torn_now = -1 + with lock: + print(f"时长 {DUR_S:.0f}s 发送 {stats['sends']} 次(错{stats['send_err']}) " + f"≈{stats['sends']/DUR_S:.0f} CMD拍/s") + print(f"rx_torn: {torn_base[0]} -> {torn_now} (Δ={torn_now-torn_base[0]})") + print(f"段2 污染事件: {len(torn_events)}(前 8)") + for e in torn_events[:8]: + print(f" t={e[0]}s node={e[1]} dlc={e[2]}") + print(f" slot: {e[3]}") + print(f" cfg : {e[4]}") + print(f"段4 污染事件: {len(batch_events)}(前 8)") + for e in batch_events[:8]: + print(f" t={e[0]}s node={e[1]} data={e[2]}") + + +if __name__ == "__main__": + main()