HIL: add a pyocd flasher, flash lpcxpresso55s28 over CMSIS-DAP (#4094) pyocd has no VID/PID probe filter, so test/hil/pyocd/run_pyocd.py restricts pyusb discovery to the roster's vid_pid; user_script.py does an LPC55 debug-mailbox reset before pyocd's reset catch, whose flash blank check fails above 100 MHz (UM11126 5.3).
diff --git a/.claude/skills/hil/scripts/hil_remote.py b/.claude/skills/hil/scripts/hil_remote.py index c6cf505..41e6d9d 100644 --- a/.claude/skills/hil/scripts/hil_remote.py +++ b/.claude/skills/hil/scripts/hil_remote.py
@@ -45,7 +45,8 @@ # Everything the rig executes, by repo-relative path. test_hil_bounded's RemoteStaging walks # the harness's import closure and requires each file here, so keep this a plain literal. -# tools/rtt.py is loaded by hil_util through exec_module, which no import walk can see. +# tools/rtt.py is loaded by hil_util through exec_module, and test/hil/pyocd/ runs as pyocd's +# launcher and its user script: no import walk can see either. HARNESS_FILES = ( 'test/hil/hil_test.py', 'test/hil/hil_flash.py', @@ -59,6 +60,8 @@ 'test/hil/helper/hil_report.py', 'test/hil/helper/hil_tt.py', 'test/hil/helper/hil_util.py', + 'test/hil/pyocd/run_pyocd.py', + 'test/hil/pyocd/user_script.py', 'tools/rtt.py', )
diff --git a/docs/reference/hardware-in-the-loop.md b/docs/reference/hardware-in-the-loop.md index 8f5a47c..7cdcaa5 100644 --- a/docs/reference/hardware-in-the-loop.md +++ b/docs/reference/hardware-in-the-loop.md
@@ -171,12 +171,12 @@ | Purpose | What `ci` uses | |-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| | Build | `cmake`, `ninja-build`, and a toolchain per family: `gcc-arm-none-eabi`, a RISC-V GCC, ESP-IDF | -| Flashing | Five tools, one per `Flasher` value — see below | +| Flashing | One tool per `Flasher` value — see below | | Test harness | `pip install -r test/hil/requirements.txt` — hidapi, pyserial, esptool, pyusb (the raw MTP cases) | | Host-side test tools | `dfu-util`, `mtools`, `libmtp9`, `libmtp-runtime`, `alsa-utils` (apt) — the DFU, MSC, MTP and audio tests shell out to these | | USB inspection and recovery | `pciutils` (the `usbtest` firmware gate), `uhubctl` (apt), `tshark` for usbmon capture, `testusb` from the kernel's `tools/usb/testusb.c` | -The `Flasher` column in Attached boards names one of five values; only the ones your own +The `Flasher` column in Attached boards names a tool from the table below; only the ones your own boards use have to be installed. The mapping is not always guessable: | `Flasher` | Binary | @@ -186,6 +186,7 @@ | `openocd` | [`hathach/openocd`][openocd-fork] branch `tinyusb` — one build merging the Raspberry Pi (RP2350), WCH and Analog Devices (MAX32) forks, none upstream | | `esptool` | `esptool` (pip) | | `lm4flash` | `lm4flash` (apt) | +| `pyocd` | `pyocd` 0.45.1 (the launcher relies on its internals), run through `test/hil/pyocd/run_pyocd.py`, so the roster entry needs a `vid_pid` pair | [openocd-fork]: https://github.com/hathach/openocd/tree/tinyusb
diff --git a/docs/reference/hil_boards.md b/docs/reference/hil_boards.md index 48f58e1..39d654f 100644 --- a/docs/reference/hil_boards.md +++ b/docs/reference/hil_boards.md
@@ -15,7 +15,7 @@ | metro_m4_express | device, dual | jlink | metro_m4_express | pl23x; audio_test_freertos skipped: samd51 iso-IN capture fails (arecord EIO) | | lpcxpresso11u37 | device | jlink | | | | ea4088_quickstart | device, host, dual | jlink | ea4088_quickstart | console is the LPC-Link2's RTT channel: this probe has no VCOM (rtt skill) | -| lpcxpresso55s28 | device | jlink | | | +| lpcxpresso55s28 | device | pyocd | | | | ra4m1_ek | device | jlink | | | | raspberry_pi_pico | device, host, dual | openocd | raspberry_pi_pico | | | raspberry_pi_pico_w | host | openocd | | Test native host |
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py index 1b9e914..7583d3a 100755 --- a/test/hil/hil_flash.py +++ b/test/hil/hil_flash.py
@@ -11,6 +11,8 @@ import json import re +import shlex +import shutil import signal import subprocess import time @@ -230,13 +232,15 @@ """Can this flasher DELIVER a recovery while a usbfs node on the rig is poisoned? A post-HUNG reflash only helps if the flasher reaches its probe without opening the - wedged node. Three shapes qualify: + wedged node. Following shapes qualify: * openocd pinned with the roster's `vid_pid` -- the match is made from the cached descriptor and the loop `continue`s BEFORE libusb_open, so a foreign node is never opened. On 2026-08-12 it was the only flasher that still reached its probe. * esptool -- delivery is `-p <ttyACM>`, a named port; it never enumerates usbfs. * openocd over interface/jlink.cfg -- libjaylink opens SEGGER devices only (below). + * pyocd with one `vid_pid` pair -- run_pyocd.py makes pyusb drop every other device + before pyocd's matcher opens it, and leaves only the CMSIS-DAP probe plugin. Everything else enumerates by OPENING nodes, would block in D state on the poisoned one, survive SIGKILL and become a second stray. JLinkExe cannot be pinned: selection @@ -268,6 +272,8 @@ name = (flasher.get('name') or '').lower() if name == 'esptool': return True + if name == 'pyocd': + return pyocd_vid_pid(flasher) is not None # EXACT, not startswith: rescue_openocd and usbtest's # hil_flash.flash_primitive(name) both require the exact name, so an # 'openocd_wch'-style entry would pass this gate, reserve the Rescue-DP legs, @@ -315,6 +321,52 @@ # no reset_lm4flash: lm4flash has no reset-only mode; it resets+runs on flash +def pyocd_vid_pid(flasher: dict) -> list | None: + """[vid, pid] a pyocd entry filters discovery to, or None when it lacks a uid or exactly one + valid `vid_pid` pair: unfiltered, pyocd opens every CMSIS-DAP-class device (run_pyocd.py).""" + vid_pid = flasher.get('vid_pid') + if not (flasher.get('uid') and valid_vid_pid(vid_pid) and len(vid_pid.split()) == 2): + return None + return vid_pid.split() + + +def unfiltered_pyocd(board: dict) -> bool: + """Is the board's primary or recovery flasher a pyocd entry without its VID/PID filter?""" + return any((f.get('name') or '').lower() == 'pyocd' and pyocd_vid_pid(f) is None + for f in (board['flasher'], recover_flasher(board))) + + +def _pyocd_python() -> str: + # the launcher must patch the pyocd it runs, so it runs under pyocd's own interpreter + exe = shutil.which('pyocd') + if exe is None: + raise RuntimeError('pyocd is not on PATH') + with open(exe, 'rb') as f: + line = f.readline().decode(errors='replace').strip() + py = line[2:] if line.startswith('#!') else '' + if not (os.path.isabs(py) and os.path.basename(py).startswith('python') and os.access(py, os.X_OK)): + raise RuntimeError(f'{exe}: want an absolute python shebang, got {line!r}') + return py + + +def _pyocd_argv(flasher: dict, verb: str, *extra: str) -> list: + vid_pid = pyocd_vid_pid(flasher) + if vid_pid is None: + raise ValueError(f'pyocd flasher {flasher.get("uid")!r} needs a uid and one "vid_pid" pair') + # -W: with no matching probe pyocd otherwise waits for one to be plugged in + return [_pyocd_python(), str(hil_util.TINYUSB_ROOT / 'test/hil/pyocd/run_pyocd.py'), *vid_pid, verb, '-W', + '-u', f'cmsisdap:{flasher["uid"]}', *shlex.split(flasher.get('args', '')), *extra] + + +def flash_pyocd(board, firmware, timeout=None): + # pyocd chdirs into its project dir (run_pyocd.py) + return hil_util.run_cmd(_pyocd_argv(board['flasher'], 'flash', os.path.abspath(firmware)), timeout=timeout) + + +def reset_pyocd(board, timeout=None): + return hil_util.run_cmd(_pyocd_argv(board['flasher'], 'reset'), timeout=timeout) + + def flash_primitive(flasher_name: str): """The flasher's flash_* callable, case-folded from the roster name. @@ -343,6 +395,7 @@ 'jlink': '.elf', 'lm4flash': '.bin', 'openocd': '.elf', + 'pyocd': '.elf', 'stlink': '.elf', }
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index ee51719..0f54fe3 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py
@@ -167,7 +167,7 @@ name: str uid: str args: NotRequired[str] # stlink entries carry no args - vid_pid: NotRequired[str] # openocd probe pin, verbatim (e.g. "0x2e8a 0x000c") + vid_pid: NotRequired[str] # openocd/pyocd probe filter, verbatim (e.g. "0x2e8a 0x000c") verify: NotRequired[bool] # openocd read-back verify opt-out (WCH) @@ -2342,6 +2342,10 @@ if bad_rtt: # JlinkRtt speaks JLinkExe only (the OpenOCD RTT route is manual — rtt skill) _config_abort(f'"logger": "rtt" needs a jlink flasher: {", ".join(bad_rtt)}') + bad_pyocd = [e['name'] for e in config_boards if hil_flash.unfiltered_pyocd(e)] + if bad_pyocd: + _config_abort(f'a pyocd flasher needs a uid and one "vid_pid" pair (e.g. "0x1fc9 0x0090"): ' + f'{", ".join(bad_pyocd)}') rtt_no_logger_def = [e['name'] for e in config_boards if e.get('logger') == 'rtt' and any('LOGGER=rtt' not in v['defines'] for v in hil_report.board_variants(e))]
diff --git a/test/hil/pyocd/run_pyocd.py b/test/hil/pyocd/run_pyocd.py new file mode 100644 index 0000000..db6b582 --- /dev/null +++ b/test/hil/pyocd/run_pyocd.py
@@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Run pyocd for the HIL harness: probe discovery filtered to one USB VID/PID, and +user_script.py loaded as the session's user script. + +Usage: <pyocd's python> run_pyocd.py 0xVVVV 0xPPPP <pyocd args...> + +pyocd has no VID/PID filter: its CMSIS-DAP discovery opens every device whose class could +be CMSIS-DAP to read its interface string, and checks the serial last (pyocd 0.45.1 +pyusb_v2_backend.py HasCmsisDapv2Interface). On a rig one wedged node then hangs every flash, +and each flash sends control requests to other boards' probes and DUTs. pyusb's find() tests +keyword filters before custom_match, from the cached device descriptor, so injecting +idVendor/idProduct keeps every other device unopened. Only the CMSIS-DAP probe plugin is +left registered: the J-Link one enumerates through SEGGER's DLL, where the filter cannot reach. +""" +import functools +import os +import sys +from pathlib import Path + + +def _fail(msg: str): + print(f'run_pyocd.py: {msg}', file=sys.stderr) + sys.exit(2) + + +def _filter_vid_pid(find, vid: int, pid: int): + @functools.wraps(find) + def filtered(*args, **kwargs): + for key, want in (('idVendor', vid), ('idProduct', pid)): + if kwargs.setdefault(key, want) != want: + raise RuntimeError(f'run_pyocd.py: a caller asked for {key}={kwargs[key]:#06x}') + return find(*args, **kwargs) + return filtered + + +def main(argv: list) -> int: + if not sys.platform.startswith('linux'): + _fail(f'the VID/PID filter is only verified on Linux, not {sys.platform}') + try: + vid, pid = (int(v, 16) for v in argv[:2]) + except ValueError: + _fail(f'want "0xVVVV 0xPPPP" before the pyocd args, got {argv[:2]}') + # hidapi would bypass pyusb; pyocd reads this when its interface package is imported + os.environ['PYOCD_USB_BACKEND'] = 'pyusb' + # CMSIS-DAP discovery builds a bare Session (pyocd 0.45.1 dap_access_cmsis_dap.py:81) that + # ignores --no-config and loads pyocd.yaml from this dir, else the cwd; this dir holds none. + # pyocd then chdirs here, so relative paths in roster args (--pack...) resolve from here + os.environ['PYOCD_PROJECT_DIR'] = str(Path(__file__).resolve().parent) + + # before any pyocd import: its backends bind `find` at import time + import usb.core + usb.core.find = _filter_vid_pid(usb.core.find, vid, pid) + filtered = [usb.core.find] + try: + import libusb_package + libusb_package.find = _filter_vid_pid(libusb_package.find, vid, pid) + filtered.append(libusb_package.find) + except ImportError: + pass + + from pyocd.probe.pydapaccess.interface import pyusb_backend, pyusb_v2_backend + for mod in (pyusb_backend, pyusb_v2_backend): + if getattr(mod, 'usb_find', None) not in filtered: + _fail(f'{mod.__name__}.usb_find is not the filtered find; pyocd changed how it imports it') + + from pyocd.probe.aggregator import PROBE_CLASSES + if 'cmsisdap' not in PROBE_CLASSES: + _fail(f'pyocd has no cmsisdap probe plugin: {sorted(PROBE_CLASSES)}') + for name in [k for k in PROBE_CLASSES if k != 'cmsisdap']: + del PROBE_CLASSES[name] + + from pyocd.__main__ import main as pyocd_main + # last, so they win over the args (a roster --config included) + sys.argv = ['pyocd', *argv[2:], '--no-config', + '--script', str(Path(__file__).resolve().with_name('user_script.py'))] + return pyocd_main() + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))
diff --git a/test/hil/pyocd/user_script.py b/test/hil/pyocd/user_script.py new file mode 100644 index 0000000..db1d9c1 --- /dev/null +++ b/test/hil/pyocd/user_script.py
@@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MIT +# pyocd user script, loaded by run_pyocd.py for every HIL session. +from pyocd.target.family.target_lpc5500 import DM_AP, CortexM_LPC5500 + + +def set_reset_catch(core, reset_type): + # LPC55: pyocd's reset_and_halt runs a flash-controller blank check before the reset, but + # flash commands need CPU clock <= 100 MHz and prefetch off (UM11126 rev 2.1 5.3, Table 113 + # note 2); under the application's 150 MHz clocks it never completes and the next AHB access + # answers WAIT. A debug-mailbox chip reset first resets SYSCON and the flash controller + # (Table 1058; chip reset + START_DBG_SESSION, 51.6.1). + if isinstance(core, CortexM_LPC5500): + target.unlock(target.aps[DM_AP]) + return False # pyocd's own reset catch either way
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index 35e7a8e..9055eec 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py
@@ -671,6 +671,11 @@ self.assertNotIn('vid_pid', f.get('args', ''), f"{path}: {board['name']} packs vid_pid into args; use the field") + def test_roster_pyocd_entries_filter_vid_pid(self): + # unfiltered, pyocd opens every CMSIS-DAP-class device on the rig (test/hil/pyocd/run_pyocd.py) + for path, board in roster_flashers(): + self.assertFalse(hil_flash.unfiltered_pyocd(board), f"{path}: {board['name']}") + class TestRosterFlashersDispatch(unittest.TestCase): """hil_test and hil_pool_check resolve a board's flasher through @@ -913,10 +918,9 @@ after the same demonstration. frdm_k64f is host-only, so usbtest never marks it; its entry serves a manual reset through the recovery flasher. Left out, and why: stm32f769disco (probe not on the rig to demonstrate), ra4m1_ek (reset-only worked ~80% under openocd, SYSRESETREQ or srst, where JLinkExe resets 5/5; 2026-09-22). - mimxrt1064_evk and lpcxpresso55s28 are reset-only: openocd 0ce743125 has no target cfg and - no flash driver for them (FlexSPI, LPC55), so their entries declare a bare SWD DAP and - Cortex-M target with SYSRESETREQ; 3/3 resets each re-enumerated the DUT (2026-09-22); - lpcxpresso55s28 logs "DP initialisation failed" after each reset yet resets. Same trade as + mimxrt1064_evk is reset-only: openocd 0ce743125 has no target cfg and no flash driver for + it (FlexSPI), so its entry declares a bare SWD DAP and Cortex-M target with SYSRESETREQ; + 3/3 resets each re-enumerated the DUT (2026-09-22). Same trade as nrf54lm20dk below. nrf54lm20dk's reset works (libjaylink 0.5.0 finds its PID 0x1069; 0.4.0 did not) but its reflash cannot: nrf54l.cfg declares no RRAM flash bank. Accepted trade: the entry arms in-run recovery (a reset) where JLinkExe allowed none; a wedge the reset @@ -932,14 +936,12 @@ demonstrated = {'feather_nrf52840_express', 'metro_m4_express', 'stm32f072disco', 'stm32f407disco', 'stm32f723disco', 'stm32l476disco', 'lpcxpresso11u37', 'ea4088_quickstart', 'nrf54lm20dk', 'frdm_k64f', 'mimxrt1064_evk', - 'lpcxpresso55s28', 'lpcxpresso43s67'} # hfp.json, demonstrated on tusb - reset_only = {name: ('-f interface/jlink.cfg -c "transport select swd" -c "adapter speed 1000" ' - f'-c "swd newdap {chip} cpu -expected-id 0" ' - f'-c "dap create {chip}.dap -chain-position {chip}.cpu" ' - f'-c "target create {chip}.cpu cortex_m -dap {chip}.dap" ' - '-c "cortex_m reset_config sysresetreq"') - for name, chip in (('mimxrt1064_evk', 'rt1064'), ('lpcxpresso55s28', 'lpc55s28'))} + reset_only = {'mimxrt1064_evk': ('-f interface/jlink.cfg -c "transport select swd" -c "adapter speed 1000" ' + '-c "swd newdap rt1064 cpu -expected-id 0" ' + '-c "dap create rt1064.dap -chain-position rt1064.cpu" ' + '-c "target create rt1064.cpu cortex_m -dap rt1064.dap" ' + '-c "cortex_m reset_config sysresetreq"')} seen = set() for path, board in roster_flashers(): if 'flasher_recover' not in board: @@ -1256,6 +1258,7 @@ 'test/hil/test/test_hil_bounded.py', 'test/hil/test/test_hil_mtp_raw.py', 'test/hil/test/test_hil_pool_check.py', + 'test/hil/test/test_hil_pyocd.py', 'test/hil/test/test_hil_remote.py', 'test/hil/test/test_hil_report.py', 'test/hil/test/test_hil_reset_order.py',
diff --git a/test/hil/test/test_hil_bounded.py b/test/hil/test/test_hil_bounded.py index e4dc568..ab06b9a 100644 --- a/test/hil/test/test_hil_bounded.py +++ b/test/hil/test/test_hil_bounded.py
@@ -394,6 +394,11 @@ for f in sorted(seen): self.assertIn(f'test/hil/{f}', staged, f'{f} runs on the rig but hil_remote.py does not stage it') + # test/hil/pyocd/ runs as pyocd's launcher and its user script, which no import walk sees + scripts = sorted(p.relative_to(hil_dir).as_posix() for p in hil_dir.glob('pyocd/*.py')) + self.assertTrue(scripts, 'test/hil/pyocd/ holds no user script') + for f in scripts: + self.assertIn(f'test/hil/{f}', staged, f'{f} runs under pyocd but hil_remote.py does not stage it') class _MtpFakeRig:
diff --git a/test/hil/test/test_hil_pyocd.py b/test/hil/test/test_hil_pyocd.py new file mode 100644 index 0000000..fbe5b3e --- /dev/null +++ b/test/hil/test/test_hil_pyocd.py
@@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""The pyocd flasher, its VID/PID-filtering launcher (test/hil/pyocd/run_pyocd.py) and its user script. + +The launcher cases run it against fake usb/libusb_package/pyocd packages whose find() applies +keyword filters before custom_match, as pyusb's does. They prove the plumbing only; that the +real pyusb and pyocd open nothing else is the strace evidence from the rig. +""" +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +HIL_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(HIL_DIR)) + +import hil_flash # noqa: E402 + +LAUNCHER = HIL_DIR / 'pyocd' / 'run_pyocd.py' +USER_SCRIPT = HIL_DIR / 'pyocd' / 'user_script.py' +PROBE = [0x1fc9, 0x0090] +OTHER = [0x1366, 0x1024] + +FAKES = { + 'usb/__init__.py': '', + 'usb/core.py': ''' +import json, os +class Device: + def __init__(self, vid, pid): + self.idVendor, self.idProduct = vid, pid +def find(find_all=False, backend=None, custom_match=None, **kwargs): + devs = [Device(*v) for v in json.loads(os.environ['FAKE_USB_DEVICES'])] + found = [d for d in devs if all(getattr(d, k) == v for k, v in kwargs.items()) + and (custom_match is None or custom_match(d))] + return found if find_all else (found[0] if found else None) +''', + 'pyocd/__init__.py': '', + 'pyocd/probe/__init__.py': '', + 'pyocd/probe/aggregator.py': "PROBE_CLASSES = {'cmsisdap': 1, 'jlink': 2, 'stlink': 3}\n", + 'pyocd/probe/pydapaccess/__init__.py': '', + 'pyocd/probe/pydapaccess/interface/__init__.py': '', + 'pyocd/__main__.py': ''' +import json, os, sys +def main(): + from pyocd.probe.aggregator import PROBE_CLASSES + from pyocd.probe.pydapaccess.interface import pyusb_backend, pyusb_v2_backend + touched = [] + for mod in (pyusb_backend, pyusb_v2_backend): + mod.usb_find(find_all=True, custom_match=lambda d: touched.append([d.idVendor, d.idProduct])) + print(json.dumps({'touched': touched, 'classes': sorted(PROBE_CLASSES), + 'backend': os.environ.get('PYOCD_USB_BACKEND'), + 'project_dir': os.environ.get('PYOCD_PROJECT_DIR'), 'argv': sys.argv[1:]})) + sys.exit(0) +''', +} +BACKEND = ''' +import os +if os.environ.get('FAKE_BIND_ELSEWHERE'): + usb_find = lambda *a, **k: [] +else: + try: + from libusb_package import find as usb_find + except ImportError: + from usb.core import find as usb_find +''' +LIBUSB_PACKAGE = ''' +def find(*args, **kwargs): + import usb.core + return usb.core.find(*args, **kwargs) +''' + + +@unittest.skipUnless(sys.platform.startswith('linux'), 'the launcher refuses other platforms') +class Launcher(unittest.TestCase): + def setUp(self): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + self.root = Path(td.name) + files = {**FAKES, 'pyocd/probe/pydapaccess/interface/pyusb_backend.py': BACKEND, + 'pyocd/probe/pydapaccess/interface/pyusb_v2_backend.py': BACKEND} + for rel, body in files.items(): + (self.root / 'fakes' / rel).parent.mkdir(parents=True, exist_ok=True) + (self.root / 'fakes' / rel).write_text(body) + # an interpreter may have the real libusb_package; the shadow makes it absent + for where, body in (('libusb', LIBUSB_PACKAGE), ('nolibusb', "raise ImportError('absent')\n")): + (self.root / where / 'libusb_package').mkdir(parents=True) + (self.root / where / 'libusb_package' / '__init__.py').write_text(body) + + def run_launcher(self, *args, libusb_package=True, **env): + path = [str(self.root / 'fakes'), str(self.root / ('libusb' if libusb_package else 'nolibusb'))] + env = {**os.environ, 'PYTHONPATH': os.pathsep.join(path), 'PYOCD_USB_BACKEND': 'hidapiusb', + 'FAKE_USB_DEVICES': json.dumps([OTHER, PROBE, [0x0483, 0x3752]]), **env} + return subprocess.run([sys.executable, str(LAUNCHER), *args], env=env, + capture_output=True, text=True, timeout=30) + + def test_only_the_filtered_probe_reaches_pyocds_matcher(self): + for libusb_package in (True, False): + with self.subTest(libusb_package=libusb_package): + r = self.run_launcher('0x1fc9', '0x0090', 'flash', '-u', 'cmsisdap:X', '--script', 'roster.py', + 'fw.elf', libusb_package=libusb_package) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(r.stdout), { + 'touched': [PROBE, PROBE], # once per backend, nothing else + 'classes': ['cmsisdap'], + 'backend': 'pyusb', # hidapi would bypass the filter + 'project_dir': str(LAUNCHER.parent), # not the caller's cwd + # argparse keeps the last --script + 'argv': ['flash', '-u', 'cmsisdap:X', '--script', 'roster.py', 'fw.elf', + '--no-config', '--script', str(USER_SCRIPT)]}) + + def test_the_project_dir_holds_no_pyocd_config(self): + # discovery's bare Session loads one from there despite --no-config + for name in ('pyocd.yaml', 'pyocd.yml', '.pyocd.yaml', '.pyocd.yml'): + self.assertFalse((LAUNCHER.parent / name).exists(), name) + + def test_a_backend_that_did_not_bind_the_filtered_find_stops_before_discovery(self): + r = self.run_launcher('0x1fc9', '0x0090', 'flash', 'fw.elf', FAKE_BIND_ELSEWHERE='1') + self.assertEqual((r.returncode, r.stdout), (2, '')) + self.assertIn('is not the filtered find', r.stderr) + + def test_a_malformed_vid_pid_stops_before_pyocd(self): + for args in (('flash', 'fw.elf'), ('0x1fc9',), ()): + r = self.run_launcher(*args) + self.assertEqual((r.returncode, r.stdout), (2, ''), args) + + def test_a_caller_asking_for_another_vid_is_refused(self): + import importlib.util + spec = importlib.util.spec_from_file_location('run_pyocd', LAUNCHER) + run_pyocd = importlib.util.module_from_spec(spec) + spec.loader.exec_module(run_pyocd) + find = run_pyocd._filter_vid_pid(lambda **kw: kw, 0x1fc9, 0x0090) + self.assertEqual(find(find_all=True), {'find_all': True, 'idVendor': 0x1fc9, 'idProduct': 0x0090}) + with self.assertRaises(RuntimeError): + find(idVendor=0x1366) + + +class Flasher(unittest.TestCase): + FLASHER = {'name': 'pyocd', 'uid': 'GSA0CQEQ', 'vid_pid': '0x1fc9 0x0090', + 'args': '-t lpc55s28'} + + def capture(self, fn, *args, **kw): + seen = {} + + def fake(cmd, **k): + seen['cmd'], seen['kw'] = cmd, k + return subprocess.CompletedProcess(cmd, 0, b'', b'') + with mock.patch.object(hil_flash.hil_util, 'run_cmd', fake), \ + mock.patch.object(hil_flash, '_pyocd_python', return_value='/py'): + fn({'name': 'b', 'flasher': self.FLASHER}, *args, **kw) + return seen['cmd'], seen['kw'] + + def test_flash_and_reset_run_the_launcher(self): + head = ['/py', str(hil_flash.hil_util.TINYUSB_ROOT / 'test/hil/pyocd/run_pyocd.py'), '0x1fc9', '0x0090'] + tail = ['-W', '-u', 'cmsisdap:GSA0CQEQ', '-t', 'lpc55s28'] + for fn, fw, verb in ((hil_flash.flash_pyocd, ('/tmp/a b/fw.elf',), 'flash'), + (hil_flash.reset_pyocd, (), 'reset')): + cmd, kw = self.capture(fn, *fw, timeout=7) + self.assertEqual(cmd, [*head, verb, *tail, *fw]) + self.assertEqual(kw, {'timeout': 7}) + + def test_an_unfiltered_entry_is_refused(self): + for bad in ({'vid_pid': None}, {'vid_pid': '0x1fc9 0x0090 0x1fc9 0x0143'}, + {'vid_pid': '1fc9:0090'}, {'uid': ''}): + flasher = {**self.FLASHER, **bad} + self.assertIsNone(hil_flash.pyocd_vid_pid(flasher), bad) + self.assertFalse(hil_flash.convoy_safe(flasher), bad) + with self.assertRaises(ValueError): + hil_flash._pyocd_argv(flasher, 'reset') + self.assertTrue(hil_flash.convoy_safe(self.FLASHER)) + + def test_an_unfiltered_primary_or_recovery_pyocd_entry_is_found(self): + jlink = {'name': 'jlink', 'uid': 'X'} + for board, want in (({'flasher': self.FLASHER}, False), + ({'flasher': {**self.FLASHER, 'name': 'PyOCD', 'vid_pid': None}}, True), + ({'flasher': jlink, 'flasher_recover': {'name': 'pyocd'}}, True), + ({'flasher': jlink}, False)): + self.assertEqual(hil_flash.unfiltered_pyocd(board), want, board) + + def test_the_interpreter_comes_from_pyocds_shebang(self): + with tempfile.TemporaryDirectory() as td: + exe = Path(td) / 'pyocd' + for shebang, want in ((f'#!{sys.executable}', sys.executable), + ('#!/usr/bin/env python3', None), ('#!/bin/sh', None), ('', None)): + exe.write_text(f'{shebang}\nimport sys\n') + exe.chmod(0o755) + with mock.patch.dict(os.environ, {'PATH': td}): + if want: + self.assertEqual(hil_flash._pyocd_python(), want) + else: + self.assertRaises(RuntimeError, hil_flash._pyocd_python) + exe.unlink() + with mock.patch.dict(os.environ, {'PATH': td}): + self.assertRaises(RuntimeError, hil_flash._pyocd_python) + + def test_hil_test_refuses_an_unfiltered_entry_before_touching_a_board(self): + with tempfile.TemporaryDirectory() as td: + cfg = Path(td) / 'c.json' + cfg.write_text(json.dumps({'boards': [{'name': 'b', 'uid': 'X', 'tests': {'device': True}, + 'flasher': {'name': 'pyocd', 'uid': 'S'}}]})) + r = subprocess.run([sys.executable, str(HIL_DIR / 'hil_test.py'), str(cfg)], + env={**os.environ, 'HIL_REPORT_DIR': td}, + capture_output=True, text=True, timeout=60) + self.assertEqual(r.returncode, 1) + self.assertIn('a pyocd flasher needs a uid and one "vid_pid" pair', r.stdout) + + +class UserScript(unittest.TestCase): + """set_reset_catch as pyocd calls it: the script's globals hold `target`, the delegate's + falsy return keeps pyocd's own reset catch.""" + def load(self): + lpc5500 = mock.MagicMock() + lpc5500.DM_AP = 2 + lpc5500.CortexM_LPC5500 = type('CortexM_LPC5500', (), {}) + target = mock.MagicMock() + target.aps = {0: 'ahb', 2: 'dm'} + ns = {'target': target} + with mock.patch.dict(sys.modules, {'pyocd': mock.MagicMock(), 'pyocd.target': mock.MagicMock(), + 'pyocd.target.family': mock.MagicMock(), + 'pyocd.target.family.target_lpc5500': lpc5500}): + exec(compile(USER_SCRIPT.read_text(), str(USER_SCRIPT), 'exec'), ns) + return ns, target, lpc5500.CortexM_LPC5500 + + def test_an_lpc55_core_is_unlocked_through_the_debug_mailbox_first(self): + ns, target, lpc55 = self.load() + self.assertFalse(ns['set_reset_catch'](lpc55(), 'sw')) + target.unlock.assert_called_once_with('dm') + + def test_any_other_core_is_left_to_pyocd(self): + ns, target, _ = self.load() + self.assertFalse(ns['set_reset_catch'](object(), 'sw')) + target.unlock.assert_not_called() + + +if __name__ == '__main__': + unittest.main()
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json index edbaa73..137e397 100644 --- a/test/hil/tinyusb.json +++ b/test/hil/tinyusb.json
@@ -272,13 +272,10 @@ "dual": false }, "flasher": { - "name": "jlink", - "uid": "000727031389", - "args": "-device LPC55S28" - }, - "flasher_recover": { - "name": "openocd", - "args": "-f interface/jlink.cfg -c \"transport select swd\" -c \"adapter speed 1000\" -c \"swd newdap lpc55s28 cpu -expected-id 0\" -c \"dap create lpc55s28.dap -chain-position lpc55s28.cpu\" -c \"target create lpc55s28.cpu cortex_m -dap lpc55s28.dap\" -c \"cortex_m reset_config sysresetreq\"" + "name": "pyocd", + "uid": "GSA0CQEQ", + "vid_pid": "0x1fc9 0x0090", + "args": "-t lpc55s28" } }, {