K1Guitar/tools/test_protocol_ble.py

489 lines
17 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
test_protocol_ble.py 通过笔记本蓝牙对吉他做全协议测试并出报告
协议来源: Doc/指令测试.docx
链路: 笔记本 BLE <-> "Smart Guitar MIDI" <-> 吉他 UART4
App->设备: F0 60 ... F7 ; 设备主动上报: F0 51 ... F7
GATT:
BLE-MIDI 03B80E5A-... / 7772E5DB-... (framed SysEx)
自定义串口 e49a25f8-... / e49a25e0() + e49a28e1(通知) (raw SysEx)
用法:
python test_protocol_ble.py
python test_protocol_ble.py --transport midi
python test_protocol_ble.py --transport uart
python test_protocol_ble.py --unpair
python test_protocol_ble.py --smoke
python test_protocol_ble.py --selftest
依赖: bleak (pip install bleak)
报告: Doc/reports/ble_sysex_<时间戳>.md
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import os
import queue
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_protocol_app_sim import ( # noqa: E402
HEAD, DEV_ID, build, FrameParser, Results, run_tests, hexs)
MIDI_SERVICE = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700"
MIDI_CHAR = "7772E5DB-3868-4112-A1A9-F2669D106BF3"
UART_SERVICE = "e49a25f8-f69a-11e8-8eb2-f2801f1b9fd1"
UART_WRITE = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UART_NOTIFY = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
DEFAULT_NAME = "Smart Guitar MIDI"
DEFAULT_MTU_PAYLOAD = 20
def ble_midi_encode_sysex(frame: bytes, max_payload: int):
ts_hi, ts_lo = 0x80, 0x80
pkts = []
cap = max_payload - 2
first = frame[:cap]
pkts.append(bytes([ts_hi, ts_lo]) + first)
rest = frame[len(first):]
while len(rest) > max_payload - 2:
pkts.append(bytes([ts_hi]) + rest[: max_payload - 1])
rest = rest[max_payload - 1 :]
if rest:
pkts.append(bytes([ts_hi, ts_lo]) + rest)
return pkts
def ble_midi_decode_packet(payload: bytes) -> bytes:
"""Strip BLE-MIDI header/timestamp bytes; keep App SysEx (F0…F7).
- Framed: [header ts][optional ts][F0 ts F7]
- Raw (some ATS2853 notifies): [F0 F7] must not treat F0 as header
(F0/F7 also have bit7=1).
"""
if not payload:
return b""
i = 0
if payload[0] not in (0xF0, 0xF7) and (payload[0] & 0x80):
i = 1 # BLE-MIDI header
out = bytearray()
while i < len(payload):
b = payload[i]
i += 1
if b in (0xF0, 0xF7):
out.append(b)
elif b & 0x80:
continue # timestamp inside/around SysEx
else:
out.append(b)
return bytes(out)
class BleMidiSim:
"""与 AppSim 同接口: send / read_frame / drain / query / close"""
def __init__(
self,
name=DEFAULT_NAME,
address=None,
scan_timeout=20.0,
transport="midi",
unpair=False,
):
from bleak import BleakClient, BleakScanner
self._BleakClient = BleakClient
self._BleakScanner = BleakScanner
self.parser = FrameParser()
self.pending = []
self._rx = queue.Queue()
self._client = None
self._write_char = None
self._notify_chars = []
self._transport = transport
self._unpair = unpair
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
self._thread.start()
self.info = self._run(self._connect(name, address, scan_timeout))
def _run(self, coro, timeout=90.0):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
async def _find(self, name, address, scan_timeout):
if address:
return await self._BleakScanner.find_device_by_address(
address, timeout=scan_timeout
)
print(f"扫描 BLE 设备 ({scan_timeout:.0f}s) ...")
# name filter is more reliable than service UUID filter on Windows
t0 = time.monotonic()
while time.monotonic() - t0 < scan_timeout:
rem = max(1.0, scan_timeout - (time.monotonic() - t0))
d = await self._BleakScanner.find_device_by_filter(
lambda d, a: d.name and name.lower() in d.name.lower(),
timeout=min(8.0, rem),
)
if d:
print(f" 发现: {d.name!r} {d.address}")
return d
print(" ...")
return None
async def _connect(self, name, address, scan_timeout):
dev = await self._find(name, address, scan_timeout)
if dev is None:
raise RuntimeError(f"未找到设备 {name!r}")
if self._unpair:
try:
tmp = self._BleakClient(dev.address, timeout=20)
await tmp.connect()
try:
await tmp.unpair()
print("已 unpair Windows 残留配对")
except Exception as e:
print(f"unpair: {e}")
try:
await tmp.disconnect()
except Exception:
pass
await asyncio.sleep(1.5)
dev = await self._find(name, address, scan_timeout) or dev
except Exception as e:
print(f"unpair session: {e}")
client = self._BleakClient(dev, timeout=30)
await client.connect()
if not client.is_connected:
raise RuntimeError("BLE connect 后立即断开")
write_char = MIDI_CHAR if self._transport == "midi" else UART_WRITE
notify_list = (
[MIDI_CHAR]
if self._transport == "midi"
else [UART_NOTIFY, MIDI_CHAR]
)
max_payload = DEFAULT_MTU_PAYLOAD
for svc in client.services:
for c in svc.characteristics:
if c.uuid.lower() == write_char.lower():
try:
max_payload = c.max_write_without_response_size or max_payload
except Exception:
pass
def _on_notify(_sender, data):
self._rx.put(bytes(data))
for u in notify_list:
try:
await client.start_notify(u, _on_notify)
self._notify_chars.append(u)
except Exception as e:
print(f"notify fail {u[:8]}: {e}")
if not self._notify_chars:
raise RuntimeError("无法开启任何 notify常见原因: Windows 残留配对)")
self._client = client
self._write_char = write_char
self._max_payload = max(DEFAULT_MTU_PAYLOAD, max_payload or DEFAULT_MTU_PAYLOAD)
return {
"name": getattr(dev, "name", None),
"address": getattr(dev, "address", address),
"max_payload": self._max_payload,
"transport": self._transport,
"notify": list(self._notify_chars),
}
async def _disconnect(self):
if self._client is None:
return
for u in self._notify_chars:
try:
await self._client.stop_notify(u)
except Exception:
pass
try:
await self._client.disconnect()
except Exception:
pass
def close(self):
try:
self._run(self._disconnect(), timeout=10)
except Exception:
pass
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=5)
def _pump(self):
while True:
try:
data = self._rx.get_nowait()
except queue.Empty:
break
if self._transport == "midi":
midi = ble_midi_decode_packet(data)
else:
# uart notify may be raw SysEx, or occasionally BLE-MIDI wrapped
midi = (
ble_midi_decode_packet(data)
if data and (data[0] & 0x80)
else data
)
self.pending.extend(self.parser.feed(midi))
def send(self, frame: bytes):
if self._transport == "midi":
pkts = ble_midi_encode_sysex(frame, self._max_payload)
else:
pkts = [frame]
for pkt in pkts:
self._run(
self._client.write_gatt_char(self._write_char, pkt, response=False),
timeout=10,
)
def read_frame(self, timeout=1.0, want_dev=False):
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=3.0):
self.drain(quiet=0.15)
self.send(frame)
r = self.read_frame(timeout=timeout)
if r is None:
# BLE 偶发丢 Notify短间隔重发一次
time.sleep(0.25)
self.send(frame)
r = self.read_frame(timeout=timeout)
return r
async def _reconnect_clean(self, name, address, scan_timeout):
"""Drop session, unpair, rescan, reconnect — recovers Windows stale pairing."""
await self._disconnect()
self._notify_chars = []
self._client = None
await asyncio.sleep(0.8)
self._unpair = True
return await self._connect(name, address, scan_timeout)
def probe_or_rebind(self, name=DEFAULT_NAME, address=None, scan_timeout=20.0):
"""Send 01 01; on timeout, unpair+reconnect once and retry."""
r = self.query(build(0x01, 0x01), timeout=3.0)
if r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60:
return True
print("首包无应答 → 自动 unpair 并重连 …")
try:
self.info = self._run(
self._reconnect_clean(name, address, scan_timeout), timeout=120.0
)
except Exception as e:
print(f"重连失败: {e}")
return False
r = self.query(build(0x01, 0x01), timeout=3.0)
ok = r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60
if not ok:
print(
"仍无应答。请 JLink/断电复位吉他E49A 裸写或异常断连后模组常停广播),"
"并确认琴已开机且蓝牙开关为开。"
)
return ok
def write_report(R: Results, info: dict, path: str, notes: list[str] | None = None):
n = {
s: sum(1 for r in R.rows if r[1] == s)
for s in ("PASS", "FAIL", "SKIP", "SENT")
}
lines = [
"# BLE SysEx 协议测试报告",
"",
f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
f"- 设备: {info.get('name')} ({info.get('address')})",
f"- 传输: {info.get('transport')} / bleak, payload={info.get('max_payload')}",
f"- notify: {info.get('notify')}",
"- 协议来源: Doc/指令测试.docx",
"- 测试脚本: tools/test_protocol_ble.py",
"",
"## 汇总",
"",
"| PASS | FAIL | SKIP | SENT |",
"|------|------|------|------|",
f"| {n['PASS']} | {n['FAIL']} | {n['SKIP']} | {n['SENT']} |",
"",
"## 明细",
"",
"| # | 用例 | 结果 | 详情 |",
"|---|------|------|------|",
]
for i, (name, status, detail) in enumerate(R.rows, 1):
lines.append(f"| {i} | {name} | {status} | {detail} |")
lines += ["", "## 备注", ""]
for note in notes or []:
lines.append(f"- {note}")
lines += [
"- `01 03` 为 MCU 实际版本;`01 0F` 固件编码暂 `BRS08L`(原 `01 FF`BLE-MIDI 下 0xFF 非法)。",
"- `01 07` UID 为 24 字符 hex ASCII96-bit保证 SysEx 7-bit 安全。",
"- `04 xx`/`06 xx` 无应答SENT 表示已发送。",
"- `FD 01` 升级指令不在本协议范围。",
]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
return path
def smoke(sim: BleMidiSim) -> Results:
R = Results()
r = sim.query(build(0x01, 0x01), timeout=3.0)
ok = r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == 0x60
R.add("smoke 01 01 连接设备", "PASS" if ok else "FAIL", hexs(r))
if ok:
r2 = sim.query(build(0x01, 0x02), timeout=3.0)
ok2 = r2 is not None and r2[2:4] == bytes([0x01, 0x02])
R.add("smoke 01 02 设备名", "PASS" if ok2 else "FAIL", hexs(r2))
r3 = sim.query(build(0x02, 0x01), timeout=3.0)
ok3 = r3 is not None and len(r3) == 47
R.add("smoke 02 01 和弦表(长帧)", "PASS" if ok3 else "FAIL", hexs(r3))
return R
def selftest():
R = Results()
pkts = ble_midi_encode_sysex(bytes.fromhex("F0600101F7"), 20)
ok = pkts == [bytes.fromhex("8080F0600101F7")]
R.add("encode short", "PASS" if ok else "FAIL", str([hexs(p) for p in pkts]))
frame = bytes([0xF0, 0x60, 0x02, 0x01]) + bytes(range(1, 43)) + bytes([0xF7])
pkts = ble_midi_encode_sysex(frame, 20)
back = b"".join(ble_midi_decode_packet(p) for p in pkts)
R.add(
"encode/decode 47B",
"PASS" if back == frame and len(pkts) >= 3 else "FAIL",
f"{len(pkts)} pkts",
)
return R.summary()
def main():
ap = argparse.ArgumentParser(description="BLE 吉他协议全量测试")
ap.add_argument("--ble-name", default=DEFAULT_NAME)
ap.add_argument("--ble-address")
ap.add_argument(
"--transport",
choices=("midi", "uart"),
default="midi",
help="midi=标准 BLE-MIDI 帧; uart=自定义 e49a 原始 SysEx裸写易弄挂模组",
)
ap.add_argument(
"--unpair",
action=argparse.BooleanOptionalAction,
default=(sys.platform == "win32"),
help="连接前 unpairWindows 默认开;--no-unpair 可关)",
)
ap.add_argument("--allow-poweroff", action="store_true")
ap.add_argument("--smoke", action="store_true", help="仅冒烟: 01 01/02 + 02 01")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--report")
args = ap.parse_args()
if args.selftest:
sys.exit(selftest())
try:
import bleak # noqa: F401
except ImportError:
sys.exit("缺少 bleak: pip install bleak")
print(f"选项: unpair={args.unpair} transport={args.transport}")
sim = BleMidiSim(
name=args.ble_name,
address=args.ble_address,
transport=args.transport,
unpair=args.unpair,
)
print(
f"=== BLE 测试: {sim.info.get('name')} ({sim.info.get('address')}) "
f"transport={sim.info.get('transport')} payload={sim.info.get('max_payload')} ==="
)
if not sim.probe_or_rebind(args.ble_name, args.ble_address):
sim.close()
path = args.report or os.path.normpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"Doc",
"reports",
f"ble_sysex_{datetime.datetime.now():%Y%m%d_%H%M%S}.md",
)
)
R = Results()
R.add("probe 01 01", "FAIL", "timeout after unpair/reconnect")
write_report(
R,
sim.info or {},
path,
notes=[
"首包无应答:多半是 Windows 残留配对,或 ATS2853 被 E49A 裸写弄挂需复位。",
"推荐: python tools/test_protocol_ble.py --unpair",
"仍失败则 JLink reset / 给吉他断电再开机后再测。",
],
)
print(f"\n报告已写入: {path}")
sys.exit(1)
notes = [
f"transport={sim.info.get('transport')}",
f"unpair={args.unpair}",
"Windows 默认 --unpair全 timeout 时先复位吉他再测。",
"勿用 --transport uart 裸写做冒烟(易停广播)。",
]
try:
R = smoke(sim) if args.smoke else run_tests(sim, allow_poweroff=args.allow_poweroff)
finally:
sim.close()
code = R.summary()
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = args.report or os.path.normpath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"Doc",
"reports",
f"ble_sysex_{ts}.md",
)
)
write_report(R, sim.info, path, notes=notes)
print(f"\n报告已写入: {path}")
sys.exit(code)
if __name__ == "__main__":
main()