Files
DCU_ECAT_Master/ecat_tool.py
T

563 lines
24 KiB
Python
Raw Normal View History

2026-09-03 01:56:29 +08:00
#!/usr/bin/env python3
"""ecat_tool.py — DCU LAN9252 EtherCAT 一体化主站工具(连接/收发/烧录)。
子命令:
adapters 列出 Npcap 适配器
status [substr] 扫描从站: 身份字/AL 状态/邮箱/PDO 尺寸
flash [--bin FILE] [substr] 烧录 EEPROM(gen_eeprom.py 镜像或 --bin)+逐字校验
eeprom read START COUNT [--out FILE] [substr]
eeprom write FILE [substr] 写 bin(奇数尾补 0xFF)+回读校验
eeprom verify FILE [substr] 只读校验
sdo read IDX SUB [--size N] [substr] CoE SDO 读(需 PREOP+,邮箱来自 SII)
sdo write IDX SUB HEX [substr] CoE SDO 写(HEX=字节流)
send [--hex H | --u64 a,b,c,d] [--rate-ms N] [--duration S | --forever]
[--dc-cycle-us N] [--dc-shift-us N] [substr]
进 OP 循环发送输出 PDO 并回显输入 PDO
monitor [--rate-ms N] [--duration S] [--dc-cycle-us N] [substr]
进 OP 只读输入(输出保持 0)
substr 默认 "I226"(按适配器描述模糊匹配)。send/monitor 的输出默认全 0。
--dc-cycle-us 非 0 时启用分布时钟: 从站 SYNC0 引脚按该周期输出脉冲,
GD32 侧 Sync0_Isr 才会被触发(默认 free-run 模式下 SYNC0 完全不打)。
验证: 主站设 1000us, 从站 [ECAT][SYNC] 日志应显示 sync0 ≈ 1000/s。
"""
import argparse
import os
import struct
import sys
import time
try:
import pysoem
except ImportError:
print("pysoem 无法加载:请先安装 Npcap(本目录 npcap-1.88.exe,"
"保持勾选 'Install WinPcap API-compatible Mode')")
sys.exit(1)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
EEPROM_SIZE = 4096 # AT24C32
# state_check 的 timeout 单位是微秒(pysoem: "Timeout value in us");
# 2000000us = 2s,与 SOEM 的 EC_TIMEOUTSTATE 默认值一致。
# 此前各处传 2000 实际只等 2ms,能通过纯属从站 ack 快,属侥幸。
EC_TIMEOUT_STATE_US = 2_000_000
AL_STATE = {0x00: "NONE", 0x01: "INIT", 0x02: "PREOP",
0x03: "BOOT", 0x04: "SAFEOP", 0x08: "OP"}
def al_name(code):
return AL_STATE.get(code, f"0x{code:02X}")
def find_adapter(substr):
for a in pysoem.find_adapters():
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
if substr.lower() in desc.lower():
return a
return None
def open_master(substr):
adapter = find_adapter(substr)
if adapter is None:
print(f"没有找到描述包含 {substr!r} 的适配器,可用:")
list_adapters()
sys.exit(1)
desc = adapter.desc.decode() if isinstance(adapter.desc, bytes) else adapter.desc
master = pysoem.Master()
master.open(adapter.name)
try:
n = master.config_init()
except Exception as e:
raise SystemExit(f"总线上没有发现从站({type(e).__name__}: {e})——"
"检查 DCU 板供电、网线是否接到该网口")
if not n:
raise SystemExit("总线上没有发现从站——检查 DCU 板供电、网线是否接到该网口")
return master, desc
def list_adapters():
for i, a in enumerate(pysoem.find_adapters()):
desc = a.desc.decode() if isinstance(a.desc, bytes) else a.desc
print(f"[{i}] {desc}\n {a.name}")
def print_slave_info(master):
print(f"从站: {len(master.slaves)} 个")
for i, s in enumerate(master.slaves):
print(f" [{i}] name={s.name!r}")
print(f" VendorID=0x{s.man:08X} ProductCode=0x{s.id:08X} Rev={s.rev}")
print(f" AL state={al_name(s.state & 0x0F)} AL code=0x{s.al_status:04X}")
try:
print(f" 输出 {len(bytes(s.output))}B / 输入 {len(bytes(s.input))}B "
f"(config_map 后有效)")
except Exception:
pass
# ---------------------------------------------------------------- status
def cmd_status(args):
if args.substr in ("-l", "--list"):
list_adapters()
return
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
print_slave_info(master)
if master.slaves and master.slaves[0].man == 0xFFFFFFFF:
print("\n提示: 身份字全 F = EEPROM 无效。先执行: ecat_tool.py flash")
# ---------------------------------------------------------------- eeprom
def eeprom_read_word(slave, word):
return bytes(slave.eeprom_read(word))[:2]
def flash_image(slave, img, label):
if len(img) % 2 != 0:
img += b"\xFF"
if len(img) > EEPROM_SIZE:
raise SystemExit(f"镜像 {len(img)}B 超过 EEPROM 容量 {EEPROM_SIZE}B")
# 字节序探针(写入 0x1234 找读回偏移,随后恢复擦除态)
test = b"\x34\x12"
slave.eeprom_write(0x0200, test)
rb = bytes(slave.eeprom_read(0x0200))
off = rb.find(test)
if off < 0:
raise SystemExit(f"EEPROM 写探针失败: 读回 {rb.hex().upper()}")
slave.eeprom_write(0x0200, b"\xFF\xFF")
print(f"字节序探针 OK (读回偏移 {off})")
n_written = n_fail = 0
fails = []
for word in range(len(img) // 2):
val = struct.unpack_from("<H", img, word * 2)[0]
if val == 0xFFFF:
continue
data = struct.pack("<H", val)
try:
slave.eeprom_write(word, data)
except Exception as e:
fails.append((word, data.hex(), f"WRITE EXC {type(e).__name__}: {e}"))
n_fail += 1
continue
if eeprom_read_word(slave, word) != data:
fails.append((word, data.hex(), eeprom_read_word(slave, word).hex()))
n_fail += 1
n_written += 1
if n_written % 64 == 0:
print(f" ... 已写 {n_written} 字")
print(f"[{label}] 刷写完成: 成功 {n_written} 字, 失败 {n_fail} 字")
if fails:
for w, exp, act in fails[:20]:
print(f" word 0x{w:04X}: 期望 {exp.upper()} 实际 {act.upper()}")
raise SystemExit(3)
print(">>> 板子断电重新上电后 ESC 才会重载配置(身份字/PDI/SYNC)")
def cmd_flash(args):
if args.bin:
with open(args.bin, "rb") as f:
img = f.read()
label = os.path.basename(args.bin)
if len(img) % 2:
img += b"\xFF"
else:
from ecat_flash_eeprom import load_image # 自带 gen_eeprom.py 自校验
img = load_image()
label = "gen_eeprom.py"
print(f"镜像: {label}, {len(img)} 字节")
master, desc = open_master(args.substr)
print(f"适配器: {desc}, 从站应答正常")
flash_image(master.slaves[0], img, label)
def cmd_eeprom(args):
master, _ = open_master(args.substr)
slave = master.slaves[0]
if args.op == "read":
start, count = args.start, args.count
rows = []
for w in range(start, start + count):
rows.append(struct.unpack("<H", eeprom_read_word(slave, w))[0])
for base in range(0, count, 8):
chunk = rows[base:base + 8]
print(f" 0x{start + base:04X}: " +
" ".join(f"{v:04X}" for v in chunk))
if args.out:
with open(args.out, "wb") as f:
f.write(struct.pack(f"<{count}H", *rows))
print(f"已保存 {args.out} ({count} word)")
elif args.op == "write":
with open(args.file, "rb") as f:
data = f.read()
if len(data) % 2:
data += b"\xFF"
if len(data) > EEPROM_SIZE:
raise SystemExit(f"{len(data)}B 超过 EEPROM 容量 {EEPROM_SIZE}B")
n = n_fail = 0
for word in range(len(data) // 2):
val = struct.unpack_from("<H", data, word * 2)[0]
if val == 0xFFFF:
continue
raw = struct.pack("<H", val)
slave.eeprom_write(word, raw)
if eeprom_read_word(slave, word) != raw:
print(f" word 0x{word:04X} 校验失败")
n_fail += 1
n += 1
print(f"写 {n} 字, 失败 {n_fail} 字" + ("" if not n_fail else " -> FAIL"))
sys.exit(0 if n_fail == 0 else 3)
elif args.op == "verify":
with open(args.file, "rb") as f:
data = f.read()
if len(data) % 2:
data += b"\xFF"
diffs = 0
for word in range(min(len(data), EEPROM_SIZE) // 2):
expect = struct.unpack_from("<H", data, word * 2)[0]
actual = struct.unpack("<H", eeprom_read_word(slave, word))[0]
if expect != actual:
print(f" word 0x{word:04X}: 文件 0x{expect:04X} != 芯片 0x{actual:04X}")
diffs += 1
print("verify: " + ("OK, 全部一致" if diffs == 0 else f"{diffs} 字不符 -> FAIL"))
sys.exit(0 if diffs == 0 else 3)
# ---------------------------------------------------------------- SDO
def cmd_sdo(args):
master, desc = open_master(args.substr)
s = master.slaves[0]
if args.op == "read":
try:
data = bytes(s.sdo_read(args.index, args.sub, args.size))
except Exception as e:
raise SystemExit(f"SDO 读失败: {type(e).__name__}: {e}")
print(f"0x{args.index:04X}:{args.sub} = {data.hex().upper()} ({len(data)}B)")
else:
try:
payload = bytes.fromhex(args.hex.replace(" ", ""))
except ValueError:
raise SystemExit("HEX 非法: 只能是十六进制字节流")
try:
s.sdo_write(args.index, args.sub, payload)
except Exception as e:
raise SystemExit(f"SDO 写失败: {type(e).__name__}: {e}")
print(f"0x{args.index:04X}:{args.sub} <- {payload.hex().upper()} ({len(payload)}B) OK")
# ---------------------------------------------------------------- PDO 收发
def enter_op(master, dc_cycle_us=0, dc_shift_us=0):
"""config_map → (可选 DC) → SAFEOP → OP。
dc_cycle_us=0 时保持 free-run(SYNC0 引脚无脉冲,从站 Sync0_Isr 不会触发)。
非 0 时先 config_dc() 测量传播延迟,再对从站下发 SYNC0 周期,
必须在 config_map() 之后、进 SAFEOP 之前完成(ETG.1020 DC 配置顺序)。
"""
master.state = pysoem.PREOP_STATE | pysoem.STATE_ACK
master.write_state()
if master.state_check(pysoem.PREOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.PREOP_STATE:
# read_state 拿真实 AL Status Code(广播 state_check 不刷新 al_status,
# 陈旧缓存恒显示 0x0000)。首败常见 0x0016/0x001D 类:停止回 INIT 时 SSC
# 禁用了邮箱 SM0/SM1,CheckSmSettings 拒绝 INIT→PREOP,重新 config_init 即恢复。
try:
master.read_state()
except Exception:
pass
codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves]
raise SystemExit(f"进 PREOP 失败: AL={codes}(真实值) —— 从站未 ack AL Control=PREOP。"
"0x0016/0x001D 类=邮箱 SM0/SM1 被停止时禁用(重跑即恢复);"
"抓 GD32 串口看 [ECAT][AL] evt/req")
# free-run 时清残留 DC 激活(2026-09-03 00:27 卡死根因之一):之前跑过 DC 的
# 0x0981 激活位跨重连不清,从站会按 DC 同步模式等 PLL,free-run 下永不进 OP。
# 必须在 config_map(其内部请求 SAFEOP)之前清除。
if not dc_cycle_us:
try:
master.slaves[0].dc_sync(False, 0, 0)
print("已清除残留 DC 激活(free-run)")
except Exception as e:
print(f"WARN: 清 DC 激活失败({type(e).__name__}: {e})")
master.config_map()
# 不要写 0x1C32:01 / 0x1C33:01 去改 SyncType:该子索引只允许在 PREOP 写
# (ACCESS_WRITE_PREOP),而 config_map() 已把从站推到 SAFEOP,实测返回
# SdoError 0x08000022(当前状态不可写)。而且没必要——SSC 在 PREOP→SAFEOP 的
# StartInputHandler() 会自动按 DC 寄存器/PDO 长度推导出 SM_SYNCHRON
# (ecatslv.c:849-880),coeappl.c 里的 FREERUN 只是上电初值。
if dc_cycle_us:
has_dc = master.config_dc()
if not has_dc:
raise SystemExit("config_dc(): 总线上没有支持 DC 的从站 —— "
"检查 SII word1 高字节(0x0151 Sync/Latch 配置)是否为 0xCC")
master.slaves[0].dc_sync(True, dc_cycle_us * 1000, dc_shift_us * 1000)
print(f"DC 已启用: SYNC0 周期 {dc_cycle_us}us"
+ (f", shift {dc_shift_us}us" if dc_shift_us else "")
+ f", 主站 DC 时间={master.dc_time}ns")
# 显式带 ACK 写 SAFEOP(config_map 内部已推过一次,这里再立一次并顺带清掉
# 上一轮残留的错误位——SSC 只有带 ACK 位的写才能清错+迁移,普通写会被忽略)
master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK
master.write_state()
if master.state_check(pysoem.SAFEOP_STATE, EC_TIMEOUT_STATE_US) != pysoem.SAFEOP_STATE:
try:
master.read_state() # 真实 AL Status Code(广播 state_check 不刷新)
except Exception:
pass
codes = [(al_name(sl.state & 0x0F), f"0x{sl.al_status:04X}") for sl in master.slaves]
raise SystemExit(f"进 SAFEOP 失败: AL={codes}(真实值) —— 检查 SII 的 SM/FMMU 类别"
"(EEPROM 是否已刷)与 GD32 SSC 是否运行")
# 主站侧直写过程数据看门狗(双保险,SOEM set_watchdog → FPWR 0x0420 = 1000ms)。
# 从站固件 ≥1.5.45 也会写 1s,但板上固件未更新时 100ms 复位默认值会在 OP 等待期
# 触发 0x001B 拖回 SAFEOP;主站每次连接直接写,不依赖固件版本。
try:
master.slaves[0].set_watchdog('processdata', 1000.0)
except Exception as e:
print(f"WARN: 主站侧写看门狗失败({type(e).__name__}: {e})")
# SAFEOP -> OP:从站有输出 PDO 时,SSC 要求先收到至少一帧过程数据才 ack OP
# (ecatslv.c:1993 的 `nPdOutputSize == 0 || bEcatFirstOutputsReceived`),
# 否则迁移被挂起、随后回落,表现为 "进 OP 失败 ... code=0x0000"。
# 所以必须边发 processdata 边等,不能只 write_state 后干等。
# AL Control 一律带 ACK 位(0x10):SSC 对"错误位已置+无 ACK+请求≠INIT"的请求
# 直接忽略(ecatslv.c:1376-1384),残留的 0x001B 错误位不清掉,写 OP 无人应答。
master.state = pysoem.OP_STATE | pysoem.STATE_ACK
master.write_state()
# 状态查询用 read_state() 而非 state_check():后者 slave=0 广播模式不更新
# slavelist[0].ALstatuscode(报错里的 code 是陈旧缓存,恒 0x0000),且内部
# 1ms 轮询发帧风暴。read_state 无错误时 1 帧 BRD,有错误位时逐从站 FPRD
# 拿**真实 AL Status Code**。失败时输出真实错误码+状态轨迹。
deadline = time.time() + 12.0 # 12s:覆盖从站 ESM 超时(9s,固件≥1.5.47)+恢复重试
trail = []
last_key = None
state = None
code = 0
wkc_samples = []
next_rewrite = 0.0 # OP|ACK 重写节流:每 200ms 最多一次(避免 AL Control 写风暴)
while time.time() < deadline:
master.send_processdata()
wkc = master.receive_processdata(2000)
if not wkc_samples or wkc_samples[-1] != (wkc, master.expected_wkc):
wkc_samples.append((wkc, master.expected_wkc))
try:
master.read_state()
except Exception:
pass
state = master.slaves[0].state & 0x0F
code = master.slaves[0].al_status
key = (state, code)
if key != last_key:
trail.append((al_name(state), code))
last_key = key
if state == pysoem.OP_STATE and code == 0:
break
# 跌回 PREOP/INIT 时:带 ACK 重写 SAFEOP 立起状态,再带 ACK 重请 OP
if state < pysoem.SAFEOP_STATE:
master.state = pysoem.SAFEOP_STATE | pysoem.STATE_ACK
master.write_state()
master.state_check(pysoem.SAFEOP_STATE, 500_000)
next_rewrite = 0.0
if time.time() >= next_rewrite:
master.state = pysoem.OP_STATE | pysoem.STATE_ACK
master.write_state()
next_rewrite = time.time() + 0.2
time.sleep(0.005) # 循环节奏 ~7ms:LRW 间隔稳低于任何看门狗配置
if state != pysoem.OP_STATE or code != 0:
try:
master.read_state()
except Exception:
pass
st = master.slaves[0]
trail.append((al_name(st.state & 0x0F), st.al_status))
try:
wd_ms = st.get_watchdog('processdata')
wd_info = f", 从站 PD 看门狗={wd_ms:.0f}ms"
except Exception:
wd_info = ""
wkc_info = " / ".join(f"wkc={w}/{e}" for w, e in wkc_samples[:6])
raise SystemExit(
f"进 OP 失败: AL={al_name(st.state & 0x0F)} code=0x{st.al_status:04X}(真实值{wd_info}) —— "
f"等待期轨迹: {' -> '.join(f'{n}+0x{c:04X}' for n, c in trail)}; LRW WKC: {wkc_info}。"
"0x001B=SM watchdog;0x001A=DC 残留激活;0x001D/0x001E=SM 配置不匹配;"
"0x0000=从站在等首帧输出。请抓 GD32 串口 [ECAT][AL] evt/req 日志对照")
def leave_init(master):
try:
master.state = pysoem.INIT_STATE
master.write_state()
except Exception:
pass
def run_pdo(master, out_data, rate_ms, duration, label, dc_cycle_us=0, dc_shift_us=0):
out_len = len(bytes(master.slaves[0].output))
if out_data is None:
out_data = bytes(out_len)
if len(out_data) < out_len:
out_data += bytes(out_len - len(out_data))
try:
master.slaves[0].output = out_data
except Exception as e:
raise SystemExit(f"写输出缓冲失败({type(e).__name__}: {e}) —— output 长度={out_len}")
enter_op(master, dc_cycle_us, dc_shift_us)
mode = f"DC {dc_cycle_us}us" if dc_cycle_us else "free-run"
print(f"[{label}] 已进 OP({mode}): 输出 {out_len}B / 输入 "
f"{len(bytes(master.slaves[0].input))}B, expected_wkc={master.expected_wkc}"
f"{', Ctrl+C 退出' if duration is None else f', 运行 {duration}s'}")
t_end = None if duration is None else time.time() + duration
cycles = wkc_errs = 0
last = 0.0
try:
while True:
master.send_processdata()
wkc = master.receive_processdata(2000)
cycles += 1
if wkc < master.expected_wkc:
wkc_errs += 1
now = time.time()
if now - last >= 0.5:
last = now
inp = bytes(master.slaves[0].input)
extra = ""
if len(inp) >= 32:
u64 = struct.unpack("<4Q", inp[:32])
extra = " u64=" + ",".join(f"0x{x:X}" for x in u64)
print(f"cycles={cycles} wkc_err={wkc_errs} in={inp.hex().upper()}{extra}")
if t_end is not None and now >= t_end:
break
time.sleep(rate_ms / 1000.0)
except KeyboardInterrupt:
print("\n用户中断")
finally:
leave_init(master)
print(f"[{label}] 结束: {cycles} 周期, wkc_err={wkc_errs}, 已回 INIT")
def parse_output_data(args):
if args.hex:
try:
return bytes.fromhex(args.hex.replace(" ", ""))
except ValueError:
raise SystemExit("--hex 非法: 只能是十六进制字节流")
if args.u64:
try:
vals = [int(v, 0) for v in args.u64.split(",")]
except ValueError:
raise SystemExit("--u64 非法: 逗号分隔的整数(支持 0x 前缀)")
if len(vals) > 4:
raise SystemExit("--u64 最多 4 个(4×64bit=32B)")
return struct.pack(f"<{len(vals)}Q", *vals)
return None
def cmd_send(args):
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
run_pdo(master, parse_output_data(args), args.rate_ms,
None if args.forever else args.duration, "send",
args.dc_cycle_us, args.dc_shift_us)
def cmd_monitor(args):
master, desc = open_master(args.substr)
print(f"适配器: {desc}")
run_pdo(master, None, args.rate_ms,
None if args.forever else args.duration, "monitor",
args.dc_cycle_us, args.dc_shift_us)
# ---------------------------------------------------------------- main
def main():
p = argparse.ArgumentParser(description="DCU LAN9252 EtherCAT 主站工具")
sub = p.add_subparsers(dest="cmd", required=False)
sub.add_parser("adapters", help="列出 Npcap 适配器")
sp = sub.add_parser("status", help="扫描从站身份/AL 状态")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("flash", help="烧录 EEPROM + 逐字校验")
sp.add_argument("--bin", help="指定 bin(默认用 gen_eeprom.py 镜像)")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("eeprom", help="EEPROM read/write/verify")
sp.add_argument("op", choices=["read", "write", "verify"])
sp.add_argument("file_or_start", nargs="?", default=None)
sp.add_argument("count_or_file", nargs="?", default=None)
sp.add_argument("--out")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("sdo", help="CoE SDO read/write")
sp.add_argument("op", choices=["read", "write"])
sp.add_argument("index", type=lambda v: int(v, 0))
sp.add_argument("sub", type=lambda v: int(v, 0))
sp.add_argument("hex", nargs="?", default=None)
sp.add_argument("--size", type=lambda v: int(v, 0), default=8)
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("send", help="进 OP 循环发送输出 PDO")
sp.add_argument("--hex", help="输出字节流(≤输出区长度)")
sp.add_argument("--u64", help="4 个 64bit, 逗号分隔, 如 0x1,0x2,0x3,0x4")
sp.add_argument("--rate-ms", type=int, default=100)
sp.add_argument("--duration", type=float, default=5.0)
sp.add_argument("--forever", action="store_true")
sp.add_argument("--dc-cycle-us", type=int, default=0,
help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)")
sp.add_argument("--dc-shift-us", type=int, default=0,
help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效")
sp.add_argument("substr", nargs="?", default="I226")
sp = sub.add_parser("monitor", help="进 OP 只读输入 PDO")
sp.add_argument("--rate-ms", type=int, default=200)
sp.add_argument("--duration", type=float, default=10.0)
sp.add_argument("--forever", action="store_true")
sp.add_argument("--dc-cycle-us", type=int, default=0,
help="启用 DC 并设 SYNC0 周期(us); 0=free-run(默认, SYNC0 不打)")
sp.add_argument("--dc-shift-us", type=int, default=0,
help="SYNC0 相移(us), 仅在 --dc-cycle-us 非 0 时有效")
sp.add_argument("substr", nargs="?", default="I226")
args = p.parse_args()
if args.cmd is None:
# 双击/无参数时默认执行 status
print("(未指定子命令, 默认执行 status; 完整用法: ecat_tool.py -h)\n")
args.cmd = "status"
args.substr = "I226"
if args.cmd == "adapters":
list_adapters()
elif args.cmd == "status":
cmd_status(args)
elif args.cmd == "flash":
cmd_flash(args)
elif args.cmd == "eeprom":
if args.op == "read":
if args.file_or_start is None or args.count_or_file is None:
raise SystemExit("用法: eeprom read START COUNT [--out FILE]")
args.start = int(args.file_or_start, 0)
args.count = int(args.count_or_file, 0)
else:
if args.file_or_start is None:
raise SystemExit(f"用法: eeprom {args.op} FILE")
args.file = args.file_or_start
cmd_eeprom(args)
elif args.cmd == "sdo":
if args.op == "write" and args.hex is None:
raise SystemExit("用法: sdo write IDX SUB HEX")
cmd_sdo(args)
elif args.cmd == "send":
cmd_send(args)
elif args.cmd == "monitor":
cmd_monitor(args)
if __name__ == "__main__":
main()