west: add NXP S32 Debug Probe runner

The NXP S32 Debug Probe is a JTAG-based probe that enables debugging on
NXP S32 devices. This probe is designed to work in conjunction with NXP
S32 Design Studio and this runner offers a wrapper to launch a debug
session from cli.

`flash` command is not implemented at the moment because presently there
are no zephyr boards that can make use of it and test it.

Signed-off-by: Manuel Argüelles <manuel.arguelles@nxp.com>
diff --git a/boards/common/nxp_s32dbg.board.cmake b/boards/common/nxp_s32dbg.board.cmake
new file mode 100644
index 0000000..edd49ea
--- /dev/null
+++ b/boards/common/nxp_s32dbg.board.cmake
@@ -0,0 +1,6 @@
+# Copyright 2023 NXP
+# SPDX-License-Identifier: Apache-2.0
+
+board_set_flasher_ifnset(nxp_s32dbg)
+board_set_debugger_ifnset(nxp_s32dbg)
+board_finalize_runner_args(nxp_s32dbg)
diff --git a/scripts/west_commands/runners/__init__.py b/scripts/west_commands/runners/__init__.py
index f4340bc..850efcd 100644
--- a/scripts/west_commands/runners/__init__.py
+++ b/scripts/west_commands/runners/__init__.py
@@ -45,6 +45,7 @@
     'nrfjprog',
     'nrfutil',
     'nsim',
+    'nxp_s32dbg',
     'openocd',
     'pyocd',
     'qemu',
diff --git a/scripts/west_commands/runners/nxp_s32dbg.py b/scripts/west_commands/runners/nxp_s32dbg.py
new file mode 100644
index 0000000..e2065f3
--- /dev/null
+++ b/scripts/west_commands/runners/nxp_s32dbg.py
@@ -0,0 +1,334 @@
+# Copyright 2023 NXP
+# SPDX-License-Identifier: Apache-2.0
+"""
+Runner for NXP S32 Debug Probe.
+"""
+
+import argparse
+import os
+import platform
+import re
+import shlex
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Optional, Union
+
+from runners.core import (BuildConfiguration, RunnerCaps, RunnerConfig,
+                          ZephyrBinaryRunner)
+
+NXP_S32DBG_USB_CLASS = 'NXP Probes'
+NXP_S32DBG_USB_VID = 0x15a2
+NXP_S32DBG_USB_PID = 0x0067
+
+
+@dataclass
+class NXPS32DebugProbeConfig:
+    """NXP S32 Debug Probe configuration parameters."""
+    conn_str: str = 's32dbg'
+    server_port: int = 45000
+    speed: int = 16000
+    remote_timeout: int = 30
+    reset_type: Optional[str] = 'default'
+    reset_delay: int = 0
+
+
+class NXPS32DebugProbeRunner(ZephyrBinaryRunner):
+    """Runner front-end for NXP S32 Debug Probe."""
+
+    def __init__(self,
+                 runner_cfg: RunnerConfig,
+                 probe_cfg: NXPS32DebugProbeConfig,
+                 core_name: str,
+                 soc_name: str,
+                 soc_family_name: str,
+                 start_all_cores: bool,
+                 s32ds_path: Optional[str] = None,
+                 tool_opt: Optional[List[str]] = None) -> None:
+        super(NXPS32DebugProbeRunner, self).__init__(runner_cfg)
+        self.elf_file: str = runner_cfg.elf_file or ''
+        self.probe_cfg: NXPS32DebugProbeConfig = probe_cfg
+        self.core_name: str = core_name
+        self.soc_name: str = soc_name
+        self.soc_family_name: str = soc_family_name
+        self.start_all_cores: bool = start_all_cores
+        self.s32ds_path_override: Optional[str] = s32ds_path
+
+        self.tool_opt: List[str] = []
+        if tool_opt:
+            for opt in tool_opt:
+                self.tool_opt.extend(shlex.split(opt))
+
+        build_cfg = BuildConfiguration(runner_cfg.build_dir)
+        self.arch = build_cfg.get('CONFIG_ARCH').replace('"', '')
+
+    @classmethod
+    def name(cls) -> str:
+        return 'nxp_s32dbg'
+
+    @classmethod
+    def capabilities(cls) -> RunnerCaps:
+        return RunnerCaps(commands={'debug', 'debugserver', 'attach'},
+                          dev_id=True, tool_opt=True)
+
+    @classmethod
+    def dev_id_help(cls) -> str:
+        return '''Debug probe connection string as in "s32dbg[:<address>]"
+                  where <address> can be the IP address if TAP is available via Ethernet,
+                  the serial ID of the probe or empty if TAP is available via USB.'''
+
+    @classmethod
+    def tool_opt_help(cls) -> str:
+        return '''Additional options for GDB client when used with "debug" or "attach" commands
+                  or for GTA server when used with "debugserver" command.'''
+
+    @classmethod
+    def do_add_parser(cls, parser: argparse.ArgumentParser) -> None:
+        parser.add_argument('--core-name',
+                            required=True,
+                            help='Core name as supported by the debug probe (e.g. "R52_0_0")')
+        parser.add_argument('--soc-name',
+                            required=True,
+                            help='SoC name as supported by the debug probe (e.g. "S32Z270")')
+        parser.add_argument('--soc-family-name',
+                            required=True,
+                            help='SoC family name as supported by the debug probe (e.g. "s32z2e2")')
+        parser.add_argument('--start-all-cores',
+                            action='store_true',
+                            help='Start all SoC cores and not just the one being debugged. '
+                                 'Use together with "debug" command.')
+        parser.add_argument('--s32ds-path',
+                            help='Override the path to NXP S32 Design Studio installation. '
+                                 'By default, this runner will try to obtain it from the system '
+                                 'path, if available.')
+        parser.add_argument('--server-port',
+                            default=NXPS32DebugProbeConfig.server_port,
+                            type=int,
+                            help='GTA server port')
+        parser.add_argument('--speed',
+                            default=NXPS32DebugProbeConfig.speed,
+                            type=int,
+                            help='JTAG interface speed')
+        parser.add_argument('--remote-timeout',
+                            default=NXPS32DebugProbeConfig.remote_timeout,
+                            type=int,
+                            help='Number of seconds to wait for the remote target responses')
+
+    @classmethod
+    def do_create(cls, cfg: RunnerConfig, args: argparse.Namespace) -> 'NXPS32DebugProbeRunner':
+        probe_cfg = NXPS32DebugProbeConfig(args.dev_id,
+                                           server_port=args.server_port,
+                                           speed=args.speed,
+                                           remote_timeout=args.remote_timeout)
+
+        return NXPS32DebugProbeRunner(cfg, probe_cfg, args.core_name, args.soc_name,
+                                      args.soc_family_name, args.start_all_cores,
+                                      s32ds_path=args.s32ds_path, tool_opt=args.tool_opt)
+
+    @staticmethod
+    def find_usb_probes() -> List[str]:
+        """Return a list of debug probe serial numbers connected via USB to this host."""
+        # use system's native commands to enumerate and retrieve the USB serial ID
+        # to avoid bloating this runner with third-party dependencies that often
+        # require priviledged permissions to access the device info
+        macaddr_pattern = r'(?:[0-9a-f]{2}[:]){5}[0-9a-f]{2}'
+        if platform.system() == 'Windows':
+            cmd = f'pnputil /enum-devices /connected /class "{NXP_S32DBG_USB_CLASS}"'
+            serialid_pattern = f'instance id: +usb\\\\.*\\\\({macaddr_pattern})'
+        else:
+            cmd = f'lsusb -v -d {NXP_S32DBG_USB_VID:x}:{NXP_S32DBG_USB_PID:x}'
+            serialid_pattern = f'iserial +.*({macaddr_pattern})'
+
+        try:
+            outb = subprocess.check_output(shlex.split(cmd), stderr=subprocess.DEVNULL)
+            out = outb.decode('utf-8').strip().lower()
+        except subprocess.CalledProcessError:
+            raise RuntimeError('error while looking for debug probes connected')
+
+        devices: List[str] = []
+        if out and 'no devices were found' not in out:
+            devices = re.findall(serialid_pattern, out)
+
+        return sorted(devices)
+
+    @classmethod
+    def select_probe(cls) -> str:
+        """
+        Find debugger probes connected and return the serial number of the one selected.
+
+        If there are multiple debugger probes connected and this runner is being executed
+        in a interactive prompt, ask the user to select one of the probes.
+        """
+        probes_snr = cls.find_usb_probes()
+        if not probes_snr:
+            raise RuntimeError('there are no debug probes connected')
+        elif len(probes_snr) == 1:
+            return probes_snr[0]
+        else:
+            if not sys.stdin.isatty():
+                raise RuntimeError(
+                    f'refusing to guess which of {len(probes_snr)} connected probes to use '
+                    '(Interactive prompts disabled since standard input is not a terminal). '
+                    'Please specify a device ID on the command line.')
+
+            print('There are multiple debug probes connected')
+            for i, probe in enumerate(probes_snr, 1):
+                print(f'{i}. {probe}')
+
+            prompt = f'Please select one with desired serial number (1-{len(probes_snr)}): '
+            while True:
+                try:
+                    value: int = int(input(prompt))
+                except EOFError:
+                    sys.exit(0)
+                except ValueError:
+                    continue
+                if 1 <= value <= len(probes_snr):
+                    break
+            return probes_snr[value - 1]
+
+    @property
+    def runtime_environment(self) -> Optional[Dict[str, str]]:
+        """Execution environment used for the client process."""
+        if platform.system() == 'Windows':
+            python_lib = (self.s32ds_path / 'S32DS' / 'build_tools' / 'msys32'
+                        / 'mingw32' / 'lib' / 'python2.7')
+            return {
+                **os.environ,
+                'PYTHONPATH': f'{python_lib}{os.pathsep}{python_lib / "site-packages"}'
+            }
+
+        return None
+
+    @property
+    def script_globals(self) -> Dict[str, Optional[Union[str, int]]]:
+        """Global variables required by the debugger scripts."""
+        return {
+            '_PROBE_IP': self.probe_cfg.conn_str,
+            '_JTAG_SPEED': self.probe_cfg.speed,
+            '_GDB_SERVER_PORT': self.probe_cfg.server_port,
+            '_RESET_TYPE': self.probe_cfg.reset_type,
+            '_RESET_DELAY': self.probe_cfg.reset_delay,
+            '_REMOTE_TIMEOUT': self.probe_cfg.remote_timeout,
+            '_CORE_NAME': f'{self.soc_name}_{self.core_name}',
+            '_SOC_NAME': self.soc_name,
+            '_IS_LOGGING_ENABLED': False,
+            '_FLASH_NAME': None,    # not supported
+            '_SECURE_TYPE': None,   # not supported
+            '_SECURE_KEY': None,    # not supported
+        }
+
+    def server_commands(self) -> List[str]:
+        """Get launch commands to start the GTA server."""
+        server_exec = str(self.s32ds_path / 'S32DS' / 'tools' / 'S32Debugger'
+                          / 'Debugger' / 'Server' / 'gta' / 'gta')
+        cmd = [server_exec, '-p', str(self.probe_cfg.server_port)]
+        return cmd
+
+    def client_commands(self) -> List[str]:
+        """Get launch commands to start the GDB client."""
+        if self.arch == 'arm':
+            client_exec_name = 'arm-none-eabi-gdb-py'
+        elif self.arch == 'arm64':
+            client_exec_name = 'aarch64-none-elf-gdb-py'
+        else:
+            raise RuntimeError(f'architecture {self.arch} not supported')
+
+        client_exec = str(self.s32ds_path / 'S32DS' / 'tools' / 'gdb-arm'
+                          / 'arm32-eabi' / 'bin' / client_exec_name)
+        cmd = [client_exec]
+        return cmd
+
+    def get_script(self, name: str) -> Path:
+        """
+        Get the file path of a debugger script with the given name.
+
+        :param name: name of the script, without the SoC family name prefix
+        :returns: path to the script
+        :raises RuntimeError: if file does not exist
+        """
+        script = (self.s32ds_path / 'S32DS' / 'tools' / 'S32Debugger' / 'Debugger' / 'scripts'
+                  / self.soc_family_name / f'{self.soc_family_name}_{name}.py')
+        if not script.exists():
+            raise RuntimeError(f'script not found: {script}')
+        return script
+
+    def do_run(self, command: str, **kwargs) -> None:
+        """
+        Execute the given command.
+
+        :param command: command name to execute
+        :raises RuntimeError: if target architecture or host OS is not supported
+        :raises MissingProgram: if required tools are not found in the host
+        """
+        if platform.system() not in ('Windows', 'Linux'):
+            raise RuntimeError(f'runner not supported on {platform.system()} systems')
+
+        if self.arch not in ('arm', 'arm64'):
+            raise RuntimeError(f'architecture {self.arch} not supported')
+
+        app_name = 's32ds' if platform.system() == 'Windows' else 's32ds.sh'
+        self.s32ds_path = Path(self.require(app_name, path=self.s32ds_path_override)).parent
+
+        if not self.probe_cfg.conn_str:
+            self.probe_cfg.conn_str = f's32dbg:{self.select_probe()}'
+            self.logger.info(f'using debug probe {self.probe_cfg.conn_str}')
+
+        if command in ('attach', 'debug'):
+            self.ensure_output('elf')
+            self.do_attach_debug(command, **kwargs)
+        else:
+            self.do_debugserver(**kwargs)
+
+    def do_attach_debug(self, command: str, **kwargs) -> None:
+        """
+        Launch the GTA server and GDB client to start a debugging session.
+
+        :param command: command name to execute
+        """
+        gdb_script: List[str] = []
+
+        # setup global variables required for the scripts before sourcing them
+        for name, val in self.script_globals.items():
+            gdb_script.append(f'py {name} = {repr(val)}')
+
+        # load platform-specific debugger script
+        if command == 'debug':
+            if self.start_all_cores:
+                startup_script = self.get_script('generic_bareboard_all_cores')
+            else:
+                startup_script = self.get_script('generic_bareboard')
+        else:
+            startup_script = self.get_script('attach')
+        gdb_script.append(f'source {startup_script}')
+
+        # executes the SoC and board initialization sequence
+        if command == 'debug':
+            gdb_script.append('py board_init()')
+
+        # initializes the debugger connection to the core specified
+        gdb_script.append('py core_init()')
+
+        gdb_script.append(f'file {Path(self.elf_file).as_posix()}')
+        if command == 'debug':
+            gdb_script.append('load')
+
+        with tempfile.TemporaryDirectory(suffix='nxp_s32dbg') as tmpdir:
+            gdb_cmds = Path(tmpdir) / 'runner.nxp_s32dbg'
+            gdb_cmds.write_text('\n'.join(gdb_script), encoding='utf-8')
+            self.logger.debug(gdb_cmds.read_text(encoding='utf-8'))
+
+            server_cmd = self.server_commands()
+            client_cmd = self.client_commands()
+            client_cmd.extend(['-x', gdb_cmds.as_posix()])
+            client_cmd.extend(self.tool_opt)
+
+            self.run_server_and_client(server_cmd, client_cmd, env=self.runtime_environment)
+
+    def do_debugserver(self, **kwargs) -> None:
+        """Start the GTA server on a given port with the given extra parameters from cli."""
+        server_cmd = self.server_commands()
+        server_cmd.extend(self.tool_opt)
+        self.check_call(server_cmd)
diff --git a/scripts/west_commands/tests/test_imports.py b/scripts/west_commands/tests/test_imports.py
index 774d4f7..274840f 100644
--- a/scripts/west_commands/tests/test_imports.py
+++ b/scripts/west_commands/tests/test_imports.py
@@ -35,6 +35,7 @@
                     'nios2',
                     'nrfjprog',
                     'nrfutil',
+                    'nxp_s32dbg',
                     'openocd',
                     'pyocd',
                     'qemu',