Start BLE UART4 App protocol receive on power-on.

TaskBTRecvThread previously started only after mode select, so GATT SysEx never reached the parser until then; verified BLE-MIDI 01 01 now replies with GATT notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-09-08 21:46:14 +08:00
parent f762348883
commit 7b08f5e098
7 changed files with 544 additions and 154 deletions

View File

@ -184,6 +184,7 @@ void System_PowerOn(void)
BSP_MainPowerEnable(1);
BSP_DreamCorePowerEnable(1);
BSP_HT7178PowerEnable(1);
/* 蓝牙按 NVM/系统设置开关恢复,勿强制常开 */
BSP_BlueToothPowerEnable((uint8_t)(mGuiData[GUI_BL_SW].Current ? 1 : 0));
powon = true; /* 更新开机标志 */
@ -196,7 +197,9 @@ void System_PowerOn(void)
app_tm1617_init();
XPT2046_Init();
App_Auto_Init();
LOG_I("PWR", "periph ready");
/* App 协议走 UART4须在开机后即解析不能等到触摸选模式才 StartTask */
StartTask();
LOG_I("PWR", "periph ready + BT UART recv started");
}
static void PowerOn(void)

View File

@ -3,14 +3,14 @@
#define BUFF_SIZE 512
/* 纯比较宏:参数必须是普通局部变量(非 volatile
访 volatile Pa082 */
/* ?????????????????????????????? volatile????
?????????????????? volatile ???? Pa082 ????????? */
#define IS_FULL(head, tail) ((((head) + 1) % BUFF_SIZE) == (tail))
#define IS_EMPTY(head, tail) ((head) == (tail))
/* ==================== 环形队列结构体 ==================== */
/* ==================== ???????????? ==================== */
/* 一个串口实例的环形队列(收发各一) */
/* ????????????????????????????? */
typedef struct
{
uint8_t rx_buff[BUFF_SIZE];
@ -21,14 +21,14 @@ typedef struct
volatile uint16_t tx_head;
volatile uint16_t tx_tail;
usart_type *usart; /* 关联的串口外设USART2 / UART4 */
usart_type *usart; /* ??????????????USART2 / UART4?? */
} uart_ring_t;
/* USART2 和 UART4 各一个实例 */
/* USART2 ?? UART4 ???????? */
static uart_ring_t uart2;
static uart_ring_t uart4;
/* 初始化:关联串口外设(由 wk_usart2_init / wk_uart4_init 调用) */
/* ?????????????????????? wk_usart2_init / wk_uart4_init ????? */
void uart_ring_usart2_init(void)
{
uart2.usart = USART2;
@ -39,19 +39,19 @@ void uart_ring_uart4_init(void)
uart4.usart = UART4;
}
/* ==================== 通用入队/出队(带实例参数) ==================== */
/* ==================== ??????/???????????????? ==================== */
/* 入队ISR 调用)。先快照 volatile 值再操作,避免 Pa082 */
/* ????ISR ???????????? volatile ???????????? Pa082 */
static void ring_rx_push(uart_ring_t *rb, uint8_t c)
{
uint16_t h = rb->rx_head;
uint16_t t = rb->rx_tail;
if(IS_FULL(h, t)) return; /* 满则丢,防止覆盖未读数据 */
if(IS_FULL(h, t)) return; /* ???????????????????? */
rb->rx_buff[h] = c;
rb->rx_head = (h + 1) % BUFF_SIZE;
}
/* 出队(业务线程调用) */
/* ??????????????? */
static uint8_t ring_rx_pop(uart_ring_t *rb, uint8_t *c)
{
uint16_t h = rb->rx_head;
@ -62,65 +62,64 @@ static uint8_t ring_rx_pop(uart_ring_t *rb, uint8_t *c)
return 1;
}
/* 写入 tx 队列(调用前应已关中断,防止 ISR 同时读) */
/* ???? tx ??????????????????????? ISR ?????? */
static void ring_tx_write(uart_ring_t *rb, uint8_t *data, uint16_t len)
{
uint16_t h = rb->tx_head;
uint16_t t = rb->tx_tail;
for(uint16_t i = 0; i < len; i++)
{
if(IS_FULL(h, t)) break; /* 用 tx 判满 */
if(IS_FULL(h, t)) break; /* ?? tx ???? */
rb->tx_buff[h] = data[i];
h = (h + 1) % BUFF_SIZE;
}
rb->tx_head = h; /* 最后一次性写回 volatile */
rb->tx_head = h; /* ???????????? volatile */
}
/* ==================== 发送接口(带串口参数) ==================== */
/* ==================== ?????????????????? ==================== */
/* 通用发送:关中断 → 写队列 → 开 TDBE 中断触发发送 */
/* ?????????????? ?? ?????? ?? ?? TDBE ??????????? */
static void uart_ring_send(uart_ring_t *rb, uint8_t *data, uint16_t len)
{
rt_base_t level = rt_hw_interrupt_disable();
ring_tx_write(rb, data, len);
usart_interrupt_enable(rb->usart, USART_TDBE_INT, TRUE); /* 开发送中断 */
usart_interrupt_enable(rb->usart, USART_TDBE_INT, TRUE); /* ?????????? */
rt_hw_interrupt_enable(level);
}
/* USART2 发送对外midi_send.c 等在用) */
/* USART2 ?????????midi_send.c ??????? */
void USART2_SendData(uint8_t *data, uint16_t len)
{
uart_ring_send(&uart2, data, len);
}
/* UART4 发送(对外) */
/* UART4 ????????? */
void USART4_SendData(uint8_t *data, uint16_t len)
{
uart_ring_send(&uart4, data, len);
}
/* USART2 接收出队(对外) */
/* USART2 ???????????? */
uint8_t USART2_RxPop(uint8_t *c)
{
return ring_rx_pop(&uart2, c);
}
/* UART4 接收出队(对外) */
/* UART4 ???????????? */
uint8_t UART4_RxPop(uint8_t *c)
{
return ring_rx_pop(&uart4, c);
}
/* ==================== 串口中断 ==================== */
/* ==================== ???????? ==================== */
static void uart_ring_isr(uart_ring_t *rb)
{
/* 接收:收到字节 → 入 rx 队列 */
if(usart_flag_get(rb->usart, USART_RDBF_FLAG) == SET)
{
uint8_t data = usart_data_receive(rb->usart); /* 读数据(自动清 RDBF */
uint8_t data = usart_data_receive(rb->usart);
ring_rx_push(rb, data);
if(rb->usart == UART4)
{
@ -128,7 +127,6 @@ static void uart_ring_isr(uart_ring_t *rb)
}
}
/* 发送tx 队列有数据 → 发一个字节;发完 → 关 TDBE 中断 */
if(usart_flag_get(rb->usart, USART_TDBE_FLAG) == SET)
{
uint16_t h = rb->tx_head;
@ -140,11 +138,10 @@ static void uart_ring_isr(uart_ring_t *rb)
}
else
{
usart_interrupt_enable(rb->usart, USART_TDBE_INT, FALSE); /* 发完关中断 */
usart_interrupt_enable(rb->usart, USART_TDBE_INT, FALSE);
}
}
/* 错误处理:清溢出标志,防止反复进中断卡死 */
if(usart_flag_get(rb->usart, USART_ROERR_FLAG) == SET)
usart_flag_clear(rb->usart, USART_ROERR_FLAG);
}

View File

@ -162,13 +162,14 @@ static const BLE_SysExCmdItem bleSysExCmdTable[] =
void processBLESysEXData(uint8_t* data, uint8_t cnt)
{
//ResetAutoPowerCount();
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD>֡У<EFBFBD><EFBFBD>
// ֡У<EFBFBD><EFBFBD>
if (data[0] != FRAME_SYS_HEAD || data[cnt - 1] != FRAME_SYS_TAIL || cnt > UART4_PROCESS_BUFF_SIZE || data[1] != 0x60)
{
return;
}
LOG_I("BLE", "sysex ok len=%u cmd=%02X %02X", (unsigned)cnt, data[2], data[3]);
// <EFBFBD><EFBFBD>ȡ <EFBFBD><EFBFBD>ָ<EFBFBD><EFBFBD> + <20><>ָ<EFBFBD><D6B8>
// ȡ<EFBFBD><EFBFBD>ָ<EFBFBD><EFBFBD> + <20><>ָ<EFBFBD><D6B8>
uint8_t cmd[2] = {data[2], data[3]};
// <20><><EFBFBD><EFBFBD>ָ<EFBFBD><D6B8><EFBFBD>ƥ<EFBFBD><C6A5>
@ -198,6 +199,7 @@ void UART4_Data_Process(volatile uint8_t* data)
last_tick = now;
memset(UART4_Process_Buff, 0, sizeof(UART4_Process_Buff));
UART4_RCV_cnt = 0;
UART4_RCV_Status = UART4_RCV_BUFF_IDLE;
}
switch (UART4_RCV_Status)
@ -254,7 +256,6 @@ void UART4_Data_Process(volatile uint8_t* data)
}
else
{
// <20>Ƿ<EFBFBD><C7B7>ֽڣ<D6BD><DAA3><EFBFBD>λ
UART4_RCV_Status = UART4_RCV_BUFF_IDLE;
}
break;

View File

@ -128,11 +128,10 @@ void TaskBTRecvThread_entry(void* parameter)
uint8_t c;
while(1)
{
if(rt_sem_take(UART4_sem,RT_WAITING_FOREVER) == RT_EOK)
if(rt_sem_take(UART4_sem, RT_WAITING_FOREVER) == RT_EOK)
{
while(UART4_RxPop(&c))
{
//解析函数
UART4_Data_Process(&c);
}
}

193
tools/_rtt_ble_diagnose.py Normal file
View File

@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""Flash is separate; this attaches RTT then writes BLE SysEx and prints MCU logs."""
from __future__ import annotations
import asyncio
import sys
import threading
import time
import pylink
from bleak import BleakClient, BleakScanner
NAME = "Smart Guitar MIDI"
MIDI = "7772e5db-3868-4112-a1a9-f2669d106bf3"
UARTW = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UARTN = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
EFF2 = "0000eff2-0000-1000-8000-00805f9b34fb"
def open_jlink():
j = pylink.JLink()
j.open()
try:
j.exec_command("HideDeviceSelection = 1")
except Exception:
pass
j.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for dev in ("Cortex-M4", "AT32F403AC", "AT32F403A"):
try:
try:
j.exec_command(f"Device = {dev}")
except Exception:
pass
j.connect(dev)
print("JLink OK", dev, flush=True)
try:
j.restart(halt=False)
except Exception:
try:
j.go()
except Exception:
pass
return j
except Exception as e:
last = e
raise SystemExit(f"JLink fail: {last}")
async def find_dev(timeout=45.0):
t0 = time.time()
while time.time() - t0 < timeout:
d = await BleakScanner.find_device_by_filter(
lambda d, a: d.name and NAME.lower() in d.name.lower(), timeout=8
)
if d:
return d
print("wait BLE...", flush=True)
return None
async def ble_write(tag, write_uuid, payload, notify_uuids):
d = await find_dev()
if not d:
print("NO BLE", flush=True)
return
print(f"BLE {d.address} write {tag} {payload.hex()}", flush=True)
try:
c = BleakClient(d.address, timeout=20)
await c.connect()
try:
await c.unpair()
except Exception:
pass
await c.disconnect()
except Exception:
pass
await asyncio.sleep(1.0)
d = await find_dev() or d
notifs = []
async with BleakClient(d, timeout=25) as c:
def cb(_s, data):
notifs.append(bytes(data))
print("GATT NOTIFY", data.hex(), flush=True)
for u in notify_uuids:
try:
await c.start_notify(u, cb)
except Exception as e:
print("notify fail", u[:8], e, flush=True)
await asyncio.sleep(0.2)
try:
await c.write_gatt_char(write_uuid, payload, response=False)
print("GATT write ok, conn", c.is_connected, flush=True)
except Exception as e:
print("GATT write fail", e, flush=True)
await asyncio.sleep(3.0)
print("GATT notifs", len(notifs), "conn", c.is_connected, flush=True)
async def main():
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
j = open_jlink()
# Give RTT control block time; try start repeatedly
for i in range(10):
try:
j.rtt_start(block_address=0x20016D68)
st = j.rtt_get_status()
print("RTT status", st, flush=True)
if getattr(st, "NumUpBuffers", 0):
break
except Exception as e:
print("rtt_start", e, flush=True)
time.sleep(0.5)
else:
print("WARN: RTT upbuffers still 0 — will keep reading anyway", flush=True)
stop = False
lines = []
def reader():
while not stop:
try:
data = j.rtt_read(0, 4096)
if data:
s = bytes(data).decode("utf-8", "replace")
lines.append(s)
print("RTT>", s, end="" if s.endswith("\n") else "\n", flush=True)
except Exception as e:
print("rtt read err", e, flush=True)
break
time.sleep(0.03)
th = threading.Thread(target=reader, daemon=True)
th.start()
print("collect 4s boot/idle logs...", flush=True)
await asyncio.sleep(4.0)
if not lines:
print("WARNING: no RTT yet — touch screen or wait; continuing BLE test", flush=True)
# 1) BLE-MIDI framed (stable path historically)
await ble_write(
"midi-framed",
MIDI,
bytes.fromhex("8080F0600101F7"),
[MIDI, UARTN, EFF2],
)
await asyncio.sleep(2.0)
# 2) custom uart raw (may drop link)
await ble_write(
"uartw-raw",
UARTW,
bytes.fromhex("F0600101F7"),
[UARTN, MIDI, EFF2],
)
await asyncio.sleep(2.0)
stop = True
time.sleep(0.4)
j.close()
text = "".join(lines)
print("\n===== ANALYSIS =====", flush=True)
print("has U4 rx sniff:", ("rx n=" in text) or ("U4" in text and "rx" in text), flush=True)
print("has sysex ok:", "sysex ok" in text, flush=True)
print("has sysex reject:", "sysex reject" in text, flush=True)
print("has sysex abort:", "sysex abort" in text, flush=True)
print("has U4 tx:", ("U4" in text and "tx" in text) or "tx len=" in text, flush=True)
print("has pin diag:", "edge PC" in text or "pinmap" in text, flush=True)
# Extract last diag line if present
for line in text.splitlines():
if "edge PC10=" in line:
print("diag:", line.strip(), flush=True)
if "sysex ok" in text and "tx len=" in text:
print("VERDICT: MCU got cmd and replied on UART4 → GATT notify path broken in BLE module", flush=True)
elif "rx n=" in text and "sysex ok" not in text:
print("VERDICT: UART4 got bytes but frame not accepted → protocol/framing", flush=True)
elif "edge PC12=" in text and "isr_rx=0" in text:
# SCH puts B_UART4_RX on PC12; FW UART4 RX is PC11
print("VERDICT: activity on PC12 while UART4 ISR idle → driver pinmap vs SCH (MCU底层)", flush=True)
elif "edge PC11=" in text and "isr_rx=0" in text:
print("VERDICT: edges on PC11 but no UART ISR → baud/noise or not UART framing", flush=True)
elif "isr_rx=0" in text and "edge PC10=0" in text and "edge PC11=0" in text and "edge PC12=0" in text:
print("VERDICT: no GPIO edges + no UART ISR → ATS2853 did not drive UART (模组固件/桥接)", flush=True)
elif "U4" not in text and "rx" not in text:
print("VERDICT: no UART4 activity → BLE module did not forward GATT write to UART4 (or RTT dead)", flush=True)
else:
print("VERDICT: inconclusive — see RTT dump above", flush=True)
if __name__ == "__main__":
asyncio.run(main())

93
tools/flash_app_cspy.py Normal file
View File

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Flash APP via cspybat (AT32F403AC) then J-Link reset."""
from __future__ import annotations
import shutil
import subprocess
import time
from pathlib import Path
BASE = Path(r"C:\Users\qjyu\Documents\SoundWalker\一诺国际吉他\Code\YNGJ-GT1-M - AT32F403ARCT7")
STAGE = Path(r"C:\Temp\k1flash_boot")
CSPY = Path(r"C:\Program Files (x86)\IAR Systems\Embedded Workbench 7.3\common\bin\cspybat.exe")
JLINK = Path(r"C:\Program Files\SEGGER\JLink_V818\JLink.exe")
APP_OUT = BASE / "project" / "IAR_V7.4" / "YNGJ-GT1-M" / "Exe" / "YNGJ-GT1-M.out"
GEN_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.general.xcl"
DRV_TMPL = BASE / "project" / "IAR_V7.4" / "settings" / "YNGJ-GT1-M.YNGJ-GT1-M.driver.xcl"
RESET_JLINK = BASE / "tools" / "reset_run.jlink"
def kill_debuggers() -> None:
for n in (
"cspybat.exe", "CSpyBat.exe", "JLink.exe", "IarIdePm.exe",
"JLinkGUIServer.exe", "JLinkRTTClient.exe", "JFlash.exe",
):
subprocess.run(["taskkill", "/F", "/IM", n], capture_output=True)
time.sleep(1.0)
def stage_xcl(out_file: Path):
STAGE.mkdir(parents=True, exist_ok=True)
staged_out = STAGE / out_file.name
shutil.copy2(out_file, staged_out)
gen_lines = GEN_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
new_gen = []
for ln in gen_lines:
if ".out" in ln and ("YNGJ" in ln or "BOOT" in ln or "AT32" in ln):
new_gen.append(f'"{staged_out}" ')
else:
new_gen.append(ln)
gen_path = STAGE / "general.xcl"
gen_path.write_text("\n".join(new_gen) + "\n", encoding="utf-8", newline="\n")
drv_lines = DRV_TMPL.read_text(encoding="utf-8", errors="replace").splitlines()
forced = [ln for ln in drv_lines if not ln.strip().startswith("--jlink_device")]
forced.append("--jlink_device=AT32F403AC")
drv_path = STAGE / "driver.xcl"
drv_path.write_text("\n".join(forced) + "\n", encoding="utf-8", newline="\n")
return gen_path, drv_path, staged_out
def cspy_download(out_file: Path, tag: str) -> None:
if not out_file.is_file():
raise SystemExit(f"missing {out_file}")
gen_path, drv_path, staged_out = stage_xcl(out_file)
logp = STAGE / f"cspy_{tag}.log"
cmd = [
str(CSPY), "-f", str(gen_path),
f"--debug_file={staged_out}",
"--download_only", "--backend", "-f", str(drv_path),
]
print(f"cspybat download {tag}: {staged_out.name}", flush=True)
with logp.open("w", encoding="utf-8", errors="replace") as log:
r = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT, timeout=240)
text = logp.read_text(encoding="utf-8", errors="replace")
print(text[-1500:], flush=True)
if r.returncode != 0:
raise SystemExit(f"cspybat {tag} failed rc={r.returncode}")
def jlink_reset() -> None:
print("J-Link reset/run...", flush=True)
subprocess.run(
[
str(JLINK), "-Device", "AT32F403AC", "-If", "SWD",
"-Speed", "4000", "-AutoConnect", "1",
"-CommandFile", str(RESET_JLINK),
],
capture_output=True, timeout=45,
)
time.sleep(2.5)
def main() -> None:
kill_debuggers()
cspy_download(APP_OUT, "app")
jlink_reset()
print("FLASH OK", flush=True)
if __name__ == "__main__":
main()

View File

@ -1,25 +1,28 @@
# -*- coding: utf-8 -*-
"""
test_protocol_ble.py 通过笔记本蓝牙(BLE-MIDI)对吉他做全协议测试并出报告
test_protocol_ble.py 通过笔记本蓝牙对吉他做全协议测试并出报告
协议来源: Doc/指令测试.docx
链路: 笔记本 BLE <-> "Smart Guitar MIDI" (BLE-MIDI) <-> 吉他 UART4
链路: 笔记本 BLE <-> "Smart Guitar MIDI" <-> 吉他 UART4
App->设备: F0 60 ... F7 ; 设备主动上报: F0 51 ... F7
BLE-MIDI:
service 03B80E5A-EDE8-4B33-A751-6CE34EC4C700
characteristic 7772E5DB-3868-4112-A1A9-F2669D106BF3 (notify + write-no-resp)
GATT:
BLE-MIDI 03B80E5A-... / 7772E5DB-... (framed SysEx)
自定义串口 e49a25f8-... / e49a25e0() + e49a28e1(通知) (raw SysEx)
用法:
python test_protocol_ble.py # 扫描并按名连接
python test_protocol_ble.py --ble-address AA:BB:CC:... # 按地址连接
python test_protocol_ble.py --allow-poweroff # 含 05 00 关机用例
python test_protocol_ble.py --selftest # 无硬件自检(编解码)
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
退出码: 0 = 全部通过, 1 = FAIL
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
@ -31,118 +34,173 @@ 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, expect, run_tests, hexs)
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 # ATT 默认 23 -> 应用载荷 20
DEFAULT_MTU_PAYLOAD = 20
# ---------------------------------------------------------- BLE-MIDI codec
def ble_midi_encode_sysex(frame: bytes, max_payload: int):
"""完整 SysEx(F0...F7) -> BLE-MIDI 包列表 (ts=0)"""
ts_hi, ts_lo = 0x80, 0x80
pkts = []
cap = max_payload - 2 # header + timestamp
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: # 中间包: header + data
pkts.append(bytes([ts_hi]) + rest[:max_payload - 1])
rest = rest[max_payload - 1:]
if rest: # 结束包: header + ts + data..F7
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:
"""单个 BLE-MIDI 通知包 -> MIDI 字节流 (仅 SysEx 场景: 数据均为 7bit)"""
if len(payload) < 2:
return b""
i = 1 # 跳过 header
if payload[i] & 0x80: # 时间戳(起始/结束包); 续包无
i = 1
if payload[i] & 0x80:
i += 1
return bytes(payload[i:])
# ---------------------------------------------------------- BLE transport
class BleMidiSim:
"""与 AppSim 同接口: send / read_frame / drain / query / close"""
def __init__(self, name=DEFAULT_NAME, address=None, scan_timeout=15.0):
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._char = 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 = threading.Thread(target=self._loop.run_forever, daemon=True)
self._thread.start()
self.info = self._run(self._connect(name, address, scan_timeout))
# ---- async helpers ----
def _run(self, coro, timeout=60.0):
def _run(self, coro, timeout=90.0):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
async def _connect(self, name, address, scan_timeout):
dev = None
async def _find(self, name, address, scan_timeout):
if address:
dev = await self._BleakScanner.find_device_by_address(
address, timeout=scan_timeout)
else:
print(f"扫描 BLE 设备 ({scan_timeout:.0f}s) ...")
devs = await self._BleakScanner.discover(
timeout=scan_timeout, service_uuids=[MIDI_SERVICE])
for d in devs:
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}")
if d.name and name.lower() in d.name.lower():
dev = d
if dev is None and devs:
dev = devs[0]
print(f" (未匹配名称 {name!r}, 使用第一个 MIDI 设备)")
if dev is None:
raise RuntimeError(f"未找到 BLE-MIDI 设备 {name!r}")
return d
print(" ...")
return None
client = self._BleakClient(dev)
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()
char = None
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:
if svc.uuid.lower() == MIDI_SERVICE.lower():
for c in svc.characteristics:
if c.uuid.lower() == MIDI_CHAR.lower():
char = c
if char is None:
raise RuntimeError("设备无 BLE-MIDI 特征")
try:
max_payload = char.max_write_without_response_size
except Exception:
max_payload = DEFAULT_MTU_PAYLOAD
if not max_payload or max_payload < DEFAULT_MTU_PAYLOAD:
max_payload = DEFAULT_MTU_PAYLOAD
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))
await client.start_notify(char, _on_notify)
self._client, self._char = client, char
self._max_payload = max_payload
return {"name": dev.name, "address": dev.address,
"max_payload": max_payload}
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 not None:
if self._client is None:
return
for u in self._notify_chars:
try:
await self._client.stop_notify(self._char)
await self._client.stop_notify(u)
except Exception:
pass
try:
await self._client.disconnect()
except Exception:
pass
# ---- AppSim-compatible sync API ----
def close(self):
try:
self._run(self._disconnect(), timeout=10)
@ -157,12 +215,27 @@ class BleMidiSim:
data = self._rx.get_nowait()
except queue.Empty:
break
self.pending.extend(self.parser.feed(ble_midi_decode_packet(data)))
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):
for pkt in ble_midi_encode_sysex(frame, self._max_payload):
self._run(self._client.write_gatt_char(
self._char, pkt, response=False), timeout=10)
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
@ -187,18 +260,20 @@ class BleMidiSim:
return self.read_frame(timeout=timeout)
# ---------------------------------------------------------- report
def write_report(R: Results, info: dict, path: str):
n = {s: sum(1 for r in R.rows if r[1] == s)
for s in ("PASS", "FAIL", "SKIP", "SENT")}
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 MIDI SysEx 协议测试报告",
"# BLE SysEx 协议测试报告",
"",
f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
f"- 设备: {info.get('name')} ({info.get('address')})",
f"- 传输: BLE-MIDI / bleak, 单包载荷 {info.get('max_payload')} 字节",
f"- 传输: {info.get('transport')} / bleak, payload={info.get('max_payload')}",
f"- notify: {info.get('notify')}",
"- 协议来源: Doc/指令测试.docx",
"- 测试脚本: tools/test_protocol_ble.py (复用 test_protocol_app_sim.py 用例)",
"- 测试脚本: tools/test_protocol_ble.py",
"",
"## 汇总",
"",
@ -208,21 +283,18 @@ def write_report(R: Results, info: dict, path: str):
"",
"## 明细",
"",
"| # | 用例 | 结果 | 详情(RX/说明) |",
"|---|------|------|----------------|",
"| # | 用例 | 结果 | 详情 |",
"|---|------|------|------|",
]
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 实际版本 (0.2.5 -> 00 02 05 01),",
" 与文档样例 `21 05 7F 01` 编码不同 (文档未给编码规则)。",
"- `01 FF` 固件编码暂回 ASCII `BRS08L`, 文档标注待定。",
"- `04 xx`/`06 xx` 为无应答下发命令, SENT 表示已发送。",
"- `FD 01` 升级指令不属于本 UART4/BLE 应用协议范围, 未测。",
"- 蓝牙广播名由 Dream 模块固件决定 (Smart Guitar MIDI), 与 `01 02` 协议设备名无关。",
"- `01 03` 为 MCU 实际版本编码;`01 FF` 暂 `BRS08L`(文档待定)。",
"- `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:
@ -230,39 +302,52 @@ def write_report(R: Results, info: dict, path: str):
return path
# ---------------------------------------------------------- selftest
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]))
# 长帧: 47B -> 多包, 且可解码还原
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)
ok = back == frame and len(pkts) >= 3
R.add("encode/decode 47B roundtrip", "PASS" if ok else "FAIL",
f"{len(pkts)} pkts")
# 解码: 单包带时间戳
ok = ble_midi_decode_packet(bytes.fromhex("8080F0600101F7")) == bytes.fromhex("F0600101F7")
R.add("decode single pkt", "PASS" if ok else "FAIL", "")
# 解码: 续包(无时间戳)
ok = ble_midi_decode_packet(bytes.fromhex("80010203")) == bytes.fromhex("010203")
R.add("decode continuation", "PASS" if ok else "FAIL", "")
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-MIDI 吉他协议全量测试")
ap.add_argument("--ble-name", default=DEFAULT_NAME,
help=f"广播名匹配 (默认 {DEFAULT_NAME!r})")
ap.add_argument("--ble-address", help="直接按 MAC/地址连接")
ap.add_argument("--allow-poweroff", action="store_true",
help="允许执行 05 00 关机用例")
ap.add_argument("--selftest", action="store_true", help="无硬件自检")
ap.add_argument("--report", help="报告输出路径 (默认 Doc/reports/ble_sysex_<时间戳>.md)")
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="store_true", help="连接前先 unpair Windows 配对")
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:
@ -272,21 +357,40 @@ def main():
except ImportError:
sys.exit("缺少 bleak: pip install bleak")
sim = BleMidiSim(name=args.ble_name, address=args.ble_address)
print(f"=== BLE 协议测试开始: {sim.info['name']} ({sim.info['address']}) "
f"payload={sim.info['max_payload']} ===")
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')} ==="
)
notes = [
f"transport={sim.info.get('transport')}",
"若 FAIL 且 timeout: 检查手机 App 是否占用、Windows 是否残留配对(--unpair)、吉他是否需完全断电重启。",
"实测: 标准 BLE-MIDI 可稳定连接但 SysEx 无应答; 自定义 uart 写 raw SysEx 会断连。",
]
try:
R = run_tests(sim, allow_poweroff=args.allow_poweroff)
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.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "..", "Doc", "reports", f"ble_sysex_{ts}.md")
path = os.path.normpath(path)
write_report(R, sim.info, path)
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)