feat: 新增 SerialMate 串口调试助手 v0.1.0
- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""protocol.py 协议自检(无硬件): DLPilot 实测帧向量回归 + CRC 标准校验值 + 解析同步
|
||||
|
||||
用法: python selftest.py (全部通过退出码 0)
|
||||
"""
|
||||
import random
|
||||
import sys
|
||||
|
||||
import protocol as d
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, got, want):
|
||||
ok = got == want
|
||||
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def check_close(name, got, want):
|
||||
ok = isinstance(got, float) and abs(got - want) < 1e-6
|
||||
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
cfg = d.ProtoConfig() # 默认即 DLPilot: D5/B5/01/SUM8/不含包头
|
||||
|
||||
# 1) 帧构造: DLPilot selftest_rs485.py 的实测向量(上游 protocols.md)
|
||||
for cmd, want in (
|
||||
('M730S0T2000', 'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C'),
|
||||
('M303', 'D5 01 04 4D 33 30 33 E8'),
|
||||
('M304', 'D5 01 04 4D 33 30 34 E9'),
|
||||
('M305', 'D5 01 04 4D 33 30 35 EA'),
|
||||
):
|
||||
check(f'build_frame({cmd})', cfg.build_frame(cmd.encode('ascii')).hex(' ').upper(), want)
|
||||
|
||||
# 2) CRC 算法实现对照公开标准校验值 check("123456789")
|
||||
check('crc8 check', d._crc8(b'123456789'), 0xF4)
|
||||
check('crc16_modbus check', d._crc16_modbus(b'123456789'), 0x4B37)
|
||||
check('crc16_xmodem check', d._crc16_xmodem(b'123456789'), 0x31C3)
|
||||
check('crc16_ccitt check', d._crc16_ccitt(b'123456789'), 0x29B1)
|
||||
|
||||
# 3) CRC 覆盖范围(手算向量: 包头 5A 板ID 11, payload 01 02)
|
||||
for scope, want in (('payload_only', '5A 11 02 01 02 03'),
|
||||
('no_header', '5A 11 02 01 02 16'),
|
||||
('with_header', '5A 11 02 01 02 70')):
|
||||
c = d.ProtoConfig(tx_hdr=0x5A, board=0x11, crc_scope=scope)
|
||||
check(f'coverage {scope}', c.build_frame(b'\x01\x02').hex(' ').upper(), want)
|
||||
|
||||
# 4) CRC16 帧往返 + 坏帧拒绝(组帧/解析同头才能回路, 收发方向包头本就允许不同)
|
||||
c16 = d.ProtoConfig(tx_hdr=0x55, rx_hdr=0x55, crc_algo='CRC16_MODBUS', crc_scope='with_header')
|
||||
f = c16.build_frame(b'ABC')
|
||||
check('crc16 frame len', len(f), 3 + 3 + 2)
|
||||
check('crc16 verify', c16.verify_frame(f), True)
|
||||
bad = bytearray(f)
|
||||
bad[4] ^= 0xFF
|
||||
check('crc16 bad rejected', c16.verify_frame(bytes(bad)), False)
|
||||
frames = d.FrameParser(c16).feed(f)
|
||||
check('crc16 parse payload', (frames[0].payload, frames[0].code) if frames else None, (b'ABC', 0x01))
|
||||
|
||||
# 5) LEN=0 空载荷帧(心跳类): D5 01 00 + sum(01)=01
|
||||
check('empty payload frame', cfg.build_frame(b'').hex(' ').upper(), 'D5 01 00 01')
|
||||
loop = d.ProtoConfig(tx_hdr=0xB5) # tx/rx 同头以便回路
|
||||
check('empty payload parse', d.FrameParser(loop).feed(loop.build_frame(b''))[0].payload, b'')
|
||||
|
||||
# 6) 超长 payload 拒绝
|
||||
try:
|
||||
cfg.build_frame(b'\x00' * 256)
|
||||
fails.append('oversize-not-rejected')
|
||||
except ValueError:
|
||||
print('PASS 超长 payload 拒绝')
|
||||
|
||||
# 7) 增量解析: ok 应答逐字节喂入(拆到最碎) + 前导噪声
|
||||
good = bytes.fromhex('B5 F0 02 6F 6B CC')
|
||||
p = d.FrameParser(cfg)
|
||||
out = []
|
||||
for i in range(len(good)):
|
||||
out += p.feed(good[i:i + 1])
|
||||
check('byte-wise feed frames', len(out), 1)
|
||||
check('code', out[0].code, 0xF0)
|
||||
check('payload', out[0].payload, b'ok')
|
||||
check('raw', d.hexs(out[0].raw), 'B5 F0 02 6F 6B CC')
|
||||
out = d.FrameParser(cfg).feed(b'\x01\x02' + good)
|
||||
check('noise+frame count', len(out), 1)
|
||||
|
||||
# 8) CRC 坏帧被丢弃后继续同步到下一帧
|
||||
bad = bytearray(good)
|
||||
bad[-1] ^= 0xFF
|
||||
frames = d.FrameParser(cfg).feed(bytes(bad) + good)
|
||||
check('bad-crc then good', len(frames), 1)
|
||||
check('recovered code', frames[0].code, 0xF0)
|
||||
|
||||
# 9) 伪帧头与帧头字节入载荷
|
||||
# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧
|
||||
frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good)
|
||||
check('pseudo-header resync', len(frames), 1)
|
||||
check('resync payload', frames[0].payload, b'ok')
|
||||
# 9b payload 内含 0xB5(帧头字节)的合法帧: B5 F0 02 B5 F0 + sum(F0 02 B5 F0)=0x297→97
|
||||
frames = d.FrameParser(cfg).feed(bytes.fromhex('B5 F0 02 B5 F0 97') + good)
|
||||
check('rx-hdr-in-payload', len(frames), 2)
|
||||
check('rx-hdr-in-payload payload', frames[0].payload, b'\xB5\xF0')
|
||||
|
||||
# 10) 自定义包头/板ID 往返(非 DLPilot 值)
|
||||
cx = d.ProtoConfig(tx_hdr=0xC3, rx_hdr=0xC3, board=0x7A)
|
||||
frames = d.FrameParser(cx).feed(cx.build_frame(b'PING'))
|
||||
check('custom hdr frame count', len(frames), 1)
|
||||
check('custom hdr code', frames[0].code, 0x7A)
|
||||
check('custom hdr payload', frames[0].payload, b'PING')
|
||||
|
||||
# 11) 数值提取(曲线通道)
|
||||
check_close('bin u16be', d.extract_value(bytes.fromhex('00 34'), 'bin', fmt='u16be'), 52.0)
|
||||
check_close('bin s16le 负值', d.extract_value(bytes.fromhex('FE FF'), 'bin', fmt='s16le'), -2.0)
|
||||
check_close('bin f32be', d.extract_value(bytes.fromhex('3F 80 00 00'), 'bin', fmt='f32be'), 1.0)
|
||||
check_close('bin offset', d.extract_value(bytes.fromhex('AA 00 34'), 'bin', offset=1, fmt='u16be'), 52.0)
|
||||
check_close('num TL:246', d.extract_value(b'okTL:246', 'num'), 246.0)
|
||||
check_close('num scale 0.1', d.extract_value(b'okTL:246', 'num', scale=0.1), 24.6)
|
||||
check_close('num 第3个', d.extract_value(b'okR12G34B56', 'num', index=3), 56.0)
|
||||
check_close('re 捕获组', d.extract_value(b'okTL:246', 're'), 246.0)
|
||||
check('num 缺数字', d.extract_value(b'ok', 'num'), None)
|
||||
check('bin 越界', d.extract_value(b'\x01', 'bin', offset=2), None)
|
||||
check('re 非法表达式', d.extract_value(b'x', 're', pattern='('), None)
|
||||
check('未知模式', d.extract_value(b'x', 'bogus'), None)
|
||||
|
||||
# 12) 随机往返模糊: 三种算法 × 三种覆盖范围, 整喂/乱拆两种方式
|
||||
rng = random.Random(20260927)
|
||||
for algo in ('SUM8', 'CRC8', 'CRC16_MODBUS'):
|
||||
for scope in d.CRC_SCOPES:
|
||||
cf = d.ProtoConfig(tx_hdr=0xB5, crc_algo=algo, crc_scope=scope) # tx=rx 回路
|
||||
for _ in range(20):
|
||||
pl = bytes(rng.randrange(256) for _ in range(rng.randrange(41)))
|
||||
frame = cf.build_frame(pl)
|
||||
fp = d.FrameParser(cf)
|
||||
if rng.random() < 0.5:
|
||||
got = fp.feed(frame)
|
||||
else:
|
||||
cut = rng.randrange(len(frame) + 1)
|
||||
got = fp.feed(frame[:cut]) + fp.feed(frame[cut:])
|
||||
ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame
|
||||
if not ok:
|
||||
check(f'fuzz {algo}/{scope}', ok, True)
|
||||
break
|
||||
print(f'模糊往返 3 算法 × 3 覆盖 × 20 帧: {"PASS" if not fails else "见上"}')
|
||||
|
||||
# 13) 日志行安全: clean_text 把控制字符换成 '·'
|
||||
check('clean_text NUL', d.clean_text('a\x00b\x7f'), 'a·b·')
|
||||
|
||||
# 14) 帧构成定制(发送/接收同一布局, 收发方向仅包头值不同; 回路用例 rx_hdr=D5
|
||||
# 以便解析器接受 TX 包头, 字面量同步头布局则与包头值无关)
|
||||
# 14a 双字节字面量同步头 + 无板ID: AA 55 LEN payload CRC
|
||||
cl = d.FrameLayout.parse('AA 55 LEN PAYLOAD CRC')
|
||||
c = d.ProtoConfig(layout=cl)
|
||||
check('layout 字面量组帧', c.build_frame(b'\x01\x02').hex(' ').upper(), 'AA 55 02 01 02 5A')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'\x01\x02'))
|
||||
check('layout 字面量解析', (len(frames), frames[0].code, frames[0].payload) if frames else None,
|
||||
(1, None, b'\x01\x02'))
|
||||
|
||||
# 14b 自定义字段顺序: HDR LEN ID PAYLOAD CRC(板ID 在 LEN 后)
|
||||
c = d.ProtoConfig(rx_hdr=0xD5, board=0x2A,
|
||||
layout=d.FrameLayout.parse('HDR LEN ID PAYLOAD CRC'))
|
||||
check('layout 换序组帧', c.build_frame(b'OK').hex(' ').upper(), 'D5 02 2A 4F 4B C6')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'OK'))
|
||||
check('layout 换序解析', (frames[0].code, frames[0].payload) if frames else None, (0x2A, b'OK'))
|
||||
|
||||
# 14c 无板ID布局: HDR LEN PAYLOAD CRC, Frame.code 应为 None
|
||||
c = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse('HDR LEN PAYLOAD CRC'))
|
||||
check('layout 无ID组帧', c.build_frame(b'M1').hex(' ').upper(), 'D5 02 4D 31 80')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'M1'))
|
||||
check('layout 无ID解析', (frames[0].code, frames[0].payload) if frames else None,
|
||||
(None, b'M1'))
|
||||
|
||||
# 14d LEN 口径: body(payload+CRC) / total(整帧) —— M303 载荷 4 字节
|
||||
c = d.ProtoConfig(rx_hdr=0xD5, len_sem='body')
|
||||
check('LEN 口径 body', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 05 4D 33 30 33 E9')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'M303'))
|
||||
check('LEN 口径 body 解析', frames[0].payload if frames else None, b'M303')
|
||||
c = d.ProtoConfig(rx_hdr=0xD5, len_sem='total')
|
||||
check('LEN 口径 total', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 08 4D 33 30 33 EC')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'M303'))
|
||||
check('LEN 口径 total 解析', frames[0].payload if frames else None, b'M303')
|
||||
|
||||
# 14e 仅 payload 覆盖 + 板ID 在 payload 后: 'HDR LEN PAYLOAD ID CRC'
|
||||
c = d.ProtoConfig(rx_hdr=0xD5, board=0x7B, crc_scope='payload_only',
|
||||
layout=d.FrameLayout.parse('HDR LEN PAYLOAD ID CRC'))
|
||||
check('layout ID在payload后', c.build_frame(b'\x01').hex(' ').upper(), 'D5 01 01 7B 01')
|
||||
frames = d.FrameParser(c).feed(c.build_frame(b'\x01'))
|
||||
check('layout ID在payload后解析', (frames[0].code, frames[0].payload) if frames else None,
|
||||
(0x7B, b'\x01'))
|
||||
|
||||
# 14f 非法帧构成一律拒绝
|
||||
for bad in ('HDR PAYLOAD CRC', # 缺 LEN
|
||||
'HDR LEN CRC PAYLOAD', # CRC 不在末尾
|
||||
'LEN PAYLOAD CRC', # 无同步头
|
||||
'HDR ID ID LEN PAYLOAD CRC', # ID 重复
|
||||
'HDR ID LEN FOO CRC', # 未知令牌
|
||||
'', # 空
|
||||
'HDR ID LEN PAYLOAD'): # 缺 CRC
|
||||
try:
|
||||
d.FrameLayout.parse(bad)
|
||||
fails.append(f'layout-not-rejected:{bad}')
|
||||
print('FAIL 帧构成未拒绝 ->', bad)
|
||||
except ValueError:
|
||||
pass
|
||||
print('非法帧构成拒绝: PASS')
|
||||
check('大小写/逗号容错', d.FrameLayout.parse('hdr, id, len, payload, crc').tokens,
|
||||
d.FrameLayout.parse(d.DEFAULT_LAYOUT).tokens)
|
||||
|
||||
# 15) 自定义布局模糊往返(乱拆喂入; 有 HDR 令牌的布局回路需 rx_hdr=tx_hdr)
|
||||
rng2 = random.Random(9527)
|
||||
for spec in ('AA 55 LEN PAYLOAD CRC', 'HDR LEN ID PAYLOAD CRC',
|
||||
'HDR LEN PAYLOAD ID CRC'):
|
||||
cf = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse(spec))
|
||||
for _ in range(10):
|
||||
pl = bytes(rng2.randrange(256) for _ in range(rng2.randrange(41)))
|
||||
frame = cf.build_frame(pl)
|
||||
fp = d.FrameParser(cf)
|
||||
cut = rng2.randrange(len(frame) + 1)
|
||||
got = fp.feed(frame[:cut]) + fp.feed(frame[cut:])
|
||||
ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame
|
||||
if not ok:
|
||||
check(f'layout fuzz {spec}', ok, True)
|
||||
break
|
||||
print(f'布局模糊往返 3 布局 × 10 帧: {"PASS" if not fails else "见上"}')
|
||||
|
||||
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user