diff --git a/chipflow/packaging/__init__.py b/chipflow/packaging/__init__.py
index c6d64e9e..6aff2bef 100644
--- a/chipflow/packaging/__init__.py
+++ b/chipflow/packaging/__init__.py
@@ -75,6 +75,13 @@
from .utils import (
load_pinlock,
lock_pins,
+ swap_pins,
+)
+
+# Renderers
+from .render import (
+ render_text,
+ render_svg,
)
# CLI commands
@@ -129,6 +136,10 @@
# Utilities
'load_pinlock',
'lock_pins',
+ 'swap_pins',
+ # Renderers
+ 'render_text',
+ 'render_svg',
# CLI
'PinCommand',
]
diff --git a/chipflow/packaging/commands.py b/chipflow/packaging/commands.py
index 5787f9ae..770df588 100644
--- a/chipflow/packaging/commands.py
+++ b/chipflow/packaging/commands.py
@@ -5,8 +5,10 @@
import inspect
import logging
+from pathlib import Path
-from .utils import lock_pins
+from .utils import lock_pins, swap_pins, load_pinlock
+from .render import render_text, render_svg
logger = logging.getLogger(__name__)
@@ -36,10 +38,30 @@ def build_cli_parser(self, parser):
parser: argparse parser to add subcommands to
"""
assert inspect.getdoc(self.lock) is not None
+ assert inspect.getdoc(self.swap) is not None
+ assert inspect.getdoc(self.show) is not None
action_argument = parser.add_subparsers(dest="action")
+
action_argument.add_parser(
"lock", help=inspect.getdoc(self.lock).splitlines()[0]) # type: ignore
+ swap_parser = action_argument.add_parser(
+ "swap", help=inspect.getdoc(self.swap).splitlines()[0]) # type: ignore
+ swap_parser.add_argument("pin_a", type=int, help="first pin number")
+ swap_parser.add_argument("pin_b", type=int, help="second pin number")
+
+ show_parser = action_argument.add_parser(
+ "show", help=inspect.getdoc(self.show).splitlines()[0]) # type: ignore
+ show_parser.add_argument(
+ "--format", "-f",
+ choices=("text", "svg"), default="text",
+ help="output format (default: text)"
+ )
+ show_parser.add_argument(
+ "--output", "-o", type=Path, default=None,
+ help="write to file instead of stdout"
+ )
+
def run_cli(self, args):
"""
Execute the CLI command.
@@ -50,6 +72,10 @@ def run_cli(self, args):
logger.debug(f"command {args}")
if args.action == "lock":
self.lock()
+ elif args.action == "swap":
+ self.swap(args.pin_a, args.pin_b)
+ elif args.action == "show":
+ self.show(format=args.format, output=args.output)
def lock(self):
"""
@@ -58,3 +84,31 @@ def lock(self):
Will attempt to reuse previous pin positions.
"""
lock_pins(self.config)
+
+ def swap(self, pin_a: int, pin_b: int):
+ """
+ Swap two pin assignments in pins.lock.
+
+ Both pins must currently be allocated to user ports; bringup
+ pins (clock, reset, power, heartbeat, JTAG) are package-defined
+ and cannot be swapped.
+ """
+ swap_pins(pin_a, pin_b)
+
+ def show(self, format: str = "text", output: Path | None = None):
+ """
+ Show the current pin allocation from pins.lock.
+
+ Text format works for any package type. SVG format renders a
+ package layout for perimeter packages (Quad/Block).
+ """
+ lockfile = load_pinlock()
+ if format == "svg":
+ rendered = render_svg(lockfile)
+ else:
+ rendered = render_text(lockfile)
+ if output is not None:
+ output.write_text(rendered)
+ print(f"Wrote {output}")
+ else:
+ print(rendered, end='')
diff --git a/chipflow/packaging/render.py b/chipflow/packaging/render.py
new file mode 100644
index 00000000..6ae775a7
--- /dev/null
+++ b/chipflow/packaging/render.py
@@ -0,0 +1,230 @@
+# SPDX-License-Identifier: BSD-2-Clause
+"""
+Renderers for ``pins.lock`` — text table and SVG layout.
+
+Used by ``chipflow pin show``. The text view works for any package
+type. The SVG view is currently implemented for perimeter-pin packages
+(:class:`QuadPackageDef`, :class:`BlockPackageDef`); other types raise
+:class:`ChipFlowError` and are expected to fall back to the text view.
+"""
+
+from html import escape
+from typing import Dict, Tuple
+
+from .. import ChipFlowError
+from .lockfile import LockFile
+from .standard import BlockPackageDef, QuadPackageDef
+
+
+def _walk_ports(lockfile: LockFile):
+ """Yield (component, interface, port_name, bit, pin, port_desc) for
+ every assigned pin in the lockfile."""
+ for cname, comp in lockfile.port_map.ports.items():
+ for iname, intf in comp.items():
+ for pname, port in intf.items():
+ if port.pins is None:
+ continue
+ for bit, pin in enumerate(port.pins):
+ yield cname, iname, pname, bit, pin, port
+
+
+def _slot_label(cname: str, iname: str, pname: str, bit: int, width: int) -> str:
+ if width > 1:
+ return f"{cname}.{iname}.{pname}[{bit}]"
+ return f"{cname}.{iname}.{pname}"
+
+
+def render_text(lockfile: LockFile) -> str:
+ """Render the pin allocation as a sorted text table."""
+ rows = []
+ for cname, iname, pname, bit, pin, port in _walk_ports(lockfile):
+ label = _slot_label(cname, iname, pname, bit, len(port.pins or []))
+ direction = port.iomodel.get('direction', '')
+ # io.Direction uses 'i'/'o'/'io' as its short value form.
+ dir_str = getattr(direction, 'value', str(direction))
+ rows.append((pin, label, port.type, dir_str))
+
+ def _sortkey(row):
+ p = row[0]
+ return (0, p) if isinstance(p, int) else (1, repr(p))
+
+ rows.sort(key=_sortkey)
+
+ out = ["{:<8} {:<8} {:<5} {}".format("PIN", "TYPE", "DIR", "PORT")]
+ for pin, label, kind, direction in rows:
+ out.append("{:<8} {:<8} {:<5} {}".format(str(pin), kind, direction, label))
+ return "\n".join(out) + "\n"
+
+
+def _pin_to_perimeter_position(pin: int, width: int, height: int) -> Tuple[str, int]:
+ """Map a perimeter pin number to ``(side, slot)``.
+
+ Convention shared by :class:`QuadPackageDef` and
+ :class:`BlockPackageDef`: pin 1 sits at the top of the West edge,
+ numbering proceeds counter-clockwise. ``slot`` is 0-indexed along
+ the side in the direction of numbering (top→bottom on W/E in
+ natural progression, left→right on S, right→left on N).
+ """
+ p = pin
+ if p <= height:
+ return 'W', p - 1
+ p -= height
+ if p <= width:
+ return 'S', p - 1
+ p -= width
+ if p <= height:
+ return 'E', p - 1
+ p -= height
+ return 'N', p - 1
+
+
+def _perimeter_svg(width: int, height: int,
+ pin_labels: Dict[int, str], title: str) -> str:
+ """Render a perimeter package layout as standalone SVG.
+
+ N/S labels are rotated and extend OUTWARD from the package; W/E
+ labels are horizontal and extend outward from the side. Canvas
+ padding scales with the longest label so nothing gets clipped.
+ """
+ pitch = 30
+ # Approximate per-character width for 11px monospace.
+ char_px = 6.6
+ longest_label = max((len(s) for s in pin_labels.values()), default=4)
+ label_run = int(longest_label * char_px) + 16
+
+ # Side margins: enough for horizontal W/E labels + a small gap.
+ pad_x = label_run + 40
+ # Top/bottom margins: enough for rotated N/S labels + title strip.
+ pad_y = label_run + 60
+
+ box_w = width * pitch
+ box_h = height * pitch
+ svg_w = box_w + 2 * pad_x
+ svg_h = box_h + 2 * pad_y
+
+ parts = []
+ parts.append(
+ f'')
+ return '\n'.join(parts) + '\n'
+
+
+def render_svg(lockfile: LockFile) -> str:
+ """Render the pin allocation as SVG.
+
+ Currently supports :class:`QuadPackageDef` and
+ :class:`BlockPackageDef`. Other package types raise
+ :class:`ChipFlowError`.
+ """
+ pkg = lockfile.package.package_type
+ if not isinstance(pkg, (QuadPackageDef, BlockPackageDef)):
+ raise ChipFlowError(
+ f"SVG output is not yet supported for "
+ f"{type(pkg).__name__}. Use --format text."
+ )
+
+ pin_labels: Dict[int, str] = {}
+ for cname, iname, pname, bit, pin, port in _walk_ports(lockfile):
+ if not isinstance(pin, int):
+ continue
+ pin_labels[pin] = _slot_label(
+ cname, iname, pname, bit, len(port.pins or [])
+ )
+
+ title = f"{pkg.name} ({type(pkg).__name__} {pkg.width}×{pkg.height})"
+ return _perimeter_svg(pkg.width, pkg.height, pin_labels, title)
diff --git a/chipflow/packaging/utils.py b/chipflow/packaging/utils.py
index f56dac4f..4ebdf1aa 100644
--- a/chipflow/packaging/utils.py
+++ b/chipflow/packaging/utils.py
@@ -115,3 +115,79 @@ def lock_pins(config: Optional['Config'] = None) -> None:
with open(lockfile, 'w') as f:
f.write(newlock.model_dump_json(indent=2, serialize_as_any=True))
+
+
+def swap_pins(pin_a: int, pin_b: int) -> None:
+ """
+ Swap two pin assignments in the current ``pins.lock``.
+
+ Bringup pins (clock, reset, power, heartbeat, JTAG — everything
+ under the ``_core`` component) are package-defined and cannot be
+ swapped. Both inputs must currently be allocated to user ports of a
+ package that uses integer pin numbers (Quad / Block / Openframe).
+
+ Args:
+ pin_a: First pin number.
+ pin_b: Second pin number.
+
+ Raises:
+ ChipFlowError: ``pins.lock`` is missing or malformed; pins are
+ identical; either pin is not allocated; either pin lives in
+ the bringup ring; or the package uses non-integer pins.
+ """
+ if pin_a == pin_b:
+ raise ChipFlowError(f"Cannot swap pin {pin_a} with itself.")
+
+ chipflow_root = ensure_chipflow_root()
+ lockfile_path = Path(chipflow_root, 'pins.lock')
+ lockfile = load_pinlock()
+
+ def find_slot(pin):
+ for cname, comp in lockfile.port_map.ports.items():
+ for iname, intf in comp.items():
+ for pname, port in intf.items():
+ if port.pins is None:
+ continue
+ for i, p in enumerate(port.pins):
+ if not isinstance(p, int):
+ raise ChipFlowError(
+ "swap is currently only supported for "
+ "packages with integer pin numbers "
+ "(Quad / Block / Openframe). This "
+ f"lockfile uses pins of type "
+ f"{type(p).__name__}."
+ )
+ if p == pin:
+ return cname, iname, pname, i
+ return None
+
+ loc_a = find_slot(pin_a)
+ loc_b = find_slot(pin_b)
+
+ if loc_a is None:
+ raise ChipFlowError(f"Pin {pin_a} is not allocated in pins.lock.")
+ if loc_b is None:
+ raise ChipFlowError(f"Pin {pin_b} is not allocated in pins.lock.")
+
+ for loc, pin in ((loc_a, pin_a), (loc_b, pin_b)):
+ if loc[0] == "_core":
+ raise ChipFlowError(
+ f"Pin {pin} is a bringup pin ({loc[1]}.{loc[2]}); "
+ "bringup pins are package-defined and cannot be swapped."
+ )
+
+ ca, ia, na, idx_a = loc_a
+ cb, ib, nb, idx_b = loc_b
+ port_a = lockfile.port_map.ports[ca][ia][na]
+ port_b = lockfile.port_map.ports[cb][ib][nb]
+ assert port_a.pins is not None and port_b.pins is not None
+ port_a.pins[idx_a] = pin_b
+ port_b.pins[idx_b] = pin_a
+
+ with open(lockfile_path, 'w') as f:
+ f.write(lockfile.model_dump_json(indent=2, serialize_as_any=True))
+
+ from .render import _slot_label
+ label_a = _slot_label(ca, ia, na, idx_a, len(port_a.pins))
+ label_b = _slot_label(cb, ib, nb, idx_b, len(port_b.pins))
+ print(f"Swapped pin {pin_a} ({label_a}) with pin {pin_b} ({label_b}).")
diff --git a/tests/test_pin_swap_show.py b/tests/test_pin_swap_show.py
new file mode 100644
index 00000000..ee74905d
--- /dev/null
+++ b/tests/test_pin_swap_show.py
@@ -0,0 +1,206 @@
+# SPDX-License-Identifier: BSD-2-Clause
+"""Tests for ``chipflow pin swap`` and ``chipflow pin show``."""
+
+import os
+import tempfile
+import unittest
+from contextlib import contextmanager
+from pathlib import Path
+
+from amaranth.lib import io
+
+from chipflow import ChipFlowError
+from chipflow.config.models import Process
+from chipflow.packaging.lockfile import LockFile, Package
+from chipflow.packaging.port_desc import PortMap, PortDesc
+from chipflow.packaging.standard import BlockPackageDef, BareDiePackageDef
+from chipflow.packaging.utils import swap_pins, load_pinlock
+from chipflow.packaging.render import render_text, render_svg
+
+
+def _build_block_lockfile() -> LockFile:
+ """A 4×4 BlockPackageDef with bringup (clk=1, rst_n=2) plus two
+ user pins on a soc.uart interface (tx=3, rx[0..1]=4,5)."""
+ pkg = BlockPackageDef(name="block", width=4, height=4)
+ port_map = PortMap(ports={
+ "_core": {"bringup_pins": {
+ "clk": PortDesc(
+ type='clock', pins=[1], port_name='clk',
+ iomodel={"width": 1, "direction": io.Direction.Input,
+ "clock_domain": "sync"},
+ ),
+ "rst_n": PortDesc(
+ type='reset', pins=[2], port_name='rst_n',
+ iomodel={"width": 1, "direction": io.Direction.Input,
+ "clock_domain": "sync", "invert": True},
+ ),
+ }},
+ "soc": {"uart": {
+ "tx": PortDesc(
+ type='io', pins=[3], port_name='tx',
+ iomodel={"width": 1, "direction": io.Direction.Output},
+ ),
+ "rx": PortDesc(
+ type='io', pins=[4, 5], port_name='rx',
+ iomodel={"width": 2, "direction": io.Direction.Input},
+ ),
+ }},
+ })
+ return LockFile(
+ process=Process.SKY130,
+ package=Package(package_type=pkg),
+ port_map=port_map,
+ metadata={},
+ )
+
+
+@contextmanager
+def _chipflow_root_with(lockfile: LockFile):
+ """Write the lockfile into a fresh tmpdir set as CHIPFLOW_ROOT,
+ flushing the cached root so loaders pick the new one up."""
+ from chipflow.utils import ensure_chipflow_root
+ old = os.environ.get('CHIPFLOW_ROOT')
+ with tempfile.TemporaryDirectory() as tmpdir:
+ os.environ['CHIPFLOW_ROOT'] = tmpdir
+ if hasattr(ensure_chipflow_root, 'root'):
+ delattr(ensure_chipflow_root, 'root')
+ path = Path(tmpdir) / 'pins.lock'
+ path.write_text(lockfile.model_dump_json(indent=2, serialize_as_any=True))
+ try:
+ yield path
+ finally:
+ if old is not None:
+ os.environ['CHIPFLOW_ROOT'] = old
+ else:
+ os.environ.pop('CHIPFLOW_ROOT', None)
+ if hasattr(ensure_chipflow_root, 'root'):
+ delattr(ensure_chipflow_root, 'root')
+
+
+class SwapPinsTestCase(unittest.TestCase):
+ def test_swap_two_user_pins(self):
+ with _chipflow_root_with(_build_block_lockfile()):
+ swap_pins(3, 4)
+ reloaded = load_pinlock()
+ ports = reloaded.port_map.ports['soc']['uart']
+ # tx had pin 3 → now 4; rx[0] had pin 4 → now 3.
+ self.assertEqual(ports['tx'].pins, [4])
+ self.assertEqual(ports['rx'].pins, [3, 5])
+
+ def test_swap_within_multi_bit_port(self):
+ """Two bits of the same multi-bit port can be swapped."""
+ with _chipflow_root_with(_build_block_lockfile()):
+ swap_pins(4, 5)
+ reloaded = load_pinlock()
+ self.assertEqual(
+ reloaded.port_map.ports['soc']['uart']['rx'].pins, [5, 4]
+ )
+
+ def test_swap_with_self_rejected(self):
+ with _chipflow_root_with(_build_block_lockfile()):
+ with self.assertRaises(ChipFlowError) as cm:
+ swap_pins(3, 3)
+ self.assertIn("itself", str(cm.exception))
+
+ def test_swap_unallocated_pin_rejected(self):
+ with _chipflow_root_with(_build_block_lockfile()):
+ with self.assertRaises(ChipFlowError) as cm:
+ swap_pins(3, 99)
+ self.assertIn("99", str(cm.exception))
+ self.assertIn("not allocated", str(cm.exception))
+
+ def test_swap_bringup_pin_rejected(self):
+ """clk lives in _core.bringup_pins → can't swap with a user pin."""
+ with _chipflow_root_with(_build_block_lockfile()):
+ with self.assertRaises(ChipFlowError) as cm:
+ swap_pins(1, 3)
+ self.assertIn("bringup", str(cm.exception))
+
+ def test_swap_persists_to_disk(self):
+ """After swap, the file on disk reflects the new mapping."""
+ with _chipflow_root_with(_build_block_lockfile()) as path:
+ swap_pins(3, 5)
+ text = path.read_text()
+ # Pin 3 should now belong to rx[1], pin 5 to tx — easiest
+ # check is to load and inspect.
+ after = LockFile.model_validate_json(text)
+ self.assertEqual(after.port_map.ports['soc']['uart']['tx'].pins, [5])
+ self.assertEqual(
+ after.port_map.ports['soc']['uart']['rx'].pins, [4, 3]
+ )
+
+ def test_swap_unsupported_pin_type(self):
+ """Non-int pin types (e.g. BareDie's (Side, idx)) are rejected
+ with a clear message."""
+ # Build a minimal BareDie lockfile by hand.
+ pkg = BareDiePackageDef(name="bare", width=4, height=4)
+ port_map = PortMap(ports={
+ "soc": {"uart": {
+ "tx": PortDesc(
+ type='io', pins=[("N", 0)], port_name='tx',
+ iomodel={"width": 1, "direction": io.Direction.Output},
+ ),
+ }},
+ })
+ lockfile = LockFile(
+ process=Process.SKY130,
+ package=Package(package_type=pkg),
+ port_map=port_map,
+ metadata={},
+ )
+ with _chipflow_root_with(lockfile):
+ with self.assertRaises(ChipFlowError) as cm:
+ swap_pins(1, 2)
+ self.assertIn("integer", str(cm.exception))
+
+
+class RenderTextTestCase(unittest.TestCase):
+ def test_text_lists_all_pins_sorted(self):
+ text = render_text(_build_block_lockfile())
+ lines = text.strip().splitlines()
+ self.assertEqual(lines[0].split()[0], "PIN")
+ # Pin column of body rows, in order
+ pin_col = [line.split()[0] for line in lines[1:]]
+ self.assertEqual(pin_col, ['1', '2', '3', '4', '5'])
+
+ def test_text_includes_port_paths(self):
+ text = render_text(_build_block_lockfile())
+ self.assertIn("_core.bringup_pins.clk", text)
+ self.assertIn("_core.bringup_pins.rst_n", text)
+ self.assertIn("soc.uart.tx", text)
+ self.assertIn("soc.uart.rx[0]", text)
+ self.assertIn("soc.uart.rx[1]", text)
+
+
+class RenderSvgTestCase(unittest.TestCase):
+ def test_svg_basic_shape(self):
+ svg = render_svg(_build_block_lockfile())
+ self.assertTrue(svg.startswith('