tools: add BLE/RTT/pick test and flash helper scripts

- ble_log_pull.py: pull field logs over BLE (midi/uart transport)
- rtt_flash_log_dump.py / log_decode.py: dump and decode RAM/flash logs via J-Link RTT
- _test_normal_pick.py: semi-automated normal-mode pick regression (RTT + physical pad)
- _ble_channel_matrix.py / _probe_ble_rtt.py / _usb_midi_sysex_probe.py: GATT/BLE/USB-MIDI diagnostics
- build_artist_kit.py: package artist tone kit
- flash_boot_app_v024.py: flash boot+app images

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-09-14 14:58:11 +08:00
parent f841d9e7b1
commit daee47a457
9 changed files with 1512 additions and 0 deletions

View File

@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
"""Systematic BLE channel matrix for Smart Guitar MIDI App SysEx."""
from __future__ import annotations
import asyncio
import time
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"
SYSEX = bytes.fromhex("F0600101F7")
FRAMED = bytes.fromhex("8080F0600101F7")
FRAMED_TSF7 = bytes.fromhex("8080F060010180F7")
def hx(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b) if b else "(none)"
async def find(timeout=60.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("waiting advertise...")
return None
async def unpair_if_needed(addr: str):
try:
c = BleakClient(addr, timeout=15)
await c.connect()
try:
await c.unpair()
print("unpaired")
except Exception as e:
print("unpair skip:", e)
try:
await c.disconnect()
except Exception:
pass
await asyncio.sleep(1.5)
except Exception as e:
print("unpair session:", e)
async def one_session(tag: str, write_uuid: str, payload: bytes, notify_uuids, settle=2.5):
d = await find(45)
if not d:
print(f"[{tag}] NO DEVICE")
return {"tag": tag, "ok": False, "err": "no device", "notifs": []}
print(f"\n=== {tag} === write {write_uuid[:8]} {hx(payload)}")
notifs = []
err = None
connected_end = False
try:
async with BleakClient(d, timeout=25) as c:
print("connected", c.is_connected)
def cb(sender, data):
b = bytes(data)
notifs.append(b)
print(f" NOTIFY {hx(b)}")
for u in notify_uuids:
try:
await c.start_notify(u, cb)
print(" notify on", u[:8])
except Exception as e:
print(" notify fail", u[:8], e)
await asyncio.sleep(0.3)
try:
await c.write_gatt_char(write_uuid, payload, response=False)
print(" write ok")
except Exception as e:
err = f"write: {e}"
print(" write fail", e)
await asyncio.sleep(settle)
connected_end = c.is_connected
print(" end conn=", connected_end, "notifs=", len(notifs))
except Exception as e:
err = str(e)
print(" session err", e)
return {
"tag": tag,
"ok": any(b[:3] == b"\xF0\x60\x01" or b[:4] == b"\x80\x80\xF0\x60" for b in notifs),
"notifs": [hx(b) for b in notifs],
"conn_end": connected_end,
"err": err,
}
async def main():
d = await find(60)
if not d:
print("FATAL: device not advertising")
return
print("found", d.name, d.address)
await unpair_if_needed(d.address)
await asyncio.sleep(2)
results = []
# matrix: do NOT put uartw first if it kills radio; do midi first
cases = [
("midi-framed", MIDI, FRAMED, [MIDI, UARTN, EFF2]),
("midi-framed-tsF7", MIDI, FRAMED_TSF7, [MIDI]),
("midi-raw", MIDI, SYSEX, [MIDI]),
("eff2-raw", EFF2, SYSEX, [EFF2, MIDI, UARTN]),
("eff2-framed", EFF2, FRAMED, [EFF2, MIDI]),
("uartw-raw", UARTW, SYSEX, [UARTN, MIDI, EFF2]),
("uartw-framed", UARTW, FRAMED, [UARTN, MIDI]),
]
for tag, wu, payload, nus in cases:
r = await one_session(tag, wu, payload, nus)
results.append(r)
# if uart killed advertising, wait/reset hint
if not r.get("conn_end", True):
print("link dropped; waiting re-advertise...")
await asyncio.sleep(3)
if not await find(30):
print("device gone after drop — stop matrix (need JLink reset)")
break
else:
await asyncio.sleep(1)
print("\n===== MATRIX SUMMARY =====")
for r in results:
status = "PASS" if r["ok"] else "FAIL"
print(f"{status:4} {r['tag']:18} conn_end={r.get('conn_end')} notifs={r['notifs'] or ['(none)']} err={r.get('err')}")
if __name__ == "__main__":
asyncio.run(main())

126
tools/_probe_ble_rtt.py Normal file
View File

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""Probe: RTT log + BLE uartw write to see if MCU gets F0 60."""
import asyncio
import threading
import time
import pylink
from bleak import BleakScanner, BleakClient
UARTW = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
UARTN = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
MIDI = "7772e5db-3868-4112-a1a9-f2669d106bf3"
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 connected", 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 connect failed: {last}")
async def find_dev(timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
d = await BleakScanner.find_device_by_filter(
lambda d, a: d.name and "Smart Guitar" in d.name, timeout=8
)
if d:
return d
print("waiting BLE...", flush=True)
return None
async def main():
j = open_jlink()
j.rtt_start()
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 err", e, flush=True)
break
time.sleep(0.03)
th = threading.Thread(target=reader, daemon=True)
th.start()
print("collect boot logs 3s...", flush=True)
await asyncio.sleep(3)
d = await find_dev(90)
if not d:
print("NO BLE DEVICE", flush=True)
stop = True
j.close()
return
print("BLE", d.name, d.address, flush=True)
try:
async with BleakClient(d, timeout=30) as c:
print("connected", flush=True)
notifs = []
def cb(_s, data):
notifs.append(bytes(data))
print("NOTIFY", data.hex(), flush=True)
for u in (UARTN, MIDI):
try:
await c.start_notify(u, cb)
print("notify ok", u[:8], flush=True)
except Exception as e:
print("notify fail", u[:8], e, flush=True)
await asyncio.sleep(0.5)
print("WRITE uartw F0 60 01 01 F7", flush=True)
try:
await c.write_gatt_char(UARTW, bytes.fromhex("F0600101F7"), response=False)
print("write returned", flush=True)
except Exception as e:
print("write err", e, flush=True)
await asyncio.sleep(4)
print("notifs", len(notifs), "conn", c.is_connected, flush=True)
except Exception as e:
print("BLE session err", e, flush=True)
await asyncio.sleep(1)
stop = True
time.sleep(0.3)
j.close()
print("RTT total chunks", len(lines), flush=True)
ble_hits = [x for x in lines if "BLE" in x or "sysex" in x]
print("BLE-related RTT lines:", ble_hits, flush=True)
if __name__ == "__main__":
asyncio.run(main())

181
tools/_test_normal_pick.py Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""RTT verify normal-mode: hold pad + pick sounds; release stops.
Uses real physical pad hold (inject is cleared by TM1629 scan within one tick).
"""
from __future__ import annotations
import struct
import sys
import time
sys.path.insert(0, ".")
from rtt_pitch_reg_test import ( # noqa: E402
RttSession,
connect_jlink,
find_rtt_control_block,
wait_rtt_ready,
)
SPEED_TAIL = struct.pack("<HHH", 40, 260, 1)
GUI_SWITCH_SIZE = 8
TAB_INDEX = 5
def count_pitch(lines: list[str]) -> int:
return sum(1 for L in lines if "[PITCH]" in L and " on " in L)
def parse_tab(lines: list[str]) -> int | None:
for L in reversed(lines):
if "TONE tab=" not in L:
continue
try:
return int(L.split("tab=")[1].split()[0])
except Exception:
continue
return None
def with_halt(jlink, fn):
was = jlink.halted()
if not was:
jlink.halt()
try:
return fn()
finally:
if not was:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.02)
def find_pattern(jlink, needle: bytes, ram_base=0x20000000, ram_size=0x18000) -> int | None:
def _find():
chunk = 0x1000
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
return None
return with_halt(jlink, _find)
def mem_write(jlink, addr: int, data: bytes) -> None:
with_halt(jlink, lambda: jlink.memory_write8(addr, list(data)))
def mem_read(jlink, addr: int, n: int) -> bytes:
return with_halt(jlink, lambda: bytes(jlink.memory_read8(addr, n)))
def force_tab_normal(jlink) -> bool:
hit = find_pattern(jlink, SPEED_TAIL)
if hit is None:
print("!! GUI_SPEED row not found", flush=True)
return False
base = (hit - 2) - GUI_SWITCH_SIZE
tab_addr = base + TAB_INDEX * GUI_SWITCH_SIZE
mem_write(jlink, tab_addr, struct.pack("<H", 2))
got = struct.unpack("<H", mem_read(jlink, tab_addr, 2))[0]
print(f"mGuiData @ 0x{base:08X} forced TAB={got}", flush=True)
return got == 2
def wait_enter(msg: str, seconds: float) -> None:
print(f"\n>>> {msg}", flush=True)
print(f" ({seconds:.0f}s countdown)", flush=True)
for left in range(int(seconds), 0, -1):
print(f" {left}...", flush=True)
time.sleep(1.0)
def main() -> int:
j = connect_jlink("Cortex-M4")
cb = find_rtt_control_block(j)
if not cb:
raise SystemExit("RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
j.rtt_start(cb)
wait_rtt_ready(j)
sess = RttSession(j)
print("\n=== Setup: 1.bin + TAB=普通(2) ===", flush=True)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone bin1 0", "TONE_BIN1", timeout_s=4.0, retries=4)
if not force_tab_normal(j):
j.rtt_stop()
j.close()
return 3
sess.cmd_ack("tone status", "TONE tab=", timeout_s=3.0, retries=4)
print(f"tab={parse_tab(sess.lines)}", flush=True)
# Stop any leftover
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
force_tab_normal(j)
print("\n=== TEST1: pick with NO pad hold (expect silence) ===", flush=True)
print("请双手离开指板。", flush=True)
wait_enter("双手离开指板后等待自动拨片测试", 3)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone start", "TONE_START_DONE", timeout_s=3.0, retries=5)
n1 = count_pitch(sess.pump(1.5))
print(f"TEST1 PITCH on={n1} (expect 0)", flush=True)
print("\n=== TEST2: hold pad + pick (expect sound) ===", flush=True)
wait_enter("请按住指板任意和弦键不放建议按住第2排中部", 5)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
# keep KEY from physical hold; tone start without clearing if we use 'k'
# But we don't know KEY — physical hold already set KEY_ID+PressFlag via scan.
# tone start WITHOUT k clears KEY_ID! Must use tone start k — but then KEY kept.
# If user is holding, KEY_ID is set; PressFlag is set. tone start sets StartFlag=0
# then clears KEY unless k. Use: don't clear — need tone start k AND physical KEY already set.
sess.cmd_ack("tone start k", "TONE_START_DONE", timeout_s=3.0, retries=5)
play = sess.pump(2.5)
n2 = count_pitch(play)
print(f"TEST2 PITCH on={n2} (expect >0) — keep holding!", flush=True)
for L in play:
if "[PITCH]" in L:
print(" ", L, flush=True)
break
print("\n=== TEST3: release pad (expect stop) ===", flush=True)
wait_enter("请松开指板(松手)", 3)
force_tab_normal(j)
sess.cmd("log clear", 0.15)
post = sess.pump(2.0)
n3 = count_pitch(post)
print(f"TEST3 PITCH on after release={n3} (expect 0)", flush=True)
print("\n=== TEST4: hold again + pick (expect sound) ===", flush=True)
wait_enter("请再次按住指板和弦键不放", 5)
force_tab_normal(j)
sess.cmd("log clear", 0.2)
sess.cmd_ack("tone start k", "TONE_START_DONE", timeout_s=3.0, retries=5)
n4 = count_pitch(sess.pump(2.0))
print(f"TEST4 PITCH on={n4} (expect >0)", flush=True)
print("可松开指板。", flush=True)
time.sleep(1.0)
sess.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=3)
t1, t2, t3, t4 = (n1 == 0), (n2 >= 1), (n3 == 0), (n4 >= 1)
print("\n======== RESULT ========", flush=True)
print(f"TEST1 no-hold silence: {'PASS' if t1 else 'FAIL'} (n={n1})", flush=True)
print(f"TEST2 hold+pick sound: {'PASS' if t2 else 'FAIL'} (n={n2})", flush=True)
print(f"TEST3 release stop: {'PASS' if t3 else 'FAIL'} (n={n3})", flush=True)
print(f"TEST4 re-trigger: {'PASS' if t4 else 'FAIL'} (n={n4})", flush=True)
ok_all = t1 and t2 and t3 and t4
print("OVERALL:", "PASS" if ok_all else "FAIL", flush=True)
j.rtt_stop()
j.close()
return 0 if ok_all else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""Quick USB MIDI (SAM5704) SysEx probe for App F0 60 frames."""
from __future__ import annotations
import ctypes
import ctypes.wintypes as w
import sys
import time
winmm = ctypes.WinDLL("winmm")
CALLBACK_FUNCTION = 0x30000
MIM_DATA, MIM_LONGDATA = 0x3C3, 0x3C4
MHDR_DONE = 0x01
class MIDIINCAPS(ctypes.Structure):
_fields_ = [
("wMid", w.WORD), ("wPid", w.WORD), ("vDriverVersion", w.DWORD),
("szPname", ctypes.c_char * 32), ("dwSupport", w.DWORD),
]
class MIDIOUTCAPS(ctypes.Structure):
_fields_ = [
("wMid", w.WORD), ("wPid", w.WORD), ("vDriverVersion", w.DWORD),
("szPname", ctypes.c_char * 32), ("wTechnology", w.WORD),
("wVoices", w.WORD), ("wNotes", w.WORD), ("wChannelMask", w.WORD),
("dwSupport", w.DWORD),
]
class MIDIHDR(ctypes.Structure):
_fields_ = [
("lpData", ctypes.c_void_p),
("dwBufferLength", w.DWORD),
("dwBytesRecorded", w.DWORD),
("dwUser", ctypes.c_void_p),
("dwFlags", w.DWORD),
("lpNext", ctypes.c_void_p),
("reserved", ctypes.c_void_p),
("dwOffset", w.DWORD),
("dwReserved", ctypes.c_size_t * 8),
]
def find_sam(kind: str):
n = winmm.midiInGetNumDevs() if kind == "in" else winmm.midiOutGetNumDevs()
for i in range(n):
if kind == "in":
c = MIDIINCAPS()
winmm.midiInGetDevCapsA(i, ctypes.byref(c), ctypes.sizeof(c))
else:
c = MIDIOUTCAPS()
winmm.midiOutGetDevCapsA(i, ctypes.byref(c), ctypes.sizeof(c))
name = c.szPname.decode("mbcs", "replace")
if "SAM5704" in name:
return i, name
return None, None
def hx(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b)
def main() -> int:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
in_i, in_n = find_sam("in")
out_i, out_n = find_sam("out")
print(f"IN={in_i} {in_n} OUT={out_i} {out_n}", flush=True)
if in_i is None or out_i is None:
print("SAM5704 not found")
return 1
received: list[bytes] = []
keep = [] # keep buffer refs alive
MidiInProc = ctypes.WINFUNCTYPE(
None, w.HANDLE, w.UINT, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_size_t
)
@MidiInProc
def on_midi_in(h, msg, _inst, p1, p2):
if msg == MIM_LONGDATA:
hdr = ctypes.cast(p1, ctypes.POINTER(MIDIHDR)).contents
n = hdr.dwBytesRecorded
if n and hdr.lpData:
data = ctypes.string_at(hdr.lpData, n)
received.append(data)
print("RX", hx(data), flush=True)
winmm.midiInAddBuffer(h, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
elif msg == MIM_DATA:
print(
f"RX SHORT {(p1 & 0xFF):02X} {((p1 >> 8) & 0xFF):02X} {((p1 >> 16) & 0xFF):02X}",
flush=True,
)
h_in = w.HANDLE()
r = winmm.midiInOpen(ctypes.byref(h_in), in_i, on_midi_in, 0, CALLBACK_FUNCTION)
print("midiInOpen", r, flush=True)
if r != 0:
return r
for _ in range(4):
buf = ctypes.create_string_buffer(1024)
hdr = MIDIHDR()
hdr.lpData = ctypes.cast(buf, ctypes.c_void_p)
hdr.dwBufferLength = 1024
keep.append((buf, hdr))
winmm.midiInPrepareHeader(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInAddBuffer(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInStart(h_in)
h_out = w.HANDLE()
r = winmm.midiOutOpen(ctypes.byref(h_out), out_i, 0, 0, 0)
print("midiOutOpen", r, flush=True)
if r != 0:
return r
def send_sysex(msg: bytes):
buf = ctypes.create_string_buffer(msg)
hdr = MIDIHDR()
hdr.lpData = ctypes.cast(buf, ctypes.c_void_p)
hdr.dwBufferLength = len(msg)
keep.append((buf, hdr))
winmm.midiOutPrepareHeader(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
print("TX", hx(msg), flush=True)
winmm.midiOutLongMsg(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
t0 = time.time()
while not (hdr.dwFlags & MHDR_DONE) and time.time() - t0 < 2:
time.sleep(0.01)
winmm.midiOutUnprepareHeader(h_out, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
for label, msg in [
("01 01", bytes.fromhex("F0600101F7")),
("01 02", bytes.fromhex("F0600102F7")),
("01 03", bytes.fromhex("F0600103F7")),
]:
print("---", label, flush=True)
n0 = len(received)
send_sysex(msg)
time.sleep(1.2)
print(" new rx", len(received) - n0, flush=True)
print("TOTAL RX", len(received), flush=True)
app_like = [x for x in received if len(x) >= 2 and x[0] == 0xF0 and x[1] == 0x60]
print("F0 60 replies", len(app_like), flush=True)
winmm.midiInStop(h_in)
winmm.midiInReset(h_in)
for buf, hdr in keep[:4]:
winmm.midiInUnprepareHeader(h_in, ctypes.byref(hdr), ctypes.sizeof(MIDIHDR))
winmm.midiInClose(h_in)
winmm.midiOutClose(h_out)
return 0
if __name__ == "__main__":
raise SystemExit(main())

343
tools/ble_log_pull.py Normal file
View File

@ -0,0 +1,343 @@
# -*- 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()

185
tools/build_artist_kit.py Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Assemble portable K1 artist tone-update kit + zip for handoff."""
from __future__ import annotations
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
REPO = Path(__file__).resolve().parents[3] # 一诺国际吉他 (tools->proj->Code->repo)
# __file__ = .../YNGJ.../tools/build_artist_kit.py → parents[0]=tools [1]=proj [2]=Code [3]=repo
PROJ = Path(__file__).resolve().parents[1]
TOOLS = Path(__file__).resolve().parent
def main() -> None:
# Fix REPO: tools -> project -> Code -> 一诺国际吉他
repo = TOOLS.parents[1] # Code's parent? TOOLS.parent=proj, TOOLS.parents[1]=Code, [2]=repo
repo = TOOLS.parents[2]
proj = TOOLS.parent
day = datetime.now().strftime("%Y%m%d")
kit_name = f"K1_音师音色更新工具_{day}"
out_root = repo / "tools" / "out"
out_root.mkdir(parents=True, exist_ok=True)
kit = out_root / kit_name
if kit.exists():
shutil.rmtree(kit)
kit.mkdir(parents=True)
baseline = kit / "基线"
drop = kit / "投放"
outdir = kit / "输出"
baseline.mkdir()
drop.mkdir()
outdir.mkdir()
shutil.copy2(TOOLS / "tone_artist_pack.py", kit / "tone_artist_pack.py")
bat = """@echo off
chcp 65001 >nul
setlocal
cd /d "%~dp0"
echo.
echo ========================================
echo K1 音师音色更新工具 (USB / SoundWalkerIAP)
echo 依赖: Python 3无需 pip / 无需 J-Link
echo ========================================
echo.
where python >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 python请安装 Python 3安装时勾选 Add python.exe to PATH
echo 下载: https://www.python.org/downloads/
pause
exit /b 1
)
echo 默认读取投放目录中的 1.bin 2.bin 3.bin
echo 也可传参例如: 音师更新音色.bat --bin1 .\\1.bin --bin2 .\\2.bin --bin3 .\\3.bin
echo.
python "%~dp0tone_artist_pack.py" --open %*
set ERR=%ERRORLEVEL%
echo.
if not "%ERR%"=="0" (
echo 打包失败查看: python "%~dp0tone_artist_pack.py" -h
pause
exit /b %ERR%
)
echo 完成请打开输出\\音师音色包_日期 请这样刷.txt SoundWalkerIAP 刷机
pause
exit /b 0
"""
(kit / "音师更新音色.bat").write_text(bat, encoding="utf-8", newline="\r\n")
ziliao = next(
p
for p in repo.iterdir()
if p.is_dir() and (p / "logo.bin").is_file() and (p / "Charg.bin").is_file()
)
shutil.copy2(ziliao / "logo.bin", baseline / "logo.bin")
shutil.copy2(ziliao / "Charg.bin", baseline / "Charg.bin")
shutil.copy2(proj / "tools" / "out" / "ui0902_res.bin", baseline / "ui0902_res.bin")
iap = repo / "升级" / "MCU主控升级"
shutil.copy2(iap / "SoundWalkerIAP.exe", kit / "SoundWalkerIAP.exe")
doc = iap / "升级步骤.docx"
if doc.is_file():
shutil.copy2(doc, kit / "升级步骤.docx")
src0909 = repo / "Doc" / "音色文件" / "0909"
for n in ("1.bin", "2.bin", "3.bin"):
shutil.copy2(src0909 / n, drop / n)
(drop / "说明.txt").write_text(
"请把新的 1.bin / 2.bin / 3.bin 放到本目录(覆盖即可),然后双击上一级「音师更新音色.bat」。\n"
"\n"
"也可用命令指定任意路径:\n"
" 音师更新音色.bat --bin1 路径\\1.bin --bin2 路径\\2.bin --bin3 路径\\3.bin\n",
encoding="utf-8",
newline="\n",
)
(kit / "使用说明.txt").write_text(
"\n".join(
[
"K1 音师音色更新工具 — 使用说明",
"================================",
"",
"【需要准备】",
" 1. 安装 Python 3https://www.python.org/downloads/ ,勾选 Add to PATH",
" 只需标准库,不用 pip 装任何包",
" 2. 本工具包(解压到任意目录,如桌面)",
" 3. 吉他 + USB 线SoundWalkerIAP 升级,不需要 J-Link",
"",
"【目录说明】",
" 投放\\ ← 把新的 1.bin 2.bin 3.bin 放这里",
" 基线\\ ← logo / Charg / UI 底图(勿改)",
" 输出\\ ← 打包结果(自动生成)",
" 音师更新音色.bat",
" tone_artist_pack.py",
" SoundWalkerIAP.exe",
" 升级步骤.docx",
" 使用说明.txt ← 本文件",
"",
"【日常步骤】",
" 1. 用新文件覆盖 投放\\1.bin、2.bin、3.bin",
" 2. 双击「音师更新音色.bat」",
" 3. 在弹出的「输出\\音师音色包_日期」里:",
" - 打开 SoundWalkerIAP.exe",
" - 吉他进入 USB 升级模式(见 升级步骤.docx",
" - 擦除外部 Flash",
" - 把 extflash_ALL_artist_*.res 烧到地址 0x00000000",
" 4. 退出升级模式,重启试听",
"",
"【命令参数示例】",
" 音师更新音色.bat",
" 音师更新音色.bat --drop 投放",
" 音师更新音色.bat --bin1 .\\投放\\1.bin --bin2 .\\投放\\2.bin --bin3 .\\投放\\3.bin",
" 音师更新音色.bat --bin1 D:\\我的音色\\1.bin --bin2 D:\\我的音色\\2.bin --bin3 D:\\我的音色\\3.bin",
" python tone_artist_pack.py -h",
"",
"【生成的包格式】(与开发正式发布的 ALL.res 相同)",
" 0x00000000 logo.bin",
" 0x0000CB70 Charg.bin",
" 0x0001B8F0 1.bin节奏",
" 0x0009D07D 2.bin本地曲≤41KB",
" 0x000A71AC 3.bin万能",
" 0x00100000 UI 界面图",
"",
"【注意】",
" - 必须刷 ALL.res不要只刷单独的 1/2/3.bin否则模式选择花屏",
" - 一般只需刷外部 Flash不用重刷 MCU 固件",
" - 若增删本地曲目或改曲名,需联系开发同步固件后再测",
" - 「投放」里已带示例 1/2/3.bin可先双击 bat 试跑流程",
"",
"【出问题】",
" - 提示找不到 python → 安装 Python 3 并勾选 PATH重开窗口",
" - DAB/大小校验失败 → 检查 bin 是否完整2.bin 是否超过 41KB",
" - 其它问题把黑色窗口全文截图发给开发",
"",
]
),
encoding="utf-8",
newline="\n",
)
zip_path = out_root / f"{kit_name}.zip"
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in kit.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(out_root).as_posix())
print(f"KIT {kit}")
print(f"ZIP {zip_path}")
print(f"ZIP size = {zip_path.stat().st_size} bytes")
for p in sorted(kit.iterdir()):
print(f" {p.name}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""Flash Boot then APP via cspybat (AT32F403AC), reset, verify Boot strings."""
from __future__ import annotations
import os
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")
BOOT_OUT = BASE / "AT32F403ARCT7_BOOT" / "project" / "IAR_V7.4" / "AT32F403ARCT7_BOOT" / "Exe" / "AT32F403ARCT7_BOOT.out"
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"
NEW_GBK = bytes([0xC9, 0xD5, 0xC2, 0xBC, 0xC4, 0xA3, 0xCA, 0xBD]) # 鐑у綍妯″紡
OLD_GBK = bytes([0xC9, 0xFD, 0xBC, 0xB6, 0xC4, 0xA3, 0xCA, 0xBD]) # 鍗囩骇妯″紡
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) -> tuple[Path, 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 verify_boot_in_mcu() -> None:
import pylink
j = pylink.JLink()
j.open()
try:
try:
j.exec_command("HideDeviceSelection = 1")
except Exception:
pass
j.set_tif(pylink.enums.JLinkInterfaces.SWD)
last = None
for dev in ("AT32F403AC", "Cortex-M4", "AT32F403A"):
try:
try:
j.exec_command(f"Device = {dev}")
except Exception:
pass
j.connect(dev)
print(f"verify connect: {dev}", flush=True)
break
except Exception as exc:
last = exc
else:
raise SystemExit(f"verify connect failed: {last}")
j.halt()
# Boot image is in first ~32KB; search GBK markers
chunk = bytes(j.memory_read8(0x08000000, 0x8000))
has_new = NEW_GBK in chunk
has_old = OLD_GBK in chunk
print(f"MCU@0x08000000 contains 鐑у綍妯″紡={has_new} 鍗囩骇妯″紡={has_old}", flush=True)
if not has_new:
raise SystemExit("Boot flash verify FAILED: new 鐑у綍妯″紡 string missing")
if has_old:
print("WARN: old 鍗囩骇妯″紡 string still present somewhere in Boot region", flush=True)
else:
print("Boot flash verify OK", flush=True)
j.reset(halt=False)
finally:
j.close()
def main() -> None:
boot_bin = BOOT_OUT.with_suffix(".bin")
data = boot_bin.read_bytes()
print(
f"local BOOT.bin: new={NEW_GBK in data} old={OLD_GBK in data} size={len(data)}",
flush=True,
)
kill_debuggers()
cspy_download(BOOT_OUT, "boot")
kill_debuggers()
cspy_download(APP_OUT, "app")
jlink_reset()
verify_boot_in_mcu()
print("Done. Keep KEY for burn mode; screen should use UI0902_FLASH_MODE if ExtFlash has art.", flush=True)
if __name__ == "__main__":
main()

95
tools/log_decode.py Normal file
View File

@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""
log_decode.py K1 LOG.BINW25Q 日志分区镜像解码为可读时间线
布局:
[0x0000 .. 0x0FFF] header (magic K1LG ...)
[0x1000 .. end] 文本环形区行以 \\n 结束
用法:
python log_decode.py LOG.BIN -o problem.log
python log_decode.py LOG.BIN --write-off 1234 --wrap 1 -o problem.log
"""
from __future__ import annotations
import argparse
import struct
MAGIC = 0x4B314C47 # 'K1LG'
HDR_SIZE = 0x1000
def unpack_header(blob: bytes) -> dict:
if len(blob) < 36:
return {"valid": False}
magic = struct.unpack_from("<I", blob, 0)[0]
ver = struct.unpack_from("<H", blob, 4)[0]
boot = struct.unpack_from("<I", blob, 8)[0]
write_off = struct.unpack_from("<I", blob, 12)[0]
wrap = struct.unpack_from("<I", blob, 16)[0]
lines = struct.unpack_from("<I", blob, 20)[0]
overflow = struct.unpack_from("<I", blob, 24)[0]
seq = struct.unpack_from("<I", blob, 28)[0]
return {
"valid": magic == MAGIC,
"magic": magic,
"ver": ver,
"boot_count": boot,
"write_off": write_off,
"wrap_count": wrap,
"line_count": lines,
"overflow": overflow,
"seq": seq,
}
def extract_text(data: bytes, write_off: int, wrapped: bool) -> str:
"""Linearize ring: if wrapped, [write_off..end) + [0..write_off); else [0..write_off)."""
if not data:
return ""
if write_off > len(data):
write_off = len(data)
if wrapped and write_off < len(data):
raw = data[write_off:] + data[:write_off]
else:
raw = data[:write_off] if write_off else data
text = raw.decode("utf-8", errors="replace")
text = text.replace("\xff", "")
return text
def decode_file(bin_path: str, out_path: str, write_off=None, wrap_count=None) -> None:
with open(bin_path, "rb") as f:
blob = f.read()
hdr = unpack_header(blob)
data = blob[HDR_SIZE:] if len(blob) > HDR_SIZE else b""
wo = write_off if write_off is not None else hdr.get("write_off", 0)
wrap = wrap_count if wrap_count is not None else hdr.get("wrap_count", 0)
text = extract_text(data, wo, wrap > 0)
lines = [ln for ln in text.splitlines() if ln.strip()]
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
f"# K1 log decode magic_ok={hdr.get('valid')} ver={hdr.get('ver')} "
f"boot={hdr.get('boot_count')} write_off={wo} wrap={wrap} "
f"line_count={hdr.get('line_count')} decoded_lines={len(lines)}\n"
)
for ln in lines:
f.write(ln.rstrip("\r") + "\n")
print(f"decoded {len(lines)} lines -> {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("bin")
ap.add_argument("-o", "--out", default="problem.log")
ap.add_argument("--write-off", type=int, default=None)
ap.add_argument("--wrap", type=int, default=None)
args = ap.parse_args()
decode_file(args.bin, args.out, args.write_off, args.wrap)
if __name__ == "__main__":
main()

115
tools/rtt_flash_log_dump.py Normal file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Dump K1 persisted W25Q field log via J-Link RTT (`log flash dump` / `log flash scan`)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rtt_log_dump import ( # noqa: E402
connect_jlink,
find_rtt_control_block,
import_deps,
send_command,
wait_rtt_ready,
)
DUMP_CMD = b"log flash dump\n"
SCAN_CMD = b"log flash scan 65536\n"
def dump_flash_log(out_path: str, device: str, timeout_s: float, cmd: bytes = DUMP_CMD) -> None:
jlink = connect_jlink(device)
lines: list[str] = []
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT control block not found in SRAM")
print(f"RTT CB @ 0x{cb:08X}")
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
for _ in range(5):
stale = jlink.rtt_read(0, 4096)
if stale:
text = bytes(stale).decode("utf-8", errors="replace")
if text.strip():
print("RTT0 stale:", text.strip()[:200])
time.sleep(0.05)
print(f"Sending: {cmd!r}")
send_command(jlink, cmd)
buffer = b""
deadline = time.time() + timeout_s
while time.time() < deadline:
chunk = jlink.rtt_read(0, 8192)
if chunk:
buffer += bytes(chunk)
if len(buffer) >= 4096 and (len(buffer) % 8192) < 300:
print(f" received {len(buffer)} bytes...")
if b"LOG_FLASH_DUMP_END" in buffer:
break
else:
time.sleep(0.01)
if b"LOG_FLASH_DUMP_END" not in buffer:
preview = buffer[-500:].decode("utf-8", errors="replace") if buffer else ""
raise SystemExit(
"Timeout waiting for LOG_FLASH_DUMP_END.\n"
f"got {len(buffer)} bytes. tail={preview!r}"
)
text = buffer.decode("utf-8", errors="replace")
capture = False
for line in text.splitlines():
if line.strip() == "LOG_FLASH_DUMP_BEGIN":
capture = True
continue
if line.strip() == "LOG_FLASH_DUMP_END":
break
if capture:
lines.append(line)
header = (
f"# K1 flash log dump {datetime.now().isoformat(timespec='seconds')}\n"
f"# device={device}\n"
f"# cmd={cmd.decode('ascii', errors='replace').strip()}\n"
)
body = "\n".join(lines) + ("\n" if lines else "")
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(header)
f.write(body)
print(f"Saved {len(lines)} lines to {out_path}")
print(f"Full path: {os.path.abspath(out_path)}")
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Dump K1 Flash field log via RTT")
parser.add_argument("--out", default="crash.log")
parser.add_argument("--device", default="AT32F403AC")
parser.add_argument("--timeout", type=float, default=300.0)
parser.add_argument(
"--scan",
action="store_true",
help="Ignore write pointer; scan first 64KB data for recoverable lines",
)
args = parser.parse_args()
import_deps()
cmd = SCAN_CMD if args.scan else DUMP_CMD
dump_flash_log(args.out, args.device, args.timeout, cmd=cmd)
if __name__ == "__main__":
main()