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"
             }
         },
         {