feat(motor): 序列每步新增绿色「单发」按钮,支持仅发送该步动作一次
This commit is contained in:
@@ -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); }
|
||||
|
||||
+56
-1
@@ -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() {
|
||||
<input class="seq-num seq-hold" data-f="holdMs" type="number" value="${s.holdMs}" min="0" max="600000" step="100" aria-label="步骤 ${i + 1} 保持时间 ms" />
|
||||
<span class="seq-hold-unit">ms</span>
|
||||
<div class="seq-ops">
|
||||
<button data-op="sendOne" title="单发本步(仅发送这一步的全部电机动作,不推进序列)" class="seq-send-one">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M4.5 2.8 11 7l-6.5 4.2z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button data-op="addAction" title="本步添加电机动作(复制最后一个动作)">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
@@ -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; }
|
||||
|
||||
@@ -1 +1 @@
|
||||
5246fff769cad8c1356ddbeebe2082e0e8bce592f19c38ad9629189132a1674b
|
||||
ef593add975f1942dd45793f6fc39182cd669eafe32ff19c632d285c09d6a5ca
|
||||
@@ -280,6 +280,7 @@
|
||||
命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID);
|
||||
执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms),
|
||||
发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。
|
||||
每步的绿色「▶」按钮为单发:只把该步全部动作下发一次,不循环、不推进步骤、不等待保持时间。
|
||||
循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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("<f", 0.0) # target cur 0
|
||||
b[7:11] = struct.pack("<f", vel) # vel limit = 节点指纹
|
||||
b[11:15] = struct.pack("<f", ang) # target angle = sine
|
||||
for off in (15, 17, 19):
|
||||
struct.pack_into("<h", b, off, 100)
|
||||
return bytes(b).hex()
|
||||
|
||||
|
||||
stop = threading.Event()
|
||||
lock = threading.Lock()
|
||||
stats = {"sends": 0, "send_err": 0, "t0": time.time()}
|
||||
torn_events = [] # (t, node, dlc, data48hex, cfg_hex)
|
||||
batch_events = []
|
||||
torn_base = [0]
|
||||
|
||||
|
||||
def sender():
|
||||
"""每拍把两电机当前 CONFIG 回声成 CMD 发出去。"""
|
||||
cfgs = {}
|
||||
interval = 1.0 / SEND_HZ
|
||||
while not stop.is_set():
|
||||
t0 = time.time()
|
||||
try:
|
||||
rr = post("/api/motor/rxraw")
|
||||
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("<I", raw, off + 4)[0]
|
||||
w = min(dlc, 32)
|
||||
cfgs[(fid >> 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("<h", d, 0)[0] * 0.01
|
||||
cfg = cfgs.get(m["node"])
|
||||
if not (-40.0 <= mcu <= 125.0) or is_cfg_head(d, cfg):
|
||||
with lock:
|
||||
if len(torn_events) < 40:
|
||||
torn_events.append((round(time.time() - stats["t0"], 2),
|
||||
m["node"], m.get("dlc"), d.hex(),
|
||||
(cfg or b"").hex()))
|
||||
try:
|
||||
rr = post("/api/motor/rxraw")
|
||||
except Exception:
|
||||
continue
|
||||
rc = rr.get("cycle")
|
||||
if rc in seen_r:
|
||||
continue
|
||||
seen_r.add(rc)
|
||||
if len(seen_r) > 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("<I", raw, off + 4)[0]
|
||||
w = min(dlc, 32)
|
||||
d = raw[off + 8: off + 8 + w]
|
||||
node = (fid >> 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()
|
||||
Reference in New Issue
Block a user