mothercup/tools/flash_package.py
evan.liu f729d16ff2 V1.00.25: UART OTA stream mode + RX DMA ring + SRAM cliff fix
Firmware:
- bsp_usart: RX moved from RXDNE byte-queue IRQ to DMA CH2 circular ring
  (2KB) + IDLE-irq wakeup; bytes keep landing during flash-erase
  interrupt-off windows (the reason lockstep was needed before)
- app_ota: stream mode (ble_protocol.md section 6.7) - 16B BEGIN with
  flags bit0=STREAM (UART channel only), DATA acked on 4KB sector
  crossings (original chunk length; two ack-gating bugs fixed during
  field test), throttled BAD_STATE for go-back-N
- FreeRTOS heap 20KB->18KB: the 2KB ring pushed the stack top past the
  0x2000C000 SRAM cliff (probed via SWD: accesses above fault on this
  silicon, usable app SRAM is 32KB) which locked the board at boot;
  merge_image.py now hard-fails the package when the image initial SP
  leaves (0x20004000, 0x2000C000]

Tools:
- ble_ota_update.py: UART stream sender (8KB window, stall watchdog
  rewind, auto-fallback to lockstep on pre-V1.00.24 firmware,
  --lockstep to force); case-insensitive option parsing
- flash_package.py/bat: stream NSpyocd output live (chunked reads keep
  the \r progress bar), vendor banner rebranded to CAIIC NSLINK UMP
- merge_image.py: initial-SP cliff guard

Verified: UART stream OTA both directions, 55.8KB in ~5.9s @9.5kB/s
0 rewinds (lockstep was 39s), PASS after reboot; board boot fixed and
verified via SWD.

Docs: ble_protocol.md section 6.7, dev log section 47 (+ SRAM cliff
post-mortem), AGENTS.md RAM rule rewritten (both cliffs) + V1.00.25
2026-09-05 09:48:28 +08:00

112 lines
4.0 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
flash_package.py - erase the chip and program the merged package via
NS-LINK (NSpyocd). Streams the vendor tool's output with the vendor
banner replaced by the CAIIC one (the banner is baked into the
PyInstaller-packed NSpyocd.exe and cannot be edited).
usage: flash_package.bat [image]
[image] optional, default: tools/out/mothercup_ble_prod.hex
Connect NS-LINK to SWD (PA4/PA5) and the reset pin first, and CLOSE
Keil (it holds the probe).
"""
import os
import subprocess
import sys
# NSpyocd is a PyInstaller-packed pyocd: its banner is inside the
# compressed bundle, so we rebrand it on the fly. The console encoding
# varies (UTF-8 vs GBK), match the byte pattern in both.
BANNER_PATTERNS = [
("国民技术NSLINK上位机".encode("utf-8"), b"CAIIC NSLINK UMP"),
("国民技术NSLINK上位机".encode("gbk"), b"CAIIC NSLINK UMP"),
]
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PYOCD = os.path.join(ROOT, "nations-tec", "N32WB03x_SDK_V2.0.0",
"utilities", "dfu", "NSpyocd", "NSpyocd.exe")
# The builtin "n32wb031" target only maps 256KB of flash (fails at
# 0x01040000 on the dual-bank package); the DFP pack target
# "n32wb031keq6_2" maps the full 512KB of the KEQ6-2 we actually use.
PACK = os.path.join(ROOT, "nations-tec", "N32WB03x_DFP.1.4.0.pack")
TARGET = "n32wb031keq6_2"
def run_step(args):
"""Run one NSpyocd step, streaming output with the banner replaced.
Reads in small chunks (not line-buffered) so the tool's \\r-based
progress bar redraws live. Returns the process exit code."""
proc = subprocess.Popen([PYOCD] + args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
out = sys.stdout.buffer
keep = max(len(p) for p, _ in BANNER_PATTERNS) - 1
tail = b""
while True:
chunk = proc.stdout.read(256)
if not chunk:
break
buf = tail + chunk
# replace on the COMBINED buffer first (a pattern straddling the
# chunk boundary only exists contiguously here), then hold back
# the last maxpat-1 bytes so a pattern split by the next chunk
# is still seen whole
for old, new in BANNER_PATTERNS:
buf = buf.replace(old, new)
emit, tail = buf[:-keep], buf[-keep:]
out.write(emit)
out.flush()
for old, new in BANNER_PATTERNS:
tail = tail.replace(old, new)
out.write(tail)
out.flush()
return proc.wait()
def main():
image = sys.argv[1] if len(sys.argv) > 1 else \
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"out", "mothercup_ble_prod.hex")
if not os.path.isfile(image):
print("%s not found - run tools\\make_package.bat first" % image)
return 1
# -M under-reset: connect while holding the chip in reset
# -f 1000000: 1 MHz SWD clock (slow but tolerant of wiring)
print("Erasing chip...", flush=True)
rc = run_step(["erase", "--chip", "--pack", PACK, "-M", "under-reset",
"-f", "1000000", "-t", TARGET])
if rc != 0:
return fail()
print("Programming %s ..." % image, flush=True)
# -O smart_flash=false: with the pack target the pre-program diff pass
# faults reading 0x01000000; programming+verify itself is fine.
rc = run_step(["load", "--pack", PACK, "-M", "under-reset",
"-f", "1000000", "-t", TARGET,
"-O", "smart_flash=false", image])
if rc != 0:
return fail()
print("Program Finish!")
return 0
def fail():
print()
print("*** FLASH FAILED ***")
print("Troubleshooting:")
print(" 1. Close Keil / any tool holding the probe, replug NS-LINK USB")
print(" 2. List probes: \"%s\" list" % PYOCD)
print(" 3. Check wiring: SWDIO=PA5 SWCLK=PA4 GND and the RESET pin")
print(" 4. If several probes are attached, add -u <probe_uid> "
"to the commands above")
return 1
if __name__ == "__main__":
sys.exit(main())