K1Guitar/tools/ble_log_pull.py

344 lines
11 KiB
Python
Raw Normal View History

# -*- 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 探活,失败时不立刻 unpairWindows 上 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="默认 midiuart 裸写在本模组上容易 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()