Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions adi_lg_plugins/drivers/_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

import hashlib
import os
import socket
import subprocess

from labgrid.resource.common import NetworkResource
Expand All @@ -37,6 +38,33 @@

_STAGE_ROOT = "/tmp/adi-lg-stage"

#: host -> resolvable form, memoized (DNS probes are slow and per-run stable).
_RESOLVED_HOSTS: dict[str, str] = {}


def _resolvable_host(host: str) -> str:
"""Return ``host`` in a form the local resolver can actually resolve.

The coordinator names exporter hosts by bare name (e.g. ``tron``); on many
lab networks only the mDNS ``<name>.local`` form resolves. Mirror
``hw_ci.all_places.host_reachable``: keep the bare name when it resolves,
fall back to ``<name>.local`` when only that resolves, and otherwise return
the input unchanged so the eventual ssh error names the real host.
"""
cached = _RESOLVED_HOSTS.get(host)
if cached is not None:
return cached
resolved = host
for candidate in (host, f"{host}.local"):
try:
socket.getaddrinfo(candidate, None)
resolved = candidate
break
except OSError:
continue
_RESOLVED_HOSTS[host] = resolved
return resolved


def _sha256_file(path: str) -> str:
h = hashlib.sha256()
Expand Down Expand Up @@ -78,10 +106,11 @@ def _exporter_host(res):
"""
host = getattr(res, "host", None)
if host:
return host
return _resolvable_host(host)
extra = getattr(res, "extra", None) or {}
if isinstance(extra, dict):
return extra.get("proxy")
proxy = extra.get("proxy")
return _resolvable_host(proxy) if proxy else None
return None

def _remote_prefix(self):
Expand Down
3 changes: 3 additions & 0 deletions adi_lg_plugins/hw_ci/all_places.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"BootFPGASoCSSH",
"BootTickFPGASSH",
"BootRPI",
# JTAG-strapped ZynqMP production boot (ADRV9009-ZU11EG on tron):
# confirmed to boot Kuiper and serve iiod (validated 2026-08-04).
"BootZynqMPJTAG",
}
)

Expand Down
13 changes: 13 additions & 0 deletions adi_lg_plugins/hw_ci/render_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,19 @@ def render_env(
"firmware_elf": place.extra_tags.get("firmware-elf", ""),
"boot_marker": place.extra_tags.get("boot-marker", "Successfully initialized"),
"a9_target_name": place.extra_tags.get("a9-target-name", "*Cortex-A9 MPCore #0"),
# BootZynqMPJTAG (JTAG-strapped ZynqMP production boot): boot payloads
# live at canonical names under `recovery-root` on the exporter host
# (see the template header); serial overrides handle ser2net/pyserial
# rfc2217 quirks and coordinator-advertised hosts that don't resolve
# from the runner. Lab admin opts a place in, e.g.:
# labgrid-client -p tron set-tags boot-strategy=BootZynqMPJTAG \
# recovery-root=/home/tcollins/zu11eg-recovery \
# bitstream-path=/home/tcollins/zu11eg-recovery/bitstream.bin \
# serial-protocol-override=raw
"recovery_root": place.extra_tags.get("recovery-root", "/tmp/recovery"),
"jtag_url": place.extra_tags.get("jtag-url", "TCP:127.0.0.1:3121"),
"serial_host_override": place.extra_tags.get("serial-host-override", ""),
"serial_protocol_override": place.extra_tags.get("serial-protocol-override", ""),
}
if extra_subs:
subs.update({str(k): str(v) for k, v in extra_subs.items()})
Expand Down
108 changes: 70 additions & 38 deletions adi_lg_plugins/hw_ci/templates/BootZynqMPJTAG.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
# Auto-generated by adi_lg_plugins.hw_ci.render_env for place ${place_name}.
# carrier=${carrier} daughter-board=${daughter_board} hdl-config=${hdl_config}
#
# BootZynqMPJTAG: JTAG boot for UltraScale+ (ZynqMP) boards via the Xilinx
# "mini" U-Boot SPL (EL3, no ATF / no PMU-FW). The DUT is brought up over
# JTAG far enough to own the SD host controller; useful for re-flashing
# broken SD cards. DCC/JTAG-UART console is captured via xsdb.
# BootZynqMPJTAG: production boot for JTAG-strapped UltraScale+ (ZynqMP)
# boards — e.g. the ADRV9009-ZU11EG on ADRV2CRR-FMC (place `tron`), whose
# mode straps are fixed to JTAG. xsdb wakes the PMU ROM with the production
# PMUFW payload, optionally programs the PL, and enters production U-Boot
# through BL31 + the XLNX handoff; the strategy then drives U-Boot's SD
# path into a Kuiper shell. transition("shell") (the fleet-wide
# booted-Linux alias for this strategy's kuiper_shell) therefore works with
# the generic hardware-request flow.
#
# Caller must set psu_init_tcl + spl_elf (and optional bitstream_path) to
# paths readable by the host that runs xsdb. See
# examples/ultrascale_jtag_boot/ for how to build the mini SPL.
# Boot payloads live under the `recovery-root` place tag (default
# /tmp/recovery) at CANONICAL names on the exporter host that runs xsdb:
# psu_init.tcl spl.elf pmufw.bin u-boot.bin handoff.bin bl31.bin
# atf-handoff.bin pm-config.bin ddr-scrub.elf
# Extract/build them with examples/ultrascale_jtag_boot/
# (prepare-production-boot.py + build-mini-uboot.sh + build-recovery-uboot.sh).
# The PL bitstream is per-place via the `bitstream-path` tag (empty = skip).

imports:
- adi_lg_plugins
Expand All @@ -30,43 +38,67 @@ targets:
XilinxJTAGDriver: {}
SerialDriver: {}
ADIShellDriver:
prompt: 'root@[^:]+:[^ ]+[#] '
login_prompt: 'login:'
prompt: 'root@.*[#$$]'
login_prompt: '(analog|zu11eg-recovery) login: ?'
username: 'root'
password: 'analog'
BootZynqMPJTAG:
# Paths on the exporter host that runs xsdb (fill in per bench).
psu_init_tcl: '/tmp/recovery/psu_init.tcl'
spl_elf: '/tmp/recovery/u-boot-spl'
# Optional production handoff after repairing the SD. Uncomment all
# required payloads before transition("production_boot").
# pmufw_bin: '/tmp/recovery/production/pmufw.bin'
# uboot_bin: '/tmp/recovery/production/u-boot.bin'
# bl31_bin: '/tmp/recovery/production/bl31.bin'
# atf_handoff_bin: '/tmp/recovery/production/atf-handoff.bin'
# pm_config_bin: '/tmp/recovery/production/pm-config-object.bin'
# ddr_scrub_elf: '/tmp/recovery/ddr-ecc-scrub.elf'
# Direct-JTAG RAM recovery Linux (transition "recovery_linux"):
# recovery_trampoline_elf: '/tmp/recovery/el3-to-el2.elf'
# recovery_kernel_image: '/tmp/recovery/Image-recovery'
# recovery_initramfs: '/tmp/recovery/initramfs.cpio.gz'
# recovery_dtb: '/tmp/recovery/system-recovery.dtb'
# Recovery uses its low-bank scrubber and board-specific SD reset.
# recovery_ddr_scrub_elf: '/tmp/recovery/ddr-ecc-scrub-low-2g.elf'
# JTAG bootstrap inputs (paths on the host that runs xsdb).
psu_init_tcl: '${recovery_root}/psu_init.tcl'
spl_elf: '${recovery_root}/spl.elf'
# Production handoff payloads (see prepare-production-boot.py).
pmufw_bin: '${recovery_root}/pmufw.bin'
uboot_bin: '${recovery_root}/u-boot.bin'
handoff_bin: '${recovery_root}/handoff.bin'
bl31_bin: '${recovery_root}/bl31.bin'
atf_handoff_bin: '${recovery_root}/atf-handoff.bin'
pm_config_bin: '${recovery_root}/pm-config.bin'
# BL31 in the ADI production image logs on UART0 even when the
# external console is UART1 — initialize it (generic ZynqMP UART0 /
# CRL_APB register addresses).
bl31_console_uart_base: '0xFF000000'
bl31_console_ref_ctrl_address: '0xFF5E0074'
bl31_console_reset_mask: '0x2'
# Optional PL bitstream, programmed at the xsdb `fpga` step before
# psu_init (`bitstream-path` place tag; raw .bit works).
bitstream_path: '${bitstream_path}'
# Full-bank DDR ECC scrubber (psu_init enables ECC uninitialized).
ddr_scrub_elf: '${recovery_root}/ddr-scrub.elf'
ddr_scrub_done_address: '0xFFFC0054'
ddr_scrub_settle_ms: 120000
# 0x380E releases A53 #0 only (generated ZU11EG value); 0x0 = all A53s.
apu_release_rst_value: '0x380E'
a53_target_name: '*Cortex-A53*#0*'
# xsdb runs on the exporter via RemoteExecMixin, so the hw_server
# endpoint is exporter-local; override with the `jtag-url` tag.
jtag_url: '${jtag_url}'
# Serial quirks (per-place tags; empty = labgrid defaults). ser2net
# benches whose rfc2217 hangs pyserial's 'purge' negotiation set
# `serial-protocol-override=raw`; benches whose coordinator-advertised
# serial host doesn't resolve from the runner set
# `serial-host-override=<host>.local`.
serial_host_override: '${serial_host_override}'
serial_protocol_override: '${serial_protocol_override}'
# Capture the mini-SPL DCC console (headless).
dcc_log_path: '/tmp/zynqmp-mini-dcc.log'
spl_settle_ms: 12000
production_settle_ms: 15000
pmufw_timeout_ms: 10000
power_off_settle_s: 5
power_on_settle_s: 8
# Direct-JTAG RAM recovery Linux (transition "recovery_linux") and the
# destructive SD reflash are opt-in — uncomment and stage the extra
# payloads before using those states.
# recovery_trampoline_elf: '${recovery_root}/el3-to-el2.elf'
# recovery_kernel_image: '${recovery_root}/Image-recovery'
# recovery_initramfs: '${recovery_root}/initramfs.cpio.gz'
# recovery_dtb: '${recovery_root}/system-recovery.dtb'
# recovery_ddr_scrub_elf: '${recovery_root}/ddr-ecc-scrub-low-2g.elf'
# recovery_ddr_scrub_done_address: '0xFFFC002C'
# recovery_bitstream_path: '/tmp/recovery/system_top.bit'
# recovery_bitstream_path: '${recovery_root}/system_top.bit'
# recovery_post_init_mask_writes:
# - ['0xFF5E0238', '0x00000002', '0x00000000']
# Destructive flash state additionally requires sd_image_url,
# sd_image_size, sd_head_sha256, and sd_tail_sha256.
# sd_image_url: 'http://IMAGE_SERVER/kuiper-full.img'
# sd_image_size: 10305404928
# sd_head_sha256: '...'
# sd_tail_sha256: '...'
# Optional PL bitstream, programmed before psu_init.
# bitstream_path: '/tmp/recovery/system_top.bit'
# 0x380E releases A53 #0 only (generated ZU11EG value); 0x0 = all A53s.
apu_release_rst_value: '0x380E'
a53_target_name: '*Cortex-A53*#0*'
# Capture the mini-SPL DCC console (headless).
dcc_log_path: '/tmp/zynqmp-mini-dcc.log'
spl_settle_ms: 12000
4 changes: 3 additions & 1 deletion adi_lg_plugins/request/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ def request(
os.environ["LG_COORDINATOR"] = coord
api = _resolve_api(coord)
try:
match = match_client.get_match(api, part=part, carrier=carrier, bootfile=bootfile, mode=mode)
match = match_client.get_match(
api, part=part, carrier=carrier, bootfile=bootfile, mode=mode
)
except Exception as e:
raise BoardUnavailable(f"Coordinator REST API at {api} is unreachable: {e}") from e
if not match.satisfiable:
Expand Down
4 changes: 3 additions & 1 deletion adi_lg_plugins/strategies/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ def never_retry(func):
def wrapper(self, *args, **kwargs):
broken = getattr(self, "broken", None)
if broken is not None:
raise StrategyError(f"{self.__class__.__name__} is in broken state: {broken}") from broken
raise StrategyError(
f"{self.__class__.__name__} is in broken state: {broken}"
) from broken
try:
return func(self, *args, **kwargs)
except Exception as e:
Expand Down
6 changes: 6 additions & 0 deletions adi_lg_plugins/strategies/bootzynqmpjtag.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ def transition(self, status, *, step):
>>> strategy.transition("jtag_bootstrap")
"""
if not isinstance(status, Status):
# Fleet-wide convention: "shell" means the booted-Linux shell.
# This strategy's Linux state is kuiper_shell; alias it so the
# generic hardware-request flow (strategy.transition("shell"))
# can boot JTAG-strapped ZynqMP boards like any other place.
if status == "shell":
status = "kuiper_shell"
status = Status[status]

# Validate required inputs up-front, before @never_retry mutates any
Expand Down
10 changes: 10 additions & 0 deletions tests/hw_ci/test_all_places.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,13 @@ def fake_conn(addr, timeout=None):

monkeypatch.setattr(ap.socket, "create_connection", fake_conn)
assert host_reachable("nuc") is False


def test_bootzynqmpjtag_place_becomes_a_uri_leg():
# JTAG-strapped ZynqMP production boot (ADRV9009-ZU11EG on tron) boots
# Kuiper and serves iiod, so it must get a real boot-verify leg, not a
# reserve-only reachability check.
legs, _, _ = build_all_places_matrix(
[_place("tron", "adrv9009zu11eg", "adrv2crr-fmc", strategy="BootZynqMPJTAG")]
)
assert legs[0].mode == "uri"
37 changes: 37 additions & 0 deletions tests/hw_ci/test_render_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,40 @@ def test_every_template_declares_place_features():
assert feats == ["ad9081", "zcu102"], (
f"{strat}: rendered env missing place features (got {feats!r})"
)


# ── BootZynqMPJTAG: daily-bootable production config from place tags ──


def test_bootzynqmpjtag_renders_recovery_root_and_serial_overrides():
p = _place(
"BootZynqMPJTAG",
place="tron",
extra_tags={
"recovery-root": "/home/tcollins/zu11eg-recovery",
"bitstream-path": "/home/tcollins/zu11eg-recovery/bitstream.bin",
"serial-protocol-override": "raw",
"serial-host-override": "tron.local",
"power-driver": "HomeAssistantPowerDriver",
},
)
doc = yaml.safe_load(render_env(p))
drivers = doc["targets"]["main"]["drivers"]
assert "HomeAssistantPowerDriver" in drivers
boot = drivers["BootZynqMPJTAG"]
assert boot["psu_init_tcl"] == "/home/tcollins/zu11eg-recovery/psu_init.tcl"
assert boot["pmufw_bin"] == "/home/tcollins/zu11eg-recovery/pmufw.bin"
assert boot["bitstream_path"] == "/home/tcollins/zu11eg-recovery/bitstream.bin"
assert boot["serial_protocol_override"] == "raw"
assert boot["serial_host_override"] == "tron.local"
assert boot["jtag_url"] == "TCP:127.0.0.1:3121"
# The shell prompt survives Template substitution ($$ -> $).
assert drivers["ADIShellDriver"]["prompt"] == "root@.*[#$]"


def test_bootzynqmpjtag_defaults_to_tmp_recovery():
doc = yaml.safe_load(render_env(_place("BootZynqMPJTAG")))
boot = doc["targets"]["main"]["drivers"]["BootZynqMPJTAG"]
assert boot["spl_elf"] == "/tmp/recovery/spl.elf"
assert boot["serial_host_override"] == ""
assert boot["serial_protocol_override"] == ""
10 changes: 10 additions & 0 deletions tests/test_bootzynqmpjtag_strat.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,13 @@ def test_kuiper_shell_runs_explicit_sd_boot_and_runtime_checks():
s.shell._inject_run.assert_called_once()
s.shell.run_check.assert_any_call("ip link show eth0", timeout=s.kuiper_verify_timeout)
assert s.status == Status.kuiper_shell


def test_transition_shell_aliases_kuiper_shell():
"""The generic hardware-request flow transitions to "shell"; for this
strategy that must mean kuiper_shell (not a KeyError on Status["shell"]).
Both spellings hit the same missing-production-inputs validation."""
with pytest.raises(StrategyError):
_make_strategy().transition("kuiper_shell")
with pytest.raises(StrategyError):
_make_strategy().transition("shell")
42 changes: 42 additions & 0 deletions tests/test_remote_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,45 @@ def test_stage_file_network_resource_uses_managedfile(tmp_path):
MF.assert_called_once()
mf_instance.sync_to_resource.assert_called_once()
assert remote == "/var/cache/abc/boot.scr"


# --- mDNS .local fallback --------------------------------------------------


def test_exporter_host_falls_back_to_dot_local(monkeypatch):
"""The coordinator advertises bare exporter names; on mDNS-only lab
networks just ``<name>.local`` resolves (live-found: the nemo runner
cannot resolve bare ``tron``). Mirror hw_ci.all_places.host_reachable."""
_remote._RESOLVED_HOSTS.clear()

def fake_gai(host, *args, **kwargs):
if host == "tron.local":
return [("resolved",)]
raise OSError("no dns")

monkeypatch.setattr(_remote.socket, "getaddrinfo", fake_gai)
d = _FakeDriver(_proxied_resource("tron"))
assert d._exporter_host(d._remote_resource()) == "tron.local"
_remote._RESOLVED_HOSTS.clear()


def test_exporter_host_keeps_bare_name_when_it_resolves(monkeypatch):
_remote._RESOLVED_HOSTS.clear()
monkeypatch.setattr(_remote.socket, "getaddrinfo", lambda *a, **kw: [("resolved",)])
d = _FakeDriver(_proxied_resource("nemo"))
assert d._exporter_host(d._remote_resource()) == "nemo"
_remote._RESOLVED_HOSTS.clear()


def test_exporter_host_unresolvable_returns_input(monkeypatch):
"""A host that resolves in no form passes through unchanged so the
eventual ssh error names the real host, not a mangled one."""
_remote._RESOLVED_HOSTS.clear()

def fail_gai(*args, **kwargs):
raise OSError("no dns")

monkeypatch.setattr(_remote.socket, "getaddrinfo", fail_gai)
d = _FakeDriver(_proxied_resource("ghost"))
assert d._exporter_host(d._remote_resource()) == "ghost"
_remote._RESOLVED_HOSTS.clear()
4 changes: 3 additions & 1 deletion tests/test_request_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@ def fake_run_child(cmd, env):
return 0

monkeypatch.setattr(rc_mod, "_run_child", fake_run_child)
result = CliRunner().invoke(cli, ["request", "--part", "adrv9009", "--no-boot", "--run", "true"])
result = CliRunner().invoke(
cli, ["request", "--part", "adrv9009", "--no-boot", "--run", "true"]
)
assert result.exit_code == 0
assert fake.kwargs["mode"] == "reserve"
assert captured["LG_ENV"] == "/tmp/env.yaml"
Expand Down
Loading
Loading