# -*- 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()