| # Copyright 2026 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| |
| """Utilities for flashing Zephyr devices using West runners.""" |
| |
| import argparse |
| import logging |
| import os |
| from pathlib import Path |
| import sys |
| import yaml |
| import serial |
| import serial.tools.list_ports |
| from python.runfiles import runfiles |
| |
| try: |
| import pylink |
| except ImportError: |
| pylink = None |
| |
| _LOG = logging.getLogger(__name__) |
| |
| def setup_tool_paths(r): |
| """Locate J-Link and OpenOCD and add them to PATH if found.""" |
| extra_paths = [] |
| |
| # 1. J-Link |
| jlink_path = os.environ.get("ZEPHYR_JLINK_PATH") |
| if jlink_path: |
| if os.path.isdir(jlink_path): |
| extra_paths.append(jlink_path) |
| elif os.path.isfile(jlink_path): |
| extra_paths.append(os.path.dirname(jlink_path)) |
| else: |
| # Search standard locations |
| standard_jlink_dirs = [ |
| Path("/opt/SEGGER/JLink"), |
| Path("/usr/bin"), |
| Path("/usr/local/bin"), |
| ] |
| # Also check for versioned dirs in /opt/SEGGER |
| if os.path.exists("/opt/SEGGER"): |
| opt_segger = Path("/opt/SEGGER") |
| for entry in opt_segger.iterdir(): |
| if os.path.isdir(entry) and entry.name.startswith("JLink"): |
| standard_jlink_dirs.append(entry) |
| |
| for d in standard_jlink_dirs: |
| if os.path.exists(d / "JLinkExe") or os.path.exists(d / "JLink.exe"): |
| extra_paths.append(str(d)) |
| break |
| |
| # 2. OpenOCD |
| openocd_path = os.environ.get("ZEPHYR_OPENOCD_PATH") |
| if openocd_path: |
| if os.path.isdir(openocd_path): |
| extra_paths.append(openocd_path) |
| elif os.path.isfile(openocd_path): |
| extra_paths.append(os.path.dirname(openocd_path)) |
| else: |
| # We can also check if we packaged it, but for now fallback to PATH |
| pass |
| |
| if extra_paths: |
| path_env = os.environ.get("PATH", "") |
| # Prepend to PATH so they take precedence |
| os.environ["PATH"] = os.pathsep.join(extra_paths + [path_env] if path_env else extra_paths) |
| |
| def auto_detect_device(serial_port=None, serial_number=None): |
| """Auto-detects missing serial port or serial number. |
| |
| If both are provided, returns them as is. If one is missing, attempts to |
| resolve the other. If both are missing, attempts to pair a single connected |
| J-Link probe with a single CDC serial port. |
| |
| Args: |
| serial_port: Provided serial port, or None. |
| serial_number: Provided serial number, or None. |
| |
| Returns: |
| A tuple of (serial_port, serial_number), or (None, None) if |
| auto-detection fails. |
| """ |
| if serial_port and serial_number: |
| return serial_port, serial_number |
| |
| probes = [] |
| if pylink is not None: |
| try: |
| jlink = pylink.JLink() |
| emulators = jlink.connected_emulators() |
| probes = [str(info.SerialNumber) for info in emulators] |
| except Exception as e: |
| print(f"Warning: Failed to query J-Link probes: {e}", file=sys.stderr) |
| else: |
| print("Warning: Failed to query J-Link probes: pylink module not available", file=sys.stderr) |
| |
| ports = serial.tools.list_ports.comports() |
| |
| _LOG.info("Detected serial ports:") |
| for p in ports: |
| _LOG.info(f" {p.device}: serial_number={p.serial_number}, hwid={p.hwid}, desc={p.description}") |
| _LOG.info("Detected J-Link probes: %s", probes) |
| |
| if serial_number and not serial_port: |
| for p in ports: |
| if p.serial_number and (p.serial_number == serial_number or serial_number.endswith(p.serial_number) or p.serial_number.endswith(serial_number)): |
| return p.device, serial_number |
| raise RuntimeError(f"Could not find serial port for device {serial_number}") |
| |
| if serial_port and not serial_number: |
| for p in ports: |
| if p.device == serial_port: |
| if p.serial_number: |
| return serial_port, p.serial_number |
| sanitized_port = serial_port.replace("/", "_") |
| return serial_port, sanitized_port |
| raise RuntimeError(f"Serial port {serial_port} not found on host") |
| |
| if len(probes) == 1: |
| sn = probes[0] |
| for p in ports: |
| if p.serial_number and (p.serial_number == sn or sn.endswith(p.serial_number) or p.serial_number.endswith(sn)): |
| return p.device, sn |
| cdc_ports = [p for p in ports if "ACM" in p.device or "USB" in p.device] |
| if len(cdc_ports) == 1: |
| return cdc_ports[0].device, sn |
| |
| if len(probes) > 1: |
| raise RuntimeError(f"Multiple J-Link probes detected ({probes}). Please specify --serial-number explicitly.") |
| if len(ports) > 0: |
| cdc_ports = [p for p in ports if "ACM" in p.device or "USB" in p.device] |
| if len(cdc_ports) == 1: |
| p = cdc_ports[0] |
| sn = p.serial_number or p.device.replace("/", "_") |
| return p.device, sn |
| |
| return None, None |
| |
| def monitor_serial(serial_port, baud_rate=115200): |
| """Monitors a serial port until KeyboardInterrupt.""" |
| print(f"--- Monitoring serial port {serial_port} at {baud_rate} baud ---") |
| print("Press Ctrl+C to stop.") |
| try: |
| ser = serial.Serial(serial_port, baud_rate, timeout=1.0) |
| try: |
| ser.dtr = True |
| ser.rts = True |
| except IOError: |
| pass |
| |
| while True: |
| try: |
| data = ser.read(ser.in_waiting or 1) |
| if data: |
| sys.stdout.write(data.decode("utf-8", errors="replace")) |
| sys.stdout.flush() |
| except (serial.SerialException, OSError) as e: |
| print(f"\nSerial read error: {e}. Stopping.", file=sys.stderr) |
| break |
| except serial.SerialException as e: |
| print(f"Failed to open serial port {serial_port}: {e}", file=sys.stderr) |
| return 1 |
| except KeyboardInterrupt: |
| print("\nMonitoring stopped by user.") |
| finally: |
| if 'ser' in locals() and ser.is_open: |
| ser.close() |
| return 0 |
| |
| def flash_main(yaml_path, binary_path=None, monitor=False, serial_port=None, baud_rate=115200, serial_number=None, extra_args=[]): |
| """Flashes a Zephyr binary using runners.yaml configuration. |
| |
| Args: |
| yaml_path: Path to the runners.yaml file (rlocation or path). |
| binary_path: Optional path to the binary to flash (overrides yaml). |
| monitor: Whether to monitor the serial port after flashing. |
| serial_port: Serial port to monitor. |
| baud_rate: Baud rate for serial port. |
| serial_number: Serial number of the board. |
| extra_args: Extra arguments to pass to the flash runner. |
| |
| Returns: |
| 0 on success, non-zero on failure. |
| """ |
| r = runfiles.Create() |
| if not r: |
| raise RuntimeError("Failed to initialize runfiles") |
| |
| resolved_yaml_path = r.Rlocation(yaml_path) |
| if resolved_yaml_path: |
| yaml_file = Path(resolved_yaml_path) |
| else: |
| yaml_file = Path(yaml_path) |
| if not os.path.exists(yaml_file): |
| raise RuntimeError(f"Failed to locate runners.yaml: {yaml_path}") |
| |
| with open(yaml_file, 'r') as f: |
| config = yaml.safe_load(f) |
| |
| # Setup sys.path for Zephyr scripts |
| runner_core_path = r.Rlocation("zephyr/scripts/west_commands/runners/core.py") |
| if not runner_core_path: |
| raise RuntimeError("Failed to locate Zephyr runner scripts in runfiles. " |
| "Ensure @zephyr//scripts:west_runners_scripts is in data.") |
| |
| west_commands_dir = Path(runner_core_path).parent.parent |
| if str(west_commands_dir) not in sys.path: |
| sys.path.insert(0, str(west_commands_dir)) |
| |
| import runners.core |
| import runners |
| |
| cfg = config['config'] |
| board_dir = cfg.get('board_dir') |
| if board_dir: |
| resolved_board_dir = r.Rlocation(board_dir) |
| if not resolved_board_dir: |
| resolved_board_dir = r.Rlocation("zephyr/" + board_dir) |
| if resolved_board_dir: |
| cfg['board_dir'] = resolved_board_dir |
| |
| for key in ['elf_file', 'hex_file', 'bin_file']: |
| if key in cfg and cfg[key]: |
| abs_path = r.Rlocation(cfg[key]) |
| if not abs_path: |
| abs_path = str(Path(cfg[key]).resolve()) |
| cfg[key] = abs_path |
| |
| setup_tool_paths(r) |
| |
| flash_runner = config.get('flash-runner') |
| if not flash_runner: |
| if binary_path: |
| abs_binary = r.Rlocation(binary_path) |
| if not abs_binary: |
| abs_binary = str(Path(binary_path).resolve()) |
| if abs_binary and os.path.exists(abs_binary): |
| try: |
| os.execv(abs_binary, [abs_binary] + extra_args) |
| except OSError as e: |
| if e.errno == 8: |
| print(f"Error: Binary {binary_path} is not host-executable. " |
| f"No flash runner is configured for this board.", file=sys.stderr) |
| else: |
| print(f"Failed to execute binary: {e}", file=sys.stderr) |
| return 1 |
| return 0 |
| else: |
| print(f"No flash runner configured, and binary {binary_path} not found.", file=sys.stderr) |
| return 1 |
| else: |
| print("No flash runner configured, and no binary path provided.", file=sys.stderr) |
| return 1 |
| |
| runner_args = config.get('args', {}).get(flash_runner, []) |
| runner_args = list(runner_args) |
| runner_args.extend(extra_args) |
| |
| try: |
| __import__('runners.' + flash_runner) |
| except ImportError: |
| pass |
| |
| runner_cls = runners.get_runner_cls(flash_runner) |
| |
| build_dir = str(yaml_file.parent) |
| runner_config = runners.core.RunnerConfig( |
| build_dir = build_dir, |
| board_dir = cfg.get('board_dir'), |
| elf_file = cfg.get('elf_file'), |
| exe_file = cfg.get('exe_file'), |
| hex_file = cfg.get('hex_file'), |
| bin_file = cfg.get('bin_file'), |
| uf2_file = cfg.get('uf2_file'), |
| mot_file = cfg.get('mot_file'), |
| file = cfg.get('file'), |
| ) |
| |
| parser = argparse.ArgumentParser() |
| runner_cls.add_parser(parser) |
| parsed_args, unknown = parser.parse_known_args(runner_args) |
| if unknown: |
| print(f"Warning: unknown arguments for {flash_runner}: {unknown}") |
| |
| runner = runner_cls.create(runner_config, parsed_args) |
| |
| try: |
| runner.run('flash') |
| except Exception as e: |
| print(f"Flashing failed: {e}") |
| return 1 |
| |
| if monitor: |
| if not serial_port or not serial_number: |
| try: |
| serial_port, serial_number = auto_detect_device(serial_port, serial_number) |
| except RuntimeError as e: |
| print(f"Error: {e}", file=sys.stderr) |
| return 1 |
| |
| if not serial_port: |
| print("Serial port not specified and auto-detection failed. Cannot monitor.", file=sys.stderr) |
| return 1 |
| |
| print(f"Using device: Serial={serial_number}, Port={serial_port}") |
| return monitor_serial(serial_port, baud_rate) |
| |
| return 0 |
| |
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--yaml", required=True) |
| parser.add_argument("--binary", default=None) |
| parser.add_argument("--monitor", action="store_true", help="Monitor serial port after flashing.") |
| parser.add_argument("--serial-port", default=None, help="Serial port to monitor.") |
| parser.add_argument("--baud-rate", type=int, default=115200, help="Baud rate for serial port.") |
| parser.add_argument("--serial-number", default=None, help="Serial number of the board.") |
| args, unknown = parser.parse_known_args() |
| sys.exit(flash_main( |
| yaml_path=args.yaml, |
| binary_path=args.binary, |
| monitor=args.monitor, |
| serial_port=args.serial_port, |
| baud_rate=args.baud_rate, |
| serial_number=args.serial_number, |
| extra_args=unknown |
| )) |