#!/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)