Skip to content

Commit b3ef5e6

Browse files
committed
feat: add pyOCD target override support and enhance firmware download logic
Signed-off-by: Jos Verlinde <Jos_Verlinde@hotmail.com>
1 parent 61fb6c6 commit b3ef5e6

9 files changed

Lines changed: 379 additions & 48 deletions

File tree

mpflash/cli_flash.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@
135135
default=True,
136136
help="""Automatically install CMSIS packs for missing pyOCD targets. Default: enabled.""",
137137
)
138+
@click.option(
139+
"--target",
140+
"pyocd_target",
141+
default=None,
142+
help="""Explicit pyOCD target override (for --method pyocd), e.g. rp2040 or r7fa4m1ab.""",
143+
metavar="PYOCD_TARGET",
144+
)
138145
@click.option(
139146
"--force",
140147
"-f",
@@ -225,6 +232,7 @@ def _create_worklist_or_fail(*create_args, **create_kwargs) -> FlashTaskList:
225232
# Extract pyOCD options
226233
probe_id = kwargs.pop("probe_id", None)
227234
auto_install_packs = kwargs.pop("auto_install_packs", True)
235+
pyocd_target = kwargs.pop("pyocd_target", None)
228236

229237
params = FlashParams(**kwargs)
230238
params.versions = list(params.versions)
@@ -383,6 +391,7 @@ def _create_worklist_or_fail(*create_args, **create_kwargs) -> FlashTaskList:
383391
method=flash_method,
384392
probe_id=probe_id,
385393
auto_install_packs=auto_install_packs,
394+
target_override=pyocd_target,
386395
flash_mode=params.flash_mode,
387396
retry_on_error=params.retry_on_error,
388397
retry_baud=params.retry_baud,

mpflash/flash/__init__.py

Lines changed: 113 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def flash_tasks(
5454
):
5555
"""Flash every entry in ``tasks`` and return the updated boards."""
5656

57+
attempted_backend_downloads: set[tuple[str, str, str, str, bool]] = set()
58+
5759
def _pick_backend_compatible_firmware(task, fw_info):
5860
"""Pick a firmware image matching the explicit backend's supported formats."""
5961
if fw_info is None:
@@ -89,34 +91,88 @@ def _pick_backend_compatible_firmware(task, fw_info):
8991
detected_board_id = f"{board.board}-{board.variant}" if board.variant else board.board
9092
board_ids = [getattr(fw_info, "board_id", ""), detected_board_id]
9193

92-
candidates = []
93-
seen_files = set()
94-
for bid in board_ids:
95-
if not bid:
96-
continue
97-
# First prefer exact port match, then broaden to any port.
98-
for cand in find_downloaded_firmware(
99-
board_id=bid,
100-
version=fw_info.version,
101-
port=board.port,
102-
custom=bool(fw_info.custom),
103-
) + find_downloaded_firmware(
104-
board_id=bid,
105-
version=fw_info.version,
106-
port="",
107-
custom=bool(fw_info.custom),
108-
):
109-
if cand.firmware_file not in seen_files:
110-
seen_files.add(cand.firmware_file)
111-
candidates.append(cand)
112-
113-
for cand in reversed(candidates):
114-
if Path(cand.firmware_file).suffix.lower() in backend.supported_formats:
94+
def _find_supported_candidate():
95+
candidates = []
96+
seen_files = set()
97+
for bid in board_ids:
98+
if not bid:
99+
continue
100+
# First prefer exact port match, then broaden to any port.
101+
for cand in find_downloaded_firmware(
102+
board_id=bid,
103+
version=fw_info.version,
104+
port=board.port,
105+
custom=bool(fw_info.custom),
106+
) + find_downloaded_firmware(
107+
board_id=bid,
108+
version=fw_info.version,
109+
port="",
110+
custom=bool(fw_info.custom),
111+
):
112+
if cand.firmware_file not in seen_files:
113+
seen_files.add(cand.firmware_file)
114+
candidates.append(cand)
115+
116+
for cand in reversed(candidates):
117+
if Path(cand.firmware_file).suffix.lower() in backend.supported_formats:
118+
return cand
119+
return None
120+
121+
if candidate := _find_supported_candidate():
122+
log.info(
123+
f"Using {requested_name} compatible firmware {candidate.firmware_file} "
124+
f"instead of {fw_info.firmware_file} for {board.board} on {board.serialport}"
125+
)
126+
return candidate
127+
128+
# No local firmware matches backend-supported file types; try one
129+
# targeted download refresh before handing off to backend selection.
130+
download_key = (
131+
requested_name,
132+
detected_board_id,
133+
fw_info.version,
134+
board.port or "",
135+
bool(fw_info.custom),
136+
)
137+
if download_key not in attempted_backend_downloads:
138+
attempted_backend_downloads.add(download_key)
139+
try:
140+
from mpflash.download import download
141+
from mpflash.mpboard_id.alternate import alternate_board_names
142+
143+
log.info(
144+
f"No local {requested_name} firmware with suffix in "
145+
f"{list(backend.supported_formats)} for {board.board} on {board.serialport}; "
146+
"trying firmware download refresh"
147+
)
148+
download(
149+
ports=[board.port] if board.port else [],
150+
boards=alternate_board_names(detected_board_id, board.port),
151+
versions=[fw_info.version],
152+
force=True,
153+
clean=True,
154+
)
155+
except Exception as exc: # noqa: BLE001 - fallback to original firmware below
156+
log.debug(
157+
f"Backend-compatible firmware refresh failed for {detected_board_id} "
158+
f"{fw_info.version}: {exc}"
159+
)
160+
161+
if candidate := _find_supported_candidate():
115162
log.info(
116-
f"Using {requested_name} compatible firmware {cand.firmware_file} "
163+
f"Using downloaded {requested_name} compatible firmware {candidate.firmware_file} "
117164
f"instead of {fw_info.firmware_file} for {board.board} on {board.serialport}"
118165
)
119-
return cand
166+
return candidate
167+
168+
raise MPFlashError(
169+
f"No firmware matching backend {requested_name!r} for "
170+
f"{detected_board_id!r} {fw_info.version}. "
171+
f"Selected firmware is {fw_info.firmware_file!r} ({current_suffix or '<none>'}), "
172+
f"but {requested_name} supports {list(backend.supported_formats)}. "
173+
"A download refresh was attempted but no compatible firmware was found."
174+
)
175+
120176
return fw_info
121177

122178
flashed = []
@@ -174,12 +230,38 @@ def flash_mcu(
174230
:attr:`FlashContext.options` for the backend to consume.
175231
"""
176232
requested = _resolve_backend_name(method)
177-
try:
178-
backend = select_backend(mcu, fw_file, requested_name=requested)
179-
except MPFlashError:
180-
raise
181-
except Exception as e: # noqa: BLE001 - selection should never crash callers
182-
raise MPFlashError(f"Failed to select flash backend: {e}") from e
233+
target_override = kwargs.get("target_override")
234+
235+
# When the user explicitly provides a pyOCD target override, skip
236+
# capability probing that depends on MCU metadata-derived target matching.
237+
if requested == "pyocd" and target_override:
238+
backend = get_backend("pyocd")
239+
if backend is None:
240+
raise MPFlashError("Unknown flash method 'pyocd'.")
241+
platform = default_services.current_platform()
242+
suffix = fw_file.suffix.lower()
243+
if backend.supported_formats and suffix not in backend.supported_formats:
244+
raise MPFlashError(
245+
f"Backend 'pyocd' cannot flash {fw_file.name}: unsupported format {suffix or '<none>'}."
246+
)
247+
if backend.supported_platforms and platform not in backend.supported_platforms:
248+
raise MPFlashError(
249+
f"Backend 'pyocd' does not run on {platform.value}."
250+
)
251+
if not backend.is_available():
252+
raise MPFlashError(
253+
"pyOCD is not installed (install with: uv sync --extra pyocd)."
254+
)
255+
log.info(
256+
f"Using explicit pyOCD target override '{target_override}' for {mcu.board_id or mcu.board}"
257+
)
258+
else:
259+
try:
260+
backend = select_backend(mcu, fw_file, requested_name=requested)
261+
except MPFlashError:
262+
raise
263+
except Exception as e: # noqa: BLE001 - selection should never crash callers
264+
raise MPFlashError(f"Failed to select flash backend: {e}") from e
183265

184266
log.debug(f"Using flash backend: {backend.name} for {mcu.board_id}")
185267

mpflash/flash/builtins/pyocd/core.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,16 @@ def get_pyocd_targets() -> Dict[str, Dict[str, str]]:
355355

356356
subprocess_targets[target_name] = {"vendor": vendor, "part_number": part_number, "source": source}
357357

358-
# Merge subprocess results (subprocess is authoritative)
359-
if len(subprocess_targets) > len(targets):
360-
targets = subprocess_targets
361-
log.debug(f"Subprocess method loaded {len(targets)} total targets")
362-
else:
363-
# Supplement API results with any pack targets from subprocess
364-
pack_targets = {k: v for k, v in subprocess_targets.items() if v["source"] == "pack" and k not in targets}
365-
targets.update(pack_targets)
366-
log.debug(f"Added {len(pack_targets)} pack targets from subprocess")
358+
# Merge API + subprocess results.
359+
# Some pyOCD installations omit certain builtins in CLI output
360+
# (RP2040 is one observed example), so never discard API targets.
361+
if subprocess_targets:
362+
added = len([k for k in subprocess_targets if k not in targets])
363+
targets.update(subprocess_targets)
364+
log.debug(
365+
f"Merged {len(subprocess_targets)} subprocess targets "
366+
f"({added} new, {len(subprocess_targets) - added} updated)"
367+
)
367368

368369
except Exception as subprocess_error:
369370
log.debug(f"Subprocess target discovery failed: {subprocess_error}")
@@ -631,6 +632,32 @@ def detect_pyocd_target(mcu: MPRemoteBoard, auto_install_packs: bool = True) ->
631632
_target_cache[cache_key] = target
632633
return target
633634

635+
# RP2 fallback: pyOCD supports RP2040/RP2350 families but target names
636+
# may vary across versions (rp2040, rp2040_core0, rp2350, ...).
637+
if not target and mcu_info.get("port", "").lower() == "rp2":
638+
board_id = (mcu.board_id or "").upper()
639+
cpu = (mcu.cpu or "").upper()
640+
if "RP2350" in cpu or "PICO2" in board_id:
641+
rp_family = "rp2350"
642+
else:
643+
rp_family = "rp2040"
644+
645+
# Prefer exact family target, then core0, then any same-family target.
646+
if rp_family in pyocd_targets:
647+
target = rp_family
648+
elif f"{rp_family}_core0" in pyocd_targets:
649+
target = f"{rp_family}_core0"
650+
else:
651+
for candidate in pyocd_targets:
652+
if candidate.lower().startswith(rp_family):
653+
target = candidate
654+
break
655+
656+
if target:
657+
log.info(f"RP2 fallback target match: {mcu.board_id} -> {target}")
658+
_target_cache[cache_key] = target
659+
return target
660+
634661
# No target found - try automatic pack installation if enabled
635662
if auto_install_packs and chip_family:
636663
log.info(f"No pyOCD target found for {chip_family}, attempting automatic pack installation")

mpflash/flash/builtins/pyocd/flash.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -435,20 +435,33 @@ def __exit__(self, exc_type, exc_val, exc_tb):
435435
class PyOCDFlash:
436436
"""High-level pyOCD flash programming interface."""
437437

438-
def __init__(self, mcu: MPRemoteBoard, probe_id: Optional[str] = None, auto_install_packs: bool = True):
438+
def __init__(
439+
self,
440+
mcu: MPRemoteBoard,
441+
probe_id: Optional[str] = None,
442+
auto_install_packs: bool = True,
443+
target_override: Optional[str] = None,
444+
):
439445
"""
440446
Initialize PyOCD flash programmer.
441447
442448
Args:
443449
mcu: MPRemoteBoard instance with board information
444450
probe_id: Specific probe unique ID to use (optional)
445451
auto_install_packs: Automatically install missing CMSIS packs
452+
target_override: Explicit pyOCD target name override (optional)
446453
"""
447454
self.mcu = mcu
448455
self.probe_id = probe_id
449456

450-
# Detect target type using core functionality
451-
self.target_type = detect_pyocd_target(mcu, auto_install_packs=auto_install_packs)
457+
# Detect target type using core functionality unless explicitly overridden.
458+
self.target_type = target_override or detect_pyocd_target(
459+
mcu, auto_install_packs=auto_install_packs
460+
)
461+
if target_override:
462+
log.info(
463+
f"Using explicit pyOCD target override for {mcu.board_id}: {target_override}"
464+
)
452465

453466
if not is_pyocd_available():
454467
raise MPFlashError("No debug probe support available. Install with: uv sync --extra pyocd")
@@ -614,7 +627,13 @@ def find_pyocd_probe(probe_id: Optional[str] = None) -> Optional[PyOCDProbe]:
614627

615628

616629
def flash_pyocd(
617-
mcu: MPRemoteBoard, fw_file: Path, erase: bool = False, probe_id: Optional[str] = None, auto_install_packs: bool = True, **kwargs
630+
mcu: MPRemoteBoard,
631+
fw_file: Path,
632+
erase: bool = False,
633+
probe_id: Optional[str] = None,
634+
auto_install_packs: bool = True,
635+
target_override: Optional[str] = None,
636+
**kwargs,
618637
) -> bool:
619638
"""
620639
Flash MCU using pyOCD SWD/JTAG interface.
@@ -625,6 +644,7 @@ def flash_pyocd(
625644
erase: Whether to erase flash before programming
626645
probe_id: Specific debug probe ID to use (optional)
627646
auto_install_packs: Automatically install missing CMSIS packs
647+
target_override: Explicit pyOCD target name override (optional)
628648
**kwargs: Additional options
629649
630650
Returns:
@@ -633,12 +653,17 @@ def flash_pyocd(
633653
Raises:
634654
MPFlashError: If flashing fails
635655
"""
636-
if not is_pyocd_supported(mcu):
656+
if not target_override and not is_pyocd_supported(mcu):
637657
reason = get_unsupported_reason(mcu)
638658
raise MPFlashError(f"PyOCD flash not supported: {reason}")
639659

640660
# Create flasher and program
641-
flasher = PyOCDFlash(mcu, probe_id=probe_id, auto_install_packs=auto_install_packs)
661+
flasher = PyOCDFlash(
662+
mcu,
663+
probe_id=probe_id,
664+
auto_install_packs=auto_install_packs,
665+
target_override=target_override,
666+
)
642667
ok = flasher.flash_firmware(fw_file, erase=erase, **kwargs)
643668
if ok:
644669
# Give the board a moment to reset and re-enumerate over USB,

mpflash/flash/builtins/pyocd_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def flash(self, ctx: FlashContext) -> FlashResult:
7676

7777
passthrough = {
7878
k: ctx.options[k]
79-
for k in ("probe_id", "auto_install_packs")
79+
for k in ("probe_id", "auto_install_packs", "target_override")
8080
if k in ctx.options
8181
}
8282
ok = flash_pyocd(ctx.mcu, fw_file=ctx.fw_file, erase=ctx.erase, **passthrough)

0 commit comments

Comments
 (0)