344 lines
11 KiB
Python
344 lines
11 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
ble_log_pull.py — 通过 BLE 拉取 K1 设备现场日志分区 (LOG.BIN)
|
|||
|
|
|
|||
|
|
协议 (SysEx, App->设备 F0 60 ... F7):
|
|||
|
|
07 00 查询 meta
|
|||
|
|
07 01 按 offset 读取分片 (每片最多 20 原始字节, nibble 编码)
|
|||
|
|
07 02 清空 (需确认码 55 2A)
|
|||
|
|
|
|||
|
|
注意: 本模组上 UART GATT 裸写容易导致断连;默认优先 BLE-MIDI。
|
|||
|
|
|
|||
|
|
依赖: bleak
|
|||
|
|
用法:
|
|||
|
|
python ble_log_pull.py --out LOG.BIN --decode crash.log -v
|
|||
|
|
python ble_log_pull.py --transport midi --address CB:4E:FD:F1:C0:79 --out LOG.BIN
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
from test_protocol_ble import BleMidiSim, DEFAULT_NAME # noqa: E402
|
|||
|
|
from test_protocol_app_sim import HEAD, APP_ID, build, hexs # noqa: E402
|
|||
|
|
|
|||
|
|
CHUNK = 20
|
|||
|
|
|
|||
|
|
|
|||
|
|
def u32_to_7bit(v: int) -> bytes:
|
|||
|
|
v &= 0xFFFFFFFF
|
|||
|
|
return bytes(
|
|||
|
|
[
|
|||
|
|
(v >> 28) & 0x7F,
|
|||
|
|
(v >> 21) & 0x7F,
|
|||
|
|
(v >> 14) & 0x7F,
|
|||
|
|
(v >> 7) & 0x7F,
|
|||
|
|
v & 0x7F,
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def u32_from_7bit(b: bytes, i: int = 0) -> int:
|
|||
|
|
return (
|
|||
|
|
((b[i] & 0x7F) << 28)
|
|||
|
|
| ((b[i + 1] & 0x7F) << 21)
|
|||
|
|
| ((b[i + 2] & 0x7F) << 14)
|
|||
|
|
| ((b[i + 3] & 0x7F) << 7)
|
|||
|
|
| (b[i + 4] & 0x7F)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def nibbles_to_bytes(nibs: bytes) -> bytes:
|
|||
|
|
out = bytearray()
|
|||
|
|
for i in range(0, len(nibs) - 1, 2):
|
|||
|
|
out.append(((nibs[i] & 0x0F) << 4) | (nibs[i + 1] & 0x0F))
|
|||
|
|
return bytes(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_meta(frame: bytes) -> dict:
|
|||
|
|
if len(frame) < 36 or frame[2] != 0x07 or frame[3] != 0x00:
|
|||
|
|
raise ValueError(f"bad meta frame len={len(frame)}: {hexs(frame)}")
|
|||
|
|
i = 4
|
|||
|
|
valid = frame[i]
|
|||
|
|
i += 1
|
|||
|
|
ver = frame[i]
|
|||
|
|
i += 1
|
|||
|
|
size = u32_from_7bit(frame, i)
|
|||
|
|
i += 5
|
|||
|
|
data_size = u32_from_7bit(frame, i)
|
|||
|
|
i += 5
|
|||
|
|
write_off = u32_from_7bit(frame, i)
|
|||
|
|
i += 5
|
|||
|
|
wrap = u32_from_7bit(frame, i)
|
|||
|
|
i += 5
|
|||
|
|
boot = u32_from_7bit(frame, i)
|
|||
|
|
i += 5
|
|||
|
|
lines = u32_from_7bit(frame, i)
|
|||
|
|
return {
|
|||
|
|
"valid": valid,
|
|||
|
|
"ver": ver,
|
|||
|
|
"partition_size": size,
|
|||
|
|
"data_size": data_size,
|
|||
|
|
"write_off": write_off,
|
|||
|
|
"wrap_count": wrap,
|
|||
|
|
"boot_count": boot,
|
|||
|
|
"line_count": lines,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_chunk(frame: bytes) -> tuple[int, bytes]:
|
|||
|
|
if len(frame) < 11 or frame[2] != 0x07 or frame[3] != 0x01:
|
|||
|
|
raise ValueError(f"bad chunk frame: {hexs(frame)}")
|
|||
|
|
off = u32_from_7bit(frame, 4)
|
|||
|
|
n = frame[9]
|
|||
|
|
nibs = frame[10 : 10 + n]
|
|||
|
|
if len(nibs) < n:
|
|||
|
|
raise ValueError("truncated chunk")
|
|||
|
|
return off, nibbles_to_bytes(nibs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def safe_send(sim: BleMidiSim, frame: bytes) -> None:
|
|||
|
|
if not sim.is_connected():
|
|||
|
|
raise RuntimeError("Not connected")
|
|||
|
|
sim.send(frame)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def wait_cmd(sim: BleMidiSim, cmd1: int, cmd2: int, timeout: float, verbose: bool):
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
others = []
|
|||
|
|
while time.monotonic() - t0 < timeout:
|
|||
|
|
if not sim.is_connected():
|
|||
|
|
if verbose:
|
|||
|
|
print(" (wait) link dropped")
|
|||
|
|
return None
|
|||
|
|
sim._pump()
|
|||
|
|
i = 0
|
|||
|
|
while i < len(sim.pending):
|
|||
|
|
fr = sim.pending[i]
|
|||
|
|
if (
|
|||
|
|
len(fr) >= 4
|
|||
|
|
and fr[0] == HEAD
|
|||
|
|
and fr[1] == APP_ID
|
|||
|
|
and fr[2] == cmd1
|
|||
|
|
and fr[3] == cmd2
|
|||
|
|
):
|
|||
|
|
sim.pending.pop(i)
|
|||
|
|
return fr
|
|||
|
|
others.append(sim.pending.pop(i))
|
|||
|
|
continue
|
|||
|
|
time.sleep(0.01)
|
|||
|
|
if verbose and others:
|
|||
|
|
print(f" (timeout) saw {len(others)} other frame(s), last={hexs(others[-1][:32])}")
|
|||
|
|
elif verbose:
|
|||
|
|
print(" (timeout) no frames received at all")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def query_cmd(
|
|||
|
|
sim: BleMidiSim,
|
|||
|
|
frame: bytes,
|
|||
|
|
cmd1: int,
|
|||
|
|
cmd2: int,
|
|||
|
|
timeout: float,
|
|||
|
|
verbose: bool,
|
|||
|
|
retries: int = 2,
|
|||
|
|
):
|
|||
|
|
# 轻量 drain,避免 read_frame 清空 pending 时误伤
|
|||
|
|
t_drain = time.monotonic() + 0.15
|
|||
|
|
while time.monotonic() < t_drain:
|
|||
|
|
sim._pump()
|
|||
|
|
sim.pending.clear()
|
|||
|
|
time.sleep(0.02)
|
|||
|
|
|
|||
|
|
for attempt in range(retries + 1):
|
|||
|
|
if verbose:
|
|||
|
|
print(f" TX[{attempt}]: {hexs(frame)}")
|
|||
|
|
try:
|
|||
|
|
safe_send(sim, frame)
|
|||
|
|
except Exception as e:
|
|||
|
|
if verbose:
|
|||
|
|
print(f" TX fail: {e}")
|
|||
|
|
return None
|
|||
|
|
fr = wait_cmd(sim, cmd1, cmd2, timeout=timeout, verbose=verbose)
|
|||
|
|
if fr is not None:
|
|||
|
|
if verbose:
|
|||
|
|
print(f" RX: {hexs(fr[:48])}{'...' if len(fr) > 48 else ''}")
|
|||
|
|
return fr
|
|||
|
|
time.sleep(0.25)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def soft_probe(sim: BleMidiSim, verbose: bool = False) -> bool:
|
|||
|
|
"""只用 01 01 探活,失败时不立刻 unpair(Windows 上 unpair 易把模组打哑巴)。"""
|
|||
|
|
time.sleep(0.6) # 连接后给模组 settle
|
|||
|
|
r = query_cmd(sim, build(0x01, 0x01), 0x01, 0x01, timeout=3.0, verbose=verbose, retries=2)
|
|||
|
|
if r is not None and len(r) >= 5 and r[0] == HEAD and r[1] == APP_ID:
|
|||
|
|
return True
|
|||
|
|
if verbose:
|
|||
|
|
print(" soft probe failed; try probe_or_rebind once")
|
|||
|
|
try:
|
|||
|
|
return bool(
|
|||
|
|
sim.probe_or_rebind(
|
|||
|
|
name=sim.info.get("name") or DEFAULT_NAME,
|
|||
|
|
address=sim.info.get("address"),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
if verbose:
|
|||
|
|
print(f" probe_or_rebind exception: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pull(sim: BleMidiSim, out_path: str, timeout: float = 4.0, verbose: bool = False) -> dict:
|
|||
|
|
if not soft_probe(sim, verbose=verbose):
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"BLE 协议无应答(01 01)。请确认琴已开机、蓝牙开关为开;"
|
|||
|
|
"若刚用过 --transport uart 导致断连,请断电复位后再用 --transport midi。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
meta_frame = query_cmd(
|
|||
|
|
sim, build(0x07, 0x00), 0x07, 0x00, timeout=timeout, verbose=verbose, retries=3
|
|||
|
|
)
|
|||
|
|
if meta_frame is None:
|
|||
|
|
ver = query_cmd(
|
|||
|
|
sim, build(0x01, 0x03), 0x01, 0x03, timeout=2.0, verbose=verbose, retries=1
|
|||
|
|
)
|
|||
|
|
tip = ""
|
|||
|
|
if ver is not None:
|
|||
|
|
tip = (
|
|||
|
|
" 普通指令(01 03)正常,但 07 00 无应答 → 固件可能未含日志协议,请重新烧录。"
|
|||
|
|
)
|
|||
|
|
elif not sim.is_connected():
|
|||
|
|
tip = " 链路已断开(uart 传输常见)。请改用: python ble_log_pull.py --transport midi ..."
|
|||
|
|
raise RuntimeError("timeout waiting log meta (07 00)." + tip)
|
|||
|
|
|
|||
|
|
meta = parse_meta(meta_frame)
|
|||
|
|
print(
|
|||
|
|
f"meta valid={meta['valid']} ver={meta['ver']} size={meta['partition_size']} "
|
|||
|
|
f"write={meta['write_off']} wrap={meta['wrap_count']} "
|
|||
|
|
f"boot={meta['boot_count']} lines={meta['line_count']}"
|
|||
|
|
)
|
|||
|
|
if not meta["valid"] or meta["partition_size"] == 0:
|
|||
|
|
raise RuntimeError("device log partition invalid")
|
|||
|
|
|
|||
|
|
total = meta["partition_size"]
|
|||
|
|
buf = bytearray(b"\xff" * total)
|
|||
|
|
got = 0
|
|||
|
|
off = 0
|
|||
|
|
while off < total:
|
|||
|
|
want = min(CHUNK, total - off)
|
|||
|
|
req = bytes([HEAD, APP_ID, 0x07, 0x01]) + u32_to_7bit(off) + bytes([want, 0xF7])
|
|||
|
|
chunk = None
|
|||
|
|
for attempt in range(4):
|
|||
|
|
if not sim.is_connected():
|
|||
|
|
raise RuntimeError(f"link dropped at offset 0x{off:X}")
|
|||
|
|
try:
|
|||
|
|
safe_send(sim, req)
|
|||
|
|
except Exception as e:
|
|||
|
|
time.sleep(0.1 * (attempt + 1))
|
|||
|
|
if verbose:
|
|||
|
|
print(f" chunk TX fail @0x{off:X}: {e}")
|
|||
|
|
continue
|
|||
|
|
fr = wait_cmd(sim, 0x07, 0x01, timeout=timeout, verbose=False)
|
|||
|
|
if fr is None:
|
|||
|
|
time.sleep(0.05 * (attempt + 1))
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
roff, data = parse_chunk(fr)
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
if roff == off and data:
|
|||
|
|
chunk = data
|
|||
|
|
break
|
|||
|
|
if chunk is None:
|
|||
|
|
raise RuntimeError(f"timeout at offset 0x{off:X} (after retries)")
|
|||
|
|
buf[off : off + len(chunk)] = chunk
|
|||
|
|
got += len(chunk)
|
|||
|
|
off += len(chunk)
|
|||
|
|
if off % 4096 == 0 or off >= total:
|
|||
|
|
print(f" {off}/{total} ({100.0 * off / total:.1f}%)")
|
|||
|
|
|
|||
|
|
with open(out_path, "wb") as f:
|
|||
|
|
f.write(buf)
|
|||
|
|
print(f"wrote {out_path} ({got} bytes)")
|
|||
|
|
return meta
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser(description="Pull K1 field log over BLE")
|
|||
|
|
ap.add_argument("--out", default="LOG.BIN")
|
|||
|
|
ap.add_argument("--name", default=DEFAULT_NAME)
|
|||
|
|
ap.add_argument("--address", default=None)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--transport",
|
|||
|
|
choices=("midi", "uart", "auto"),
|
|||
|
|
default="midi",
|
|||
|
|
help="默认 midi(uart 裸写在本模组上容易 Not connected)",
|
|||
|
|
)
|
|||
|
|
ap.add_argument("--decode", default=None, help="also decode to this text path")
|
|||
|
|
ap.add_argument("--clear", action="store_true", help="clear flash log after pull")
|
|||
|
|
ap.add_argument("-v", "--verbose", action="store_true")
|
|||
|
|
ap.add_argument("--timeout", type=float, default=4.0)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
transports = [args.transport]
|
|||
|
|
if args.transport == "auto":
|
|||
|
|
# midi 优先:uart GATT 写可能导致模组断连
|
|||
|
|
transports = ["midi", "uart"]
|
|||
|
|
elif args.transport == "uart":
|
|||
|
|
print(
|
|||
|
|
"警告: --transport uart 在本机模组上常导致 Not connected;"
|
|||
|
|
"若失败请改用 --transport midi"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
last_err = None
|
|||
|
|
for tr in transports:
|
|||
|
|
print(f"=== transport={tr} ===")
|
|||
|
|
sim = None
|
|||
|
|
try:
|
|||
|
|
sim = BleMidiSim(name=args.name, address=args.address, transport=tr)
|
|||
|
|
print(f"connected: {sim.info}")
|
|||
|
|
time.sleep(0.8)
|
|||
|
|
if not sim.is_connected():
|
|||
|
|
raise RuntimeError("connected then immediately dropped")
|
|||
|
|
meta = pull(sim, args.out, timeout=args.timeout, verbose=args.verbose)
|
|||
|
|
if args.decode:
|
|||
|
|
from log_decode import decode_file
|
|||
|
|
|
|||
|
|
decode_file(
|
|||
|
|
args.out,
|
|||
|
|
args.decode,
|
|||
|
|
meta.get("write_off", 0),
|
|||
|
|
meta.get("wrap_count", 0),
|
|||
|
|
)
|
|||
|
|
if args.clear:
|
|||
|
|
req = build(0x07, 0x02, 0x55, 0x2A)
|
|||
|
|
safe_send(sim, req)
|
|||
|
|
time.sleep(0.3)
|
|||
|
|
sim._pump()
|
|||
|
|
print("clear requested")
|
|||
|
|
return
|
|||
|
|
except Exception as e:
|
|||
|
|
last_err = e
|
|||
|
|
print(f"FAILED ({tr}): {e}")
|
|||
|
|
finally:
|
|||
|
|
if sim is not None:
|
|||
|
|
try:
|
|||
|
|
sim.close()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
time.sleep(1.0)
|
|||
|
|
|
|||
|
|
raise SystemExit(
|
|||
|
|
f"all transports failed: {last_err}\n"
|
|||
|
|
"建议: 1) 断电复位吉他 2) python ble_log_pull.py --transport midi -v --out LOG.BIN --decode crash.log\n"
|
|||
|
|
"若有 J-Link: python rtt_flash_log_dump.py --out crash.log --scan"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|