import enum
import os
import shutil
import time
import attr
from labgrid.factory import target_factory
from labgrid.step import step
from labgrid.strategy import Strategy, StrategyError
from ._compat import never_retry
def _as_bool(value):
"""Coerce a YAML/tag value to bool.
Place tags render to strings (``''`` when unset), so the ``sd_autoboot``
attr must accept ``"true"``/``"1"``/``"yes"``/``"on"`` (any case) as True
and empty/``"false"``/``None`` as False, while still honouring a real
bool passed programmatically.
"""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in ("1", "true", "yes", "on")
[docs]
class Status(enum.Enum):
"""Boot strategy state machine states for TFTP-based boot."""
unknown = 0
powered_off = 1
update_boot_files = 2
booting = 3
# Optional JTAG-bootstrap step (xsdb-loads ps7_init + bitstream +
# FSBL + U-Boot ELF into DDR) so the strategy works on SDs that
# don't carry a working FSBL/U-Boot. Falls through to booting when
# the JTAG bindings/inputs aren't supplied — backwards-compatible.
jtag_bootstrap = 4
booted = 5
shell = 6
soft_off = 7
[docs]
@target_factory.reg_driver
@attr.s(eq=False)
class BootFPGASoCTFTP(Strategy):
"""Strategy to boot an FPGA SoC device using ShellDriver and TFTP.
This strategy manages the boot process of an FPGA SoC device by utilizing
both the ShellDriver for initial boot interactions and TFTP for kernel loading.
It depends on a `TFTPServerResource` to provide the server IP address.
It handles transitions through various states including powering off, booting,
updating boot files, and entering a shell.
When ``ethaddr`` is set (non-empty), the interactive U-Boot TFTP path
issues ``setenv ethaddr <value>`` BEFORE ``dhcp`` so the lease is
requested with a stable MAC (stock Kuiper boot files randomize the
MAC every boot). This applies ONLY to the interactive TFTP path:
``sd_autoboot`` boards boot with U-Boot's own environment, so set
``ethaddr`` in the SD card's ``uEnv.txt`` instead.
"""
bindings = {
"power": "PowerProtocol",
"shell": "ADIShellDriver",
"ssh": {"SSHDriver", None},
"kuiper": {"KuiperDLDriver", "CloudsmithDLDriver", None},
# Optional JTAG bootstrap. When provided AND ps7_init_tcl /
# uboot_elf are set, the strategy xsdb-loads U-Boot into DDR
# before waiting for the autoboot banner. Useful when the
# board's SD doesn't carry a working FSBL/U-Boot.
"jtag": {"XilinxJTAGDriver", None},
"tftp_server": "TFTPServerResource",
"tftp_driver": "TFTPServerDriver",
}
status = attr.ib(default=Status.unknown)
reached_linux_marker = attr.ib(default="analog")
wait_for_linux_prompt_timeout = attr.ib(default=60)
# How long to wait for U-Boot's "Hit any key to stop autoboot"
# banner after power-on. Hardcoded 30 s previously — too tight
# on slow SD or when the FPGA's FSBL takes a moment to hand off.
wait_for_autoboot_prompt_timeout = attr.ib(default=60)
# On a zero-byte autoboot-prompt timeout (board silent on UART),
# power-cycle and retry this many times before raising. Same
# pattern the ``BootFPGASoC`` strategy uses for its
# kernel-banner expect.
autoboot_banner_retries = attr.ib(default=1)
tftp_root_folder = attr.ib(default="/var/lib/tftpboot")
# Memory addresses for boot components
kernel_addr = attr.ib(default="0x30000000")
dtb_addr = attr.ib(default="0x2A000000")
# bitstream_addr = attr.ib(default="0x80000000") # Unused currently but good to have
bootargs = attr.ib(
default="console=ttyPS0,115200 root=/dev/mmcblk0p2 rw earlycon earlyprintk rootfstype=ext4 rootwait"
)
# U-Boot / platform-specific overrides. Defaults target ZynqMP
# (ZCU102); Zynq-7000 (ZC706) and similar platforms override these.
uboot_prompt = attr.ib(default="ZynqMP>.*")
kernel_image_name = attr.ib(default="Image")
dtb_image_name = attr.ib(default="system.dtb")
# ``booti`` is arm64 (ZynqMP); ``bootm`` is arm32 (Zynq-7000); ``bootz``
# for raw zImage. The command is passed as
# ``<boot_cmd> <kernel_addr> - <dtb_addr>`` unless overridden.
boot_cmd = attr.ib(default="booti")
# ---------- Optional JTAG bootstrap inputs ----------------------
# All paths are on the host that runs xsdb (the lab host bound to
# the XilinxVivadoTool resource). Bootstrap is enabled only when
# the ``jtag`` driver is bound AND both ``ps7_init_tcl`` and
# ``uboot_elf`` are set; otherwise the strategy falls through to
# the legacy "SD-bootable" path.
ps7_init_tcl = attr.ib(default=None)
uboot_elf = attr.ib(default=None)
fsbl_elf = attr.ib(default=None)
bitstream_path = attr.ib(default=None)
a9_target_name = attr.ib(default="*Cortex-A9 MPCore #0")
jtag_bootstrap_retries = attr.ib(default=2)
# SD-autoboot mode. When True, the JTAG bootstrap loads U-Boot into
# DDR and then the board's own SD card (which already carries a
# bootable Kuiper kernel+rootfs) is allowed to autoboot straight to
# Linux — the TFTP-kernel path is skipped entirely. This serves
# JTAG-recovery-class boards (no SD mux, SD already imaged) whose
# only missing piece is a working on-SD FSBL/U-Boot. Opt in via the
# place tag ``sd-autoboot``; requires the JTAG bootstrap inputs.
sd_autoboot = attr.ib(default=False, converter=_as_bool)
# Stable MAC for the interactive TFTP path. Non-empty → the strategy
# runs `setenv ethaddr <value>` before `dhcp` so the DHCP lease (and
# therefore the DUT's address) is predictable per place. Empty (the
# default / `ethaddr=stock` opt-out) leaves the board's own — on
# stock Kuiper, per-boot random — MAC untouched. Has no effect on
# sd_autoboot boards (set ethaddr in the SD's uEnv.txt instead).
ethaddr = attr.ib(default="", validator=attr.validators.instance_of(str))
def _require(self, name: str) -> str:
"""Fetch a required attr or raise StrategyError naming the field."""
value = getattr(self, name)
if not value:
raise StrategyError(f"BootFPGASoCTFTP requires '{name}' to be configured")
return value
def _jtag_bootstrap_enabled(self) -> bool:
"""JTAG bootstrap is opt-in: needs both the driver and the minimal
bootstrap inputs (PS7 init TCL + U-Boot ELF). Bitstream and FSBL
are optional refinements that ``load_zynq_uboot`` handles."""
return bool(self.jtag) and bool(self.ps7_init_tcl) and bool(self.uboot_elf)
def _boot_via_sd_autoboot(self):
"""Let the JTAG-bootstrapped U-Boot autoboot the SD's own Kuiper.
On ``sd_autoboot`` boards the SD already carries a bootable
kernel+rootfs, so once U-Boot is in DDR it autoboots straight to
Linux with no chance to drop to the U-Boot prompt. We therefore
skip the autoboot-interrupt + TFTP-kernel sequence and simply
wait for the kernel ``Linux`` banner and the Linux login marker.
"""
self.logger.info(
"sd_autoboot: U-Boot will autoboot the SD's Kuiper; waiting for "
"kernel 'Linux' banner then '%s'...",
self.reached_linux_marker,
)
self.shell.console.expect("Linux", timeout=self.wait_for_linux_prompt_timeout)
self.shell.console.expect(
self.reached_linux_marker, timeout=self.wait_for_linux_prompt_timeout
)
self.shell.bypass_login = False
self.target.deactivate(self.shell)
self.logger.info("Device booted successfully via SD autoboot")
def __attrs_post_init__(self):
super().__attrs_post_init__()
self.logger.info("BootFPGASoCTFTP strategy initialized")
if self.tftp_driver:
self.tftp_root_folder = self.tftp_driver.resource.root
self.logger.info(f"Using managed TFTP server with root: {self.tftp_root_folder}")
if self.kuiper:
self.target.activate(self.kuiper)
self.logger.info("KuiperDLDriver activated")
self.kuiper.get_boot_files_from_release()
self.target.deactivate(self.kuiper)
[docs]
@never_retry
@step()
def transition(self, status, *, step):
if not isinstance(status, Status):
status = Status[status]
self.logger.info(f"Transitioning to {status} (Existing status: {self.status})")
if status == Status.unknown:
raise StrategyError(f"can not transition to {status}")
if status == self.status:
step.skip("nothing to do")
return
if status == Status.powered_off:
self.target.deactivate(self.shell)
if self.tftp_driver:
self.target.deactivate(self.tftp_driver)
if self.jtag:
try:
self.target.deactivate(self.jtag)
except Exception: # noqa: BLE001 — best-effort cleanup
pass
if self.power:
self.target.activate(self.power)
self.power.off()
self.logger.info("Device powered off")
elif status == Status.update_boot_files:
self.transition(Status.powered_off)
if self.tftp_driver:
self.target.activate(self.tftp_driver)
if not self.kuiper:
self.logger.warning("No KuiperDLDriver attached, skipping boot file update check")
else:
self.logger.info(f"Preparing TFTP boot files in {self.tftp_root_folder}...")
for boot_file in self.kuiper._boot_files:
self.logger.info(f"Copying {os.path.basename(boot_file)} to TFTP root...")
if not os.path.exists(boot_file):
raise StrategyError(f"Boot file {boot_file} does not exist")
target = os.path.join(self.tftp_root_folder, os.path.basename(boot_file))
shutil.copyfile(boot_file, target)
self.logger.info("TFTP boot files prepared successfully")
elif status == Status.booting:
self.transition(Status.update_boot_files)
if self.power:
self.target.activate(self.power)
# Explicit off → settle → on cycle clears any residual
# board state left by the previous test / workflow run.
# Mirror of the ``BootFPGASoC`` pre-emptive cold-cycle
# fix — without it the first ``power.on()`` can leave
# the board in a latched state where FSBL doesn't run
# and the UART is completely silent until another
# power-cycle.
self.logger.info("Cold-cycling power to clear residual board state...")
self.power.off()
time.sleep(5)
self.power.on()
self.logger.info("Device powered on, booting...")
elif status == Status.jtag_bootstrap:
# Pull the board to "powered on", then (when configured)
# xsdb-load U-Boot into DDR before the autoboot wait.
self.transition(Status.booting)
if not self._jtag_bootstrap_enabled():
self.logger.info(
"JTAG bootstrap not configured (no `jtag` driver or "
"missing ps7_init_tcl/uboot_elf) — relying on the SD's "
"own FSBL/U-Boot"
)
else:
self.target.activate(self.jtag)
ps7 = self._require("ps7_init_tcl")
uboot = self._require("uboot_elf")
attempts = int(self.jtag_bootstrap_retries) + 1
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
self.logger.info(f"JTAG bootstrap attempt {attempt}/{attempts}...")
self.jtag.load_zynq_uboot(
ps7_init_tcl=ps7,
uboot_elf=uboot,
a9_target_name=self.a9_target_name,
bitstream_path=self.bitstream_path,
fsbl_elf=self.fsbl_elf,
)
break
except Exception as e:
last_error = e
self.logger.error(
f"JTAG bootstrap attempt {attempt}/{attempts} failed: {e}"
)
if attempt >= attempts:
raise StrategyError(f"JTAG bootstrap exhausted retries: {e}") from e
self.logger.info("Cold-cycling power before retry...")
self.target.activate(self.power)
self.power.off()
time.sleep(5)
self.power.on()
else: # pragma: no cover - loop always breaks or raises
raise StrategyError(f"JTAG bootstrap exhausted retries: {last_error}")
self.logger.info("U-Boot bootstrapped via JTAG")
elif status == Status.booted:
self.transition(Status.jtag_bootstrap)
self.shell.bypass_login = True
self.target.activate(self.shell)
if self.sd_autoboot:
# SD-autoboot boards skip the TFTP-kernel dance entirely:
# the JTAG-loaded U-Boot autoboots the SD's Kuiper.
self._boot_via_sd_autoboot()
self.status = status
return
# Wait for U-Boot's autoboot prompt. Retry once on a
# zero-byte silence — the same flake mode that
# ``BootFPGASoC`` hits on SD-mux boards also hits TFTP
# boards occasionally, and one more cold-cycle is enough
# to clear it.
attempt = 0
max_attempts = int(self.autoboot_banner_retries) + 1
# Index of which pattern matched on success. Used below to
# decide whether we still need to interrupt autoboot.
# 0 -> "Hit any key to stop autoboot" (classic prompting
# U-Boot — send a key to stop autoboot)
# 1 -> the U-Boot prompt itself (autoboot ran, failed its
# configured tftpboot, and dropped to the prompt —
# no interrupt needed; we're already there)
autoboot_idx = -1
while True:
attempt += 1
try:
self.logger.info(
"Waiting for U-Boot autoboot prompt or '%s' prompt...",
self.uboot_prompt,
)
autoboot_idx = self.shell.console.expect(
["Hit any key to stop autoboot", self.uboot_prompt],
timeout=self.wait_for_autoboot_prompt_timeout,
)
break
except Exception as e:
captured = b""
try:
captured = self.shell.console._expect.before or b""
except Exception:
pass
self.logger.error(
"Attempt %d/%d: no autoboot prompt within %ss (%d bytes captured).",
attempt,
max_attempts,
self.wait_for_autoboot_prompt_timeout,
len(captured),
)
if captured:
self.logger.error("Captured UART tail: %r", captured[-400:])
# Only retry on zero-byte silence. If the board
# produced output, another power-cycle won't help.
if attempt >= max_attempts or len(captured) > 0 or self.power is None:
raise e
self.logger.info("Power-cycling the board and re-attempting the autoboot wait.")
self.target.deactivate(self.shell)
self.target.activate(self.power)
self.power.off()
time.sleep(5)
self.power.on()
self.logger.info("Device re-powered, booting...")
self.shell.bypass_login = True
self.target.activate(self.shell)
if autoboot_idx == 0:
self.logger.info("Stopping autoboot...")
self.shell.console.sendline(" ")
time.sleep(2)
else:
# U-Boots that autoboot silently and drop back to the
# prompt on a failed bootcmd (e.g. when their stale env
# points DHCP/TFTP at a different network) skip the
# "Hit any key" banner entirely. We're already at the
# prompt — no key to send.
self.logger.info(
"Reached U-Boot prompt directly (no autoboot banner); skipping interrupt"
)
org_prompt = self.shell.prompt
# Temporarily set prompt to U-Boot prompt match
self.shell.prompt = self.uboot_prompt
self.shell.console.sendline("\n")
self.shell._check_prompt_uboot()
# U-Boot commands configuration
commands = [
"setenv autoload no",
# Stable MAC must be in effect BEFORE dhcp so the lease is
# requested with it (stock Kuiper randomizes the MAC every
# boot → unpinnable leases). Empty ethaddr = leave the
# board's own MAC alone.
*([f"setenv ethaddr {self.ethaddr}"] if self.ethaddr else []),
"dhcp",
f"setenv serverip {self.tftp_server.get_ip()}",
f"setenv tftpdstport {self.tftp_driver.resource.port}",
f"setenv tftpport {self.tftp_driver.resource.port}",
"printenv tftpdstport",
f"ping {self.tftp_server.get_ip()}",
# Default bootargs if not set
f"setenv bootargs {self.bootargs}",
f"tftpboot {self.kernel_addr} {self.kernel_image_name}",
f"tftpboot {self.dtb_addr} {self.dtb_image_name}",
]
self.logger.info("Configuring U-Boot for TFTP boot...")
for cmd in commands:
self.logger.info(f"U-Boot: {cmd}")
self.shell.run_uboot(f"{cmd}\n", timeout=60) # Increased timeout for TFTP
self.shell._check_prompt_uboot()
# Boot the kernel; the command does not return control to U-Boot.
self.logger.info(f"Starting kernel execution ({self.boot_cmd})...")
self.shell.console.sendline(f"{self.boot_cmd} {self.kernel_addr} - {self.dtb_addr}")
# Check if we reached Linux prompt
self.logger.info(f"Waiting for Linux boot and '{self.reached_linux_marker}' prompt...")
self.shell.prompt = org_prompt
self.shell.console.expect(
self.reached_linux_marker, timeout=self.wait_for_linux_prompt_timeout
)
self.shell.bypass_login = False
self.target.deactivate(self.shell)
self.logger.info("Device booted successfully via TFTP")
elif status == Status.shell:
self.transition(Status.booted)
self.logger.info("Preparing interactive shell...")
self.target.activate(self.shell)
self.logger.info("Shell access ready")
elif status == Status.soft_off:
self.transition(Status.shell)
try:
self.logger.info("Triggering soft power off...")
self.shell.run("poweroff")
self.shell.console.expect("Power down", timeout=30)
self.target.deactivate(self.shell)
time.sleep(10)
except Exception as e:
self.logger.debug(f"Soft off failed: {e}")
time.sleep(5)
self.target.deactivate(self.shell)
if self.power:
self.target.activate(self.power)
self.power.off()
self.logger.info("Device powered off")
else:
raise StrategyError(f"no transition found from {self.status} to {status}")
self.status = status