# -*- coding: utf-8 -*- """ test_protocol_app_sim.py — 模拟手机 App 对吉他固件做全协议串口测试 协议来源: Doc/指令测试.docx (= 指令测试0907.docx) 通道: UART4 (BLE 桥), 115200 8N1; 帧格式 F0 60 [data...] F7 设备主动上报为 F0 51 ... F7 (测试时跳过, 不计为响应) 用法: python test_protocol_app_sim.py --port COM5 # 跑全部安全用例 python test_protocol_app_sim.py --port COM5 --allow-poweroff# 含 05 00 关机用例 python test_protocol_app_sim.py --selftest # 无硬件自检(帧编解码) 依赖: pyserial (pip install pyserial) 退出码: 0 = 全部通过, 1 = 有 FAIL """ import argparse import sys import time try: import serial except ImportError: serial = None # --selftest 不需要 pyserial HEAD, TAIL, APP_ID, DEV_ID = 0xF0, 0xF7, 0x60, 0x51 # ---------------------------------------------------------------- frame codec def build(*payload): """App -> 设备: F0 60 F7""" return bytes([HEAD, APP_ID, *payload, TAIL]) class FrameParser: """字节流 -> 完整帧; 只收 F0...F7, 其余丢弃""" def __init__(self): self.buf = bytearray() def feed(self, data: bytes): """返回本轮解析出的完整帧列表""" out = [] for b in data: if not self.buf: if b == HEAD: self.buf.append(b) continue self.buf.append(b) if b == TAIL or len(self.buf) >= 64: if b == TAIL: out.append(bytes(self.buf)) self.buf.clear() return out def hexs(b): return " ".join(f"{x:02X}" for x in b) if b else "(none)" # ---------------------------------------------------------------- serial wrap class AppSim: def __init__(self, port, baud=115200, timeout=0.15): self.ser = serial.Serial(port, baud, bytesize=8, parity="N", stopbits=1, timeout=timeout) self.parser = FrameParser() self.pending = [] def close(self): self.ser.close() def _pump(self): data = self.ser.read(256) if data: self.pending.extend(self.parser.feed(data)) def send(self, frame: bytes): self.ser.write(frame) self.ser.flush() def read_frame(self, timeout=1.0, want_dev=False): """读一帧; 默认跳过设备主动上报(0x51), want_dev=True 时只要 0x51""" deadline = time.monotonic() + timeout while True: self._pump() for i, f in enumerate(self.pending): is_dev = len(f) > 1 and f[1] == DEV_ID if is_dev == want_dev: return self.pending.pop(i) self.pending.clear() # 丢掉方向不符的帧 if time.monotonic() >= deadline: return None time.sleep(0.01) def drain(self, quiet=0.2): while self.read_frame(timeout=quiet) is not None: pass def query(self, frame: bytes, timeout=1.0): self.drain() self.send(frame) return self.read_frame(timeout=timeout) # ---------------------------------------------------------------- test engine class Results: def __init__(self): self.rows = [] def add(self, name, status, detail=""): self.rows.append((name, status, detail)) print(f"[{status:>4}] {name}" + (f" | {detail}" if detail else "")) def summary(self): n = {s: sum(1 for r in self.rows if r[1] == s) for s in ("PASS", "FAIL", "SKIP", "SENT")} print("\n===== 汇总 =====") for k in ("PASS", "FAIL", "SKIP", "SENT"): print(f"{k:>5}: {n[k]}") for name, status, detail in self.rows: if status == "FAIL": print(f" FAIL: {name} | {detail}") return 1 if n["FAIL"] else 0 def expect(resp, prefix, length=None): """校验响应帧头/命令字/长度""" if resp is None: return False, "timeout, no reply" if len(resp) < 5 or resp[0] != HEAD or resp[-1] != TAIL: return False, f"bad frame: {hexs(resp)}" if tuple(resp[1:1 + len(prefix)]) != tuple(prefix): return False, f"prefix mismatch: {hexs(resp)}" if length is not None and len(resp) != length: return False, f"len {len(resp)} != {length}: {hexs(resp)}" return True, hexs(resp) def run_tests(sim, allow_poweroff=False): R = Results() # ---------------- 1. 设备信息 0x01 ---------------- r = sim.query(build(0x01, 0x01)) ok, d = expect(r, (0x60, 0x01, 0x01), 6) R.add("01 01 连接设备", "PASS" if ok else "FAIL", d) r = sim.query(build(0x01, 0x02)) ok, d = expect(r, (0x60, 0x01, 0x02)) if ok: name = bytes(r[4:-1]) ok = all(0x20 <= c < 0x7F for c in name) d += f" name={name!r}" R.add("01 02 设备名", "PASS" if ok else "FAIL", d) for sub, name in ((0x03, "固件主版本"), (0x04, "音源版本"), (0x05, "UI版本"), (0x0C, "用户固件版本")): r = sim.query(build(0x01, sub)) ok, d = expect(r, (0x60, 0x01, sub), 9) R.add(f"01 {sub:02X} {name}", "PASS" if ok else "FAIL", d) r = sim.query(build(0x01, 0x06)) ok, d = expect(r, (0x60, 0x01, 0x06), 13) R.add("01 06 其他信息", "PASS" if ok else "FAIL", d) r = sim.query(build(0x01, 0x07)) ok, d = expect(r, (0x60, 0x01, 0x07), 29) # 12B UID → 24 hex ASCII if ok: hx = bytes(r[4:-1]) ok = len(hx) == 24 and all(c in b"0123456789ABCDEF" for c in hx) d += f" uid_hex={hx.decode('ascii', 'replace')}" R.add("01 07 设备编码(UID)", "PASS" if ok else "FAIL", d) # 01 0F:BLE-MIDI 安全子命令(原 01 FF 的 0xFF 非法出现在 SysEx 数据中) r = sim.query(build(0x01, 0x0F)) ok, d = expect(r, (0x60, 0x01, 0x0F)) if ok: code = bytes(r[4:-1]) ok = len(code) > 0 and all(0x20 <= c < 0x7F for c in code) d += f" code={code!r}" R.add("01 0F 固件编码", "PASS" if ok else "FAIL", d) # 自动关机: 读 -> 设15 -> 读验证 -> 恢复原值 r0 = sim.query(build(0x01, 0x0A)) ok, d = expect(r0, (0x60, 0x01, 0x0A), 6) R.add("01 0A 自动关机(读)", "PASS" if ok else "FAIL", d) if ok: orig = r0[4] r = sim.query(build(0x01, 0x11, 15)) ok, d = expect(r, (0x60, 0x01, 0x11, 15), 6) R.add("01 11 自动关机(设15)", "PASS" if ok else "FAIL", d) r = sim.query(build(0x01, 0x0A)) ok, d = expect(r, (0x60, 0x01, 0x0A, 15), 6) R.add("01 0A 回读=15", "PASS" if ok else "FAIL", d) sim.query(build(0x01, 0x11, orig)) # restore r = sim.query(build(0x01, 0x00)) ok, d = expect(r, (0x60, 0x01, 0x00), 6) R.add("01 00 断开设备", "PASS" if ok else "FAIL", d) sim.query(build(0x01, 0x01)) # 重新连接, 便于后续用例 # ---------------- 2. 和弦映射 0x02 ---------------- r = sim.query(build(0x02, 0x01)) ok, d = expect(r, (0x60, 0x02, 0x01), 47) R.add("02 01 读和弦映射表", "PASS" if ok else "FAIL", d) cur_map = bytes(r[4:-1]) if ok else None # 21B pitch + 21B chord if cur_map: key = 1 orig_pitch, orig_chord = cur_map[key - 1], cur_map[21 + key - 1] sim.send(build(0x02, 0x02, key - 1, 0x02)) # pitch: 升位 time.sleep(0.1) r = sim.query(build(0x02, 0x01)) ok = r is not None and len(r) == 47 and r[3] == 0x01 and r[4 + key - 1] == 0x02 R.add("02 02 Pitch偏移(写+读回)", "PASS" if ok else "FAIL", hexs(r)) sim.send(build(0x02, 0x02, key - 1, orig_pitch)) # restore sim.send(build(0x02, 0x03, key - 1, 0x08)) # chord: 小三 time.sleep(0.1) r = sim.query(build(0x02, 0x01)) ok = r is not None and len(r) == 47 and r[4 + 21 + key - 1] == 0x08 R.add("02 03 Chord偏移(写+读回)", "PASS" if ok else "FAIL", hexs(r)) sim.send(build(0x02, 0x03, key - 1, orig_chord)) # restore time.sleep(0.1) # 02 04 整表写入(回写当前表) -> 设备应回 02 01 格式整表 r = sim.query(build(0x02, 0x04, *cur_map), timeout=2.0) ok, d = expect(r, (0x60, 0x02, 0x01), 47) R.add("02 04 整表写入(回读帧)", "PASS" if ok else "FAIL", d) r2 = sim.query(build(0x02, 0x01)) ok = r2 is not None and bytes(r2[4:-1]) == cur_map R.add("02 04 写入后整表一致", "PASS" if ok else "FAIL", hexs(r2)) else: for n in ("02 02 Pitch偏移", "02 03 Chord偏移", "02 04 整表写入"): R.add(n, "SKIP", "读映射表失败, 级联跳过") # ---------------- 3. 吉他参数 0x03 ---------------- r = sim.query(build(0x03, 0x04, 0x00)) ok, d = expect(r, (0x60, 0x03, 0x04, 0x00), 9) R.add("03 04 读当前节奏风格", "PASS" if ok else "FAIL", d) saved_style = bytes(r[5:8]) if ok else None # 开机特例: F0 60 03 04 01 00 00 00 F7 -> F0 60 03 04 01 F7 + F0 51 03 F7 r = sim.query(build(0x03, 0x04, 0x01, 0x00, 0x00, 0x00)) ok, d = expect(r, (0x60, 0x03, 0x04, 0x01), 7) R.add("03 04 开机特例(读风格#0)", "PASS" if ok else "FAIL", d) r51 = sim.read_frame(timeout=1.0, want_dev=True) ok51 = (r51 is not None and len(r51) == 6 and tuple(r51[:3]) == (HEAD, DEV_ID, 0x03)) R.add("03 04 开机特例(F0 51 BPM上报)", "PASS" if ok51 else "FAIL", hexs(r51)) r = sim.query(build(0x03, 0x04, 0x01, 0x01, 0x00, 0x00)) ok, d = expect(r, (0x60, 0x03, 0x04, 0x01), 7) R.add("03 04 设用户风格#0", "PASS" if ok else "FAIL", d) # 用户风格分页: 回复 F0 60 03 04 02 [ ...] F7 r = sim.query(build(0x03, 0x04, 0x02, 0x00, 0x00, 0x00, 0x07)) ok, d = expect(r, (0x60, 0x03, 0x04, 0x02)) if ok: cnt = r[5] * 128 + r[6] ok = len(r) == 7 + cnt * 6 + 1 d += f" cnt={cnt}" R.add("03 04 用户风格分页", "PASS" if ok else "FAIL", d) # 用户风格总数: 回复 F0 60 03 04 00 F7 r = sim.query(build(0x03, 0x04, 0x03)) ok, d = expect(r, (0x60, 0x03, 0x04, 0x00), 9) if ok: d += f" total={(r[5] << 16) | (r[6] << 8) | r[7]}" R.add("03 04 用户风格总数", "PASS" if ok else "FAIL", d) if saved_style: sim.query(build(0x03, 0x04, 0x01, *saved_style)) # restore style # 弦音色: 读 -> 写 -> 读 -> 恢复 r0 = sim.query(build(0x03, 0x05, 0x00)) ok, d = expect(r0, (0x60, 0x03, 0x05, 0x00), 8) R.add("03 05 读弦音色", "PASS" if ok else "FAIL", d) if ok: orig = r0[6] new = (orig + 1) % 5 sim.send(build(0x03, 0x05, 0x01, 0x00, new)) time.sleep(0.1) r = sim.query(build(0x03, 0x05, 0x00)) ok = r is not None and len(r) == 8 and r[6] == new R.add("03 05 写弦音色(写+读回)", "PASS" if ok else "FAIL", hexs(r)) sim.send(build(0x03, 0x05, 0x01, 0x00, orig)) # restore # BPM: 读 -> 写120 -> 读 -> 恢复 r0 = sim.query(build(0x03, 0x06, 0x00)) ok, d = expect(r0, (0x60, 0x03, 0x06, 0x00), 8) R.add("03 06 读BPM", "PASS" if ok else "FAIL", d) if ok: orig = r0[5] * 128 + r0[6] sim.send(build(0x03, 0x06, 0x01, 120 // 128, 120 % 128)) time.sleep(0.1) r = sim.query(build(0x03, 0x06, 0x00)) ok = r is not None and len(r) == 8 and (r[5] * 128 + r[6]) == 120 R.add("03 06 写BPM=120(写+读回)", "PASS" if ok else "FAIL", hexs(r)) sim.send(build(0x03, 0x06, 0x01, orig // 128, orig % 128)) # restore # 移调: 读 -> 写 -> 读 -> 恢复 (doc: F0 60 03 07 00 F7, 7 bytes) r0 = sim.query(build(0x03, 0x07, 0x00)) ok, d = expect(r0, (0x60, 0x03, 0x07, 0x00), 7) R.add("03 07 读移调", "PASS" if ok else "FAIL", d) if ok: orig = r0[5] new = (orig + 1) % 12 sim.send(build(0x03, 0x07, 0x01, new)) time.sleep(0.1) r = sim.query(build(0x03, 0x07, 0x00)) ok = r is not None and len(r) == 7 and r[5] == new R.add("03 07 写移调(写+读回)", "PASS" if ok else "FAIL", hexs(r)) sim.send(build(0x03, 0x07, 0x01, orig)) # restore # ---------------- 4. LED / 播放控制 0x04 (无应答, 仅下发) ---------------- for i in range(7): sim.drain() sim.send(build(0x04, i, 0x02, 0x02)) r = sim.read_frame(timeout=0.3) R.add(f"04 0{i} 点亮LED{i + 1}", "SENT" if r is None else "PASS", "" if r is None else f"unexpected reply {hexs(r)}") time.sleep(0.05) sim.drain() sim.send(build(0x04, 0x07, 0x00, 0x00)) R.add("04 07 End/结束播放", "SENT", "无应答属正常") # ---------------- 6. 段落跳转 0x06 (无应答, 仅下发) ---------------- for sub, name in ((0x01, "前奏"), (0x02, "间奏"), (0x03, "尾奏"), (0x05, "A段"), (0x06, "B段"), (0x07, "C段"), (0x08, "D段")): sim.drain() sim.send(build(0x06, sub, 0x00)) R.add(f"06 {sub:02X} {name}", "SENT", "无应答属正常") time.sleep(0.05) # ---------------- 5. 复位/关机 0x05 ---------------- if allow_poweroff: r = sim.query(build(0x05, 0x00), timeout=2.0) ok, d = expect(r, (0x60, 0x05, 0x00), 6) R.add("05 00 复位/关机", "PASS" if ok else "FAIL", d + " (设备将软关机)") else: R.add("05 00 复位/关机", "SKIP", "需 --allow-poweroff") return R # ---------------------------------------------------------------- selftest def selftest(): R = Results() f = build(0x03, 0x06, 0x01, 0x00, 0x40) R.add("build frame", "PASS" if f == bytes.fromhex("F060030601004 0F7".replace(" ", "")) else "FAIL", hexs(f)) p = FrameParser() out = p.feed(bytes.fromhex("AA F0 60 01 01")) + p.feed(bytes.fromhex("01 F7")) R.add("parser chunked", "PASS" if out == [bytes.fromhex("F0600101 01F7".replace(" ", ""))] else "FAIL", str([hexs(x) for x in out])) p = FrameParser() out = p.feed(bytes.fromhex("F0 51 05 0A F7 F0 60 01 01 01 F7".replace(" ", ""))) R.add("parser two frames", "PASS" if len(out) == 2 else "FAIL", str(len(out))) return R.summary() def main(): ap = argparse.ArgumentParser(description="模拟手机App的吉他协议全量测试") ap.add_argument("--port", help="串口, 如 COM5 (UART4 115200 8N1)") ap.add_argument("--baud", type=int, default=115200) ap.add_argument("--allow-poweroff", action="store_true", help="允许执行 05 00 关机用例") ap.add_argument("--selftest", action="store_true", help="无硬件自检") args = ap.parse_args() if args.selftest: sys.exit(selftest()) if not args.port: ap.error("需要 --port (或用 --selftest)") if serial is None: sys.exit("缺少 pyserial: pip install pyserial") sim = AppSim(args.port, args.baud) print(f"=== 协议测试开始 {args.port}@{args.baud} ===") try: code = run_tests(sim, allow_poweroff=args.allow_poweroff).summary() finally: sim.close() sys.exit(code) if __name__ == "__main__": main()