packet-tracer-skill 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import hashlib
5
5
  import json
6
6
  import re
7
7
  from collections import Counter
8
+ from collections.abc import Callable
8
9
  from pathlib import Path
9
10
  import xml.etree.ElementTree as ET
10
11
  from functools import lru_cache
@@ -706,6 +707,10 @@ def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) ->
706
707
  _WORD_VALUED_SETTINGS = (
707
708
  "switchport port-security violation",
708
709
  "switchport mode",
710
+ # An interface has one description. Without this, `description Satis` and
711
+ # `description VLAN40 standby` read as different settings and both survive
712
+ # a merge, where the device keeps only the last.
713
+ "description",
709
714
  "duplex",
710
715
  "speed",
711
716
  )
@@ -880,6 +885,49 @@ def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) ->
880
885
  return None
881
886
 
882
887
 
888
+ @lru_cache(maxsize=1)
889
+ def _modern_link_prototype_xml() -> str | None:
890
+ """A cable copied from a lab the installed Packet Tracer wrote itself.
891
+
892
+ A new link is cloned from one the file already has. When the file has none,
893
+ the fallback was a cable from the bundled `FTP.pkt`, and that sample is old
894
+ enough to predate the fields a 9.x cable carries: it refers to its devices
895
+ by index rather than by `save-ref-id`, and has no `FUNCTIONAL`,
896
+ `GEO_VIEW_COLOR` or `IS_MANAGED_IN_RACK_VIEW` at all.
897
+
898
+ Measured: adding one link to `minimal`, which has cables to copy, takes it
899
+ from four links to five and it opens. Adding one link to `wireless_home`,
900
+ which has none, produces a file Packet Tracer refuses -- and that is why
901
+ both wireless labs ship uncabled. Same writer, same ports, same devices;
902
+ the only difference is which cable was cloned.
903
+
904
+ The compatibility donor is the right source because it is already the file
905
+ this skill trusts for the installed version, so its cables are the shape
906
+ this Packet Tracer writes.
907
+ """
908
+ from packet_tracer_env import get_packet_tracer_compatibility_donor
909
+
910
+ donor = get_packet_tracer_compatibility_donor()
911
+ if donor is None:
912
+ return None
913
+ try:
914
+ root = decode_pkt_to_root(donor)
915
+ except Exception: # pragma: no cover - a donor that no longer decodes
916
+ return None
917
+ prototype = _first_link_prototype(root)
918
+ if prototype is None:
919
+ return None
920
+ return ET.tostring(prototype, encoding="unicode")
921
+
922
+
923
+ def _fallback_link_prototype() -> ET.Element | None:
924
+ xml = _modern_link_prototype_xml()
925
+ if xml is not None:
926
+ return ET.fromstring(xml)
927
+ prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
928
+ return prototype_root.find(".//LINKS/LINK")
929
+
930
+
883
931
  def _first_link_prototype(root: ET.Element) -> ET.Element | None:
884
932
  for link in root.findall(".//LINKS/LINK"):
885
933
  cable = link.find("./CABLE")
@@ -1356,8 +1404,16 @@ def _ensure_link(
1356
1404
  right_port: str,
1357
1405
  media: str,
1358
1406
  port_mem_map: dict[tuple[str, str], str] | None = None,
1407
+ allow_parallel: bool = False,
1359
1408
  ) -> None:
1360
- existing = _find_link_by_devices(root, left_name, right_name)
1409
+ # Every caller but one wants at most one cable between a pair of devices, so
1410
+ # a second call re-points the first cable instead of adding one. An
1411
+ # EtherChannel is the exception: it is only a bundle if two cables run
1412
+ # between the same two switches, and asking for the second one used to move
1413
+ # the first. The new-link branch below is what a parallel cable needs -- it
1414
+ # is the branch that drops the saving session's memory pointers, which is
1415
+ # what makes Packet Tracer accept a link it did not write itself.
1416
+ existing = None if allow_parallel else _find_link_by_devices(root, left_name, right_name)
1361
1417
  devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
1362
1418
  index_refs, save_refs = _device_refs(root)
1363
1419
  left_device = devices.get(left_name)
@@ -1387,8 +1443,7 @@ def _ensure_link(
1387
1443
  if prototype is None:
1388
1444
  prototype = _first_link_prototype(root)
1389
1445
  if prototype is None:
1390
- prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
1391
- prototype = prototype_root.find(".//LINKS/LINK")
1446
+ prototype = _fallback_link_prototype()
1392
1447
  link = copy.deepcopy(prototype)
1393
1448
  if link is None:
1394
1449
  return
@@ -1550,13 +1605,13 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1550
1605
  for target in _config_targets(device):
1551
1606
  _append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}"])
1552
1607
  if operation.get("trust_port"):
1553
- _append_config_block(target, f"interface {operation['trust_port']}", [" ip dhcp snooping trust"])
1608
+ _set_config_block(target, f"interface {operation['trust_port']}", [" ip dhcp snooping trust"])
1554
1609
  return
1555
1610
  elif operation["op"] == "set_dai":
1556
1611
  for target in _config_targets(device):
1557
1612
  _append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}", f"ip arp inspection vlan {operation['vlan']}"])
1558
1613
  if operation.get("trust_port"):
1559
- _append_config_block(target, f"interface {operation['trust_port']}", [" ip arp inspection trust", " ip dhcp snooping trust"])
1614
+ _set_config_block(target, f"interface {operation['trust_port']}", [" ip arp inspection trust", " ip dhcp snooping trust"])
1560
1615
  return
1561
1616
  elif operation["op"] == "set_port_security":
1562
1617
  body = [" switchport mode access", " switchport port-security"]
@@ -1565,7 +1620,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1565
1620
  if operation.get("violation"):
1566
1621
  body.append(f" switchport port-security violation {operation['violation']}")
1567
1622
  for target in _config_targets(device):
1568
- _append_config_block(target, f"interface {operation['port']}", body)
1623
+ _set_config_block(target, f"interface {operation['port']}", body)
1569
1624
  return
1570
1625
  elif operation["op"] == "set_lldp":
1571
1626
  for target in _config_targets(device):
@@ -1573,7 +1628,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1573
1628
  return
1574
1629
  elif operation["op"] == "set_rep":
1575
1630
  for target in _config_targets(device):
1576
- _append_config_block(target, f"interface {operation['interface']}", [f" rep segment {operation['segment']}"])
1631
+ _set_config_block(target, f"interface {operation['interface']}", [f" rep segment {operation['segment']}"])
1577
1632
  return
1578
1633
  elif operation["op"] == "set_span":
1579
1634
  for target in _config_targets(device):
@@ -1592,7 +1647,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1592
1647
  mode = str(operation.get("mode") or "auto")
1593
1648
  for target in _config_targets(device):
1594
1649
  _append_unique_config_lines(target, global_lines)
1595
- _append_config_block(
1650
+ _set_config_block(
1596
1651
  target,
1597
1652
  f"interface {operation['interface']}",
1598
1653
  [f" authentication port-control {mode}", " dot1x pae authenticator"],
@@ -1612,7 +1667,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1612
1667
  f"policy-map {operation['policy_map']}",
1613
1668
  [f" class {operation['class_map']}", f" {action}"],
1614
1669
  )
1615
- _append_config_block(
1670
+ _set_config_block(
1616
1671
  target,
1617
1672
  f"interface {operation['interface']}",
1618
1673
  [f" service-policy {operation['direction']} {operation['policy_map']}"],
@@ -1631,8 +1686,8 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1631
1686
  interfaces = [str(interface) for interface in operation.get("interfaces", []) if str(interface).strip()]
1632
1687
  for target in _config_targets(device):
1633
1688
  for interface_name in interfaces:
1634
- _append_config_block(target, f"interface {interface_name}", [f" channel-group {channel} mode {mode}"])
1635
- _append_config_block(target, f"interface Port-channel{channel}", [" no shutdown"])
1689
+ _set_config_block(target, f"interface {interface_name}", [f" channel-group {channel} mode {mode}"])
1690
+ _set_config_block(target, f"interface Port-channel{channel}", [" no shutdown"])
1636
1691
  return
1637
1692
  elif operation["op"] == "set_vtp":
1638
1693
  lines = [f"vtp domain {operation['domain']}", f"vtp mode {operation['mode']}"]
@@ -1643,7 +1698,7 @@ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
1643
1698
  return
1644
1699
  elif operation["op"] == "set_dtp":
1645
1700
  for target in _config_targets(device):
1646
- _append_config_block(target, f"interface {operation['interface']}", [f" switchport mode {operation['mode']}"])
1701
+ _set_config_block(target, f"interface {operation['interface']}", [f" switchport mode {operation['mode']}"])
1647
1702
  return
1648
1703
  else:
1649
1704
  return
@@ -1668,7 +1723,7 @@ def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
1668
1723
  def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) -> None:
1669
1724
  if operation["op"] == "set_subinterface":
1670
1725
  for target in _config_targets(device):
1671
- _append_config_block(
1726
+ _set_config_block(
1672
1727
  target,
1673
1728
  f"interface {operation['subinterface']}",
1674
1729
  [
@@ -1699,7 +1754,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1699
1754
  return
1700
1755
  elif operation["op"] == "apply_acl":
1701
1756
  for target in _config_targets(device):
1702
- _append_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
1757
+ _set_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
1703
1758
  return
1704
1759
  elif operation["op"] == "enable_ipv6_unicast_routing":
1705
1760
  for target in _config_targets(device):
@@ -1708,7 +1763,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1708
1763
  elif operation["op"] == "set_ipv6_address":
1709
1764
  for target in _config_targets(device):
1710
1765
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1711
- _append_config_block(
1766
+ _set_config_block(
1712
1767
  target,
1713
1768
  f"interface {operation['interface']}",
1714
1769
  [f" ipv6 address {operation['address']}/{operation['prefix']}", " no shutdown"],
@@ -1720,7 +1775,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1720
1775
  body.insert(1, f" ipv6 nd prefix {operation['prefix']}/{operation['prefix_len']}")
1721
1776
  for target in _config_targets(device):
1722
1777
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1723
- _append_config_block(target, f"interface {operation['interface']}", body)
1778
+ _set_config_block(target, f"interface {operation['interface']}", body)
1724
1779
  return
1725
1780
  elif operation["op"] == "set_dhcpv6_pool":
1726
1781
  pool_body = [f" address prefix {operation['prefix']}/{operation['prefix_len']}"]
@@ -1732,26 +1787,26 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1732
1787
  for target in _config_targets(device):
1733
1788
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1734
1789
  _append_config_block(target, f"ipv6 dhcp pool {operation['name']}", pool_body)
1735
- _append_config_block(target, f"interface {operation['interface']}", interface_body)
1790
+ _set_config_block(target, f"interface {operation['interface']}", interface_body)
1736
1791
  return
1737
1792
  elif operation["op"] == "set_ospfv3_interface":
1738
1793
  process_id = operation["process_id"]
1739
1794
  for target in _config_targets(device):
1740
1795
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1741
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 ospf {process_id} area {operation['area']}", " no shutdown"])
1796
+ _set_config_block(target, f"interface {operation['interface']}", [f" ipv6 ospf {process_id} area {operation['area']}", " no shutdown"])
1742
1797
  _append_config_block(target, f"ipv6 router ospf {process_id}", [])
1743
1798
  return
1744
1799
  elif operation["op"] == "set_eigrp_ipv6_interface":
1745
1800
  asn = operation["asn"]
1746
1801
  for target in _config_targets(device):
1747
1802
  _append_unique_config_lines(target, ["ipv6 unicast-routing", "no ipv6 cef"])
1748
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 eigrp {asn}", " no shutdown"])
1803
+ _set_config_block(target, f"interface {operation['interface']}", [f" ipv6 eigrp {asn}", " no shutdown"])
1749
1804
  _append_config_block(target, f"ipv6 router eigrp {asn}", [" no shutdown"])
1750
1805
  return
1751
1806
  elif operation["op"] == "set_ripng_interface":
1752
1807
  for target in _config_targets(device):
1753
1808
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1754
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 rip {operation['process_name']} enable", " no shutdown"])
1809
+ _set_config_block(target, f"interface {operation['interface']}", [f" ipv6 rip {operation['process_name']} enable", " no shutdown"])
1755
1810
  return
1756
1811
  elif operation["op"] == "set_hsrp_ipv6":
1757
1812
  # A standby group with no virtual address configures nothing, and the
@@ -1767,7 +1822,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1767
1822
  body.append(f" standby {operation['group']} preempt")
1768
1823
  for target in _config_targets(device):
1769
1824
  _append_unique_config_lines(target, ["ipv6 unicast-routing"])
1770
- _append_config_block(target, f"interface {operation['interface']}", body)
1825
+ _set_config_block(target, f"interface {operation['interface']}", body)
1771
1826
  return
1772
1827
  elif operation["op"] == "set_ospfv2_network":
1773
1828
  for target in _config_targets(device):
@@ -1797,11 +1852,11 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1797
1852
  return
1798
1853
  elif operation["op"] == "set_dhcp_relay":
1799
1854
  for target in _config_targets(device):
1800
- _append_config_block(target, f"interface {operation['interface']}", [f" ip helper-address {operation['helper']}"])
1855
+ _set_config_block(target, f"interface {operation['interface']}", [f" ip helper-address {operation['helper']}"])
1801
1856
  return
1802
1857
  elif operation["op"] == "set_nat_interface":
1803
1858
  for target in _config_targets(device):
1804
- _append_config_block(target, f"interface {operation['interface']}", [f" ip nat {operation['role']}"])
1859
+ _set_config_block(target, f"interface {operation['interface']}", [f" ip nat {operation['role']}"])
1805
1860
  return
1806
1861
  elif operation["op"] == "set_nat_static":
1807
1862
  for target in _config_targets(device):
@@ -1849,7 +1904,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1849
1904
  for target in _config_targets(device):
1850
1905
  _append_unique_config_lines(target, global_lines)
1851
1906
  if operation.get("interface") and operation.get("direction"):
1852
- _append_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
1907
+ _set_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
1853
1908
  return
1854
1909
  elif operation["op"] == "set_gre_tunnel":
1855
1910
  body = []
@@ -1864,7 +1919,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1864
1919
  ]
1865
1920
  )
1866
1921
  for target in _config_targets(device):
1867
- _append_config_block(target, f"interface {operation['interface']}", body)
1922
+ _set_config_block(target, f"interface {operation['interface']}", body)
1868
1923
  return
1869
1924
  elif operation["op"] == "set_ppp_interface":
1870
1925
  body = [" encapsulation ppp"]
@@ -1872,7 +1927,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1872
1927
  body.append(f" ppp authentication {operation['authentication']}")
1873
1928
  body.append(" no shutdown")
1874
1929
  for target in _config_targets(device):
1875
- _append_config_block(target, f"interface {operation['interface']}", body)
1930
+ _set_config_block(target, f"interface {operation['interface']}", body)
1876
1931
  return
1877
1932
  elif operation["op"] == "set_ipsec_transform_set":
1878
1933
  for target in _config_targets(device):
@@ -1893,17 +1948,17 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1893
1948
  ],
1894
1949
  )
1895
1950
  if operation.get("interface"):
1896
- _append_config_block(target, f"interface {operation['interface']}", [f" crypto map {operation['map_name']}"])
1951
+ _set_config_block(target, f"interface {operation['interface']}", [f" crypto map {operation['map_name']}"])
1897
1952
  return
1898
1953
  elif operation["op"] == "set_cbac_inspect":
1899
1954
  for target in _config_targets(device):
1900
1955
  _append_unique_config_lines(target, [f"ip inspect name {operation['name']} {operation['protocol']}"])
1901
- _append_config_block(target, f"interface {operation['interface']}", [f" ip inspect {operation['name']} {operation['direction']}"])
1956
+ _set_config_block(target, f"interface {operation['interface']}", [f" ip inspect {operation['name']} {operation['direction']}"])
1902
1957
  return
1903
1958
  elif operation["op"] == "set_zfw_zone_interface":
1904
1959
  for target in _config_targets(device):
1905
1960
  _append_config_block(target, f"zone security {operation['zone']}", [])
1906
- _append_config_block(target, f"interface {operation['interface']}", [f" zone-member security {operation['zone']}"])
1961
+ _set_config_block(target, f"interface {operation['interface']}", [f" zone-member security {operation['zone']}"])
1907
1962
  return
1908
1963
  elif operation["op"] == "set_zfw_zone_pair":
1909
1964
  for target in _config_targets(device):
@@ -1968,7 +2023,7 @@ def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) ->
1968
2023
  def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
1969
2024
  if operation["op"] == "set_management_vlan":
1970
2025
  for target in _config_targets(device):
1971
- _append_config_block(
2026
+ _set_config_block(
1972
2027
  target,
1973
2028
  f"interface Vlan{operation['vlan']}",
1974
2029
  [f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
@@ -2135,10 +2190,23 @@ def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None
2135
2190
  if node.find("CHANNEL") is not None:
2136
2191
  _ensure_text(node, "CHANNEL", str(operation["channel"]))
2137
2192
  if operation.get("passphrase"):
2138
- if node.find("WEP_KEY") is not None:
2139
- _ensure_text(node, "WEP_KEY", str(operation["passphrase"]))
2140
- if node.find("WPA_PASSPHRASE") is not None:
2141
- _ensure_text(node, "WPA_PASSPHRASE", str(operation["passphrase"]))
2193
+ # The key goes in `WEP_PROCESS`, whatever the security is.
2194
+ #
2195
+ # Those field names are legacy: a working WPA2 home router
2196
+ # keeps `WEP_PROCESS/KEY` with `WEP_PROCESS/ENCRYPTION` set to
2197
+ # the encryption type, and carries no `WPA_PASSPHRASE` at all.
2198
+ # Measured on `hr-guest`, which associates and pings; the same
2199
+ # shape the client side already used. Choosing the field by
2200
+ # authentication type -- `WEP_KEY` for WEP, `WPA_PASSPHRASE`
2201
+ # otherwise -- put the passphrase somewhere Packet Tracer does
2202
+ # not read, so the access point ran WPA2 with no key while its
2203
+ # clients had one, and nothing associated. The lab opened and
2204
+ # every field looked right on both sides.
2205
+ process = node.find("WEP_PROCESS")
2206
+ if process is None:
2207
+ process = ET.SubElement(node, "WEP_PROCESS")
2208
+ _ensure_text(process, "KEY", str(operation["passphrase"]))
2209
+ _ensure_text(process, "ENCRYPTION", str(operation["encrypt_type"]))
2142
2210
  for profile in _profile_nodes(engine):
2143
2211
  _ensure_text(profile, "NAME", str(operation["ssid"]))
2144
2212
  _ensure_text(profile, "SSID", str(operation["ssid"]))
@@ -2391,9 +2459,23 @@ def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
2391
2459
  return apply_plan_operations(root, plan)
2392
2460
 
2393
2461
 
2394
- def edit_pkt_file(pkt_path: str | Path, plan: IntentPlan, output_path: str | Path, xml_out_path: str | Path | None = None) -> Path:
2462
+ def edit_pkt_file(
2463
+ pkt_path: str | Path,
2464
+ plan: IntentPlan,
2465
+ output_path: str | Path,
2466
+ xml_out_path: str | Path | None = None,
2467
+ repair: Callable[[ET.Element], object] | None = None,
2468
+ ) -> Path:
2395
2469
  root = decode_pkt_to_root(pkt_path)
2396
2470
  updated = apply_plan_operations(root, plan)
2471
+ # An edit names ports the way a plan does -- from the request, not from the
2472
+ # file -- so it can write the same configuration that took SW2 off the
2473
+ # network: a channel-group on a port whose peer does not bundle. The
2474
+ # generation pipelines repair that after the fact and the edit path had no
2475
+ # such step. The caller passes the repair in; importing it here would close
2476
+ # a loop, since the module that owns it already imports this one.
2477
+ if repair is not None:
2478
+ repair(updated)
2397
2479
  xml_bytes = serialize_pkt_xml(updated)
2398
2480
  output_path = Path(output_path)
2399
2481
  output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -718,6 +718,82 @@ UNSLOTTED_MULTIPORT_TYPES = {
718
718
  "MerakiServer",
719
719
  }
720
720
 
721
+ # A home router numbers its LAN ports from one and gives the WAN socket a name
722
+ # of its own. Counting the sockets and numbering them all is how a cable landed
723
+ # on `GigabitEthernet 5` of a router with four LAN ports: five copper gigabit
724
+ # PORT nodes, and the fifth is `Internet`. Packet Tracer refused the file, and
725
+ # every check before it passed -- the device lists no interfaces in its
726
+ # configuration, so nothing else could contradict the count.
727
+ WIRELESS_ROUTER_TYPES = {"WirelessRouter", "WirelessRouterNewGeneration"}
728
+
729
+
730
+ def wireless_router_port_names(device: ET.Element) -> list[str]:
731
+ """The interface names a home router really answers to, LAN ports first.
732
+
733
+ A home router writes no interfaces into its configuration, so
734
+ `donor_interface_names` comes back empty and every shape check abstains --
735
+ which left the port index as the only thing anyone checked. That accepted
736
+ `FastEthernet0/1` on a device whose sockets are `Ethernet 1` .. `4`: right
737
+ index, wrong name, and Packet Tracer refuses a lab that names an interface
738
+ a device does not have.
739
+
740
+ The names are positional and set by the model. Measured over every home
741
+ router in the labs on this machine -- sixteen cables in all, and not one on
742
+ any other name:
743
+
744
+ WirelessRouter `Ethernet 1` .. `4`, `Internet`
745
+ WirelessRouterNewGeneration `GigabitEthernet 1` .. `4`, `Internet`
746
+
747
+ Both carry five copper ports and the fifth is the uplink, which is why
748
+ counting sockets and numbering them all put a cable on a port that is not
749
+ there. Note the older model's ports report `eCopperFastEthernet` and are
750
+ still named plain `Ethernet`, so the family cannot be read off the socket:
751
+ the device type is the discriminator.
752
+
753
+ Confirmed against the live devices rather than the files alone. Dropped into
754
+ an empty Packet Tracer and read back:
755
+
756
+ Linksys-WRT300N Vlan1, Internet, Ethernet 1 .. 4, Wireless
757
+ HomeRouter-PT-AC Vlan1, Internet, GigabitEthernet 1 .. 4,
758
+ Wireless 1 .. 6, Wireless0/0
759
+
760
+ Worth doing: the builder's own device table gives `Ethernet 1` .. `4` for
761
+ the AC model, which the device itself contradicts. `pt_inspect_ports` is no
762
+ help either -- it answers at the IOS layer, where the LAN sockets are
763
+ bridged into `Vlan1` and do not appear at all.
764
+
765
+ The wireless sockets follow from the same PORT list: the access-point
766
+ radios are `Wireless 1` .. `N`, or bare `Wireless` when there is only one,
767
+ and a host radio is `Wireless0/0`. They are listed so a wireless link is
768
+ never mistaken for a cable on a port that is not there.
769
+
770
+ `Internet` comes after the LAN ports so a repair prefers a LAN port and
771
+ only reaches for the uplink when the LAN ports are taken.
772
+ """
773
+ raw_type = (device.findtext("./ENGINE/TYPE") or "").strip()
774
+ if normalize_device_type(raw_type) not in WIRELESS_ROUTER_TYPES:
775
+ return []
776
+ prefix = "GigabitEthernet" if raw_type == "WirelessRouterNewGeneration" else "Ethernet"
777
+ copper = access_radios = host_radios = 0
778
+ for node in device.findall(".//PORT"):
779
+ port_type = (node.findtext("TYPE") or "").strip()
780
+ if PORT_TYPE_FAMILIES.get(port_type) in {"FastEthernet", "GigabitEthernet"}:
781
+ copper += 1
782
+ elif port_type.startswith("eAccessPointWireless"):
783
+ access_radios += 1
784
+ elif port_type.startswith("eHostWireless"):
785
+ host_radios += 1
786
+ # One of the copper sockets is the uplink, which is why counting them and
787
+ # numbering them all put a cable on a port that is not there.
788
+ names = [f"{prefix} {index}" for index in range(1, max(copper - 1, 0) + 1)]
789
+ names.append("Internet")
790
+ if access_radios == 1:
791
+ names.append("Wireless")
792
+ else:
793
+ names += [f"Wireless {index}" for index in range(1, access_radios + 1)]
794
+ names += ["Wireless0/0"] * min(host_radios, 1)
795
+ return names
796
+
721
797
 
722
798
  def donor_interface_names(device: ET.Element) -> list[str]:
723
799
  """The interfaces a donor device really has, in the order it lists them.
@@ -779,7 +855,7 @@ def _name_contradicts_device_shape(device: ET.Element, canonical: str, kind: str
779
855
  `1/1`, ... `9/1`. Both have one slash, so depth cannot tell them apart,
780
856
  and the generator's `FastEthernet0/{index}` asked such a switch for
781
857
  `FastEthernet0/2`. Measured: that single link is why a lab built from
782
- `Senan_Haciyev_tapsiriq.pkt` was refused; the same file with the uplink on
858
+ the saved serial-WAN lab was refused; the same file with the uplink on
783
859
  `FastEthernet2/1` opens.
784
860
 
785
861
  Two names are required before concluding anything about the second axis, so
@@ -827,6 +903,16 @@ def port_exists(device: ET.Element, port_name: str) -> bool:
827
903
  if "channel" in lowered or lowered.startswith("vlan") or "." in canonical:
828
904
  return False
829
905
 
906
+ # A home router's sockets are named positionally and are the same on every
907
+ # unit of a model, so they can be answered exactly rather than guessed at.
908
+ # This has to come before the branches below: `Ethernet 1` matches neither
909
+ # modelled prefix and used to fall through to the permissive branch, where a
910
+ # device with no configured interfaces is given the benefit of the doubt --
911
+ # so `Ethernet 99` passed too.
912
+ if device_type in WIRELESS_ROUTER_TYPES:
913
+ known = wireless_router_port_names(device)
914
+ return canonical in known if known else True
915
+
830
916
  # Serial is modelled now, so it gets a real answer: a router with no serial
831
917
  # card cannot carry `Serial0/0/0`, and letting that through produced a WAN
832
918
  # lab whose PPP configuration sat on an interface that was never cabled.
@@ -845,7 +931,19 @@ def port_exists(device: ET.Element, port_name: str) -> bool:
845
931
  # point, `RS 232` on a laptop, the `Switch` pass-through on an IP phone.
846
932
  # Reporting those as missing made real links look invalid, which is the
847
933
  # damaging direction: a legitimate link gets dropped.
934
+ #
935
+ # Permissive, but not blind. A device that lists its own interfaces has told
936
+ # us what it has, and a name absent from that list is not one of them. An
937
+ # ASA 5506-X whose interfaces are `GigabitEthernet1/1` .. `1/8` answered yes
938
+ # to `Ethernet0/0` -- the name fits neither modelled prefix, so it fell
939
+ # straight through to this branch, the port repair saw nothing to fix, and
940
+ # Packet Tracer refused the lab. Devices with no interfaces in their
941
+ # configuration keep the benefit of the doubt, which is what leaves access
942
+ # points, phones and laptops working.
848
943
  if not canonical.startswith(("FastEthernet", "GigabitEthernet")):
944
+ named = donor_interface_names(device)
945
+ if named and canonical not in named:
946
+ return False
849
947
  return True
850
948
 
851
949
  for kind, count in port_capacity(device).items():
@@ -21,6 +21,11 @@ SCRIPT_DIR = Path(__file__).resolve().parent
21
21
  SKILL_ROOT = SCRIPT_DIR.parent
22
22
  DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
23
23
  DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
24
+ # Labs the user happens to have on disk are catalogued for donor ranking but
25
+ # never written to the two files above: those are committed, and a lab from
26
+ # `C:/Users/<name>/Downloads` carries that person's name in its path, its
27
+ # filename and its device labels. Kept beside them, and git-ignored.
28
+ LOCAL_CATALOG_JSON = SKILL_ROOT / "references" / "local-donor-catalog.json"
24
29
  DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
25
30
 
26
31
  CAPABILITY_KEYWORDS = {
@@ -1112,25 +1117,51 @@ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
1112
1117
  return enriched
1113
1118
 
1114
1119
 
1120
+ def _catalog_item_path(item: dict, saves_root: Path | None) -> str:
1121
+ """Where a catalogued lab actually lives.
1122
+
1123
+ Entries from the Packet Tracer installation are stored relative to its
1124
+ saves root and joined back to it here. A lab the user saved somewhere else
1125
+ carries its own absolute path, and joining that to the saves root produced
1126
+ a path no file was at -- so a donor built for this skill could be
1127
+ catalogued and still never be read.
1128
+
1129
+ The committed catalogue drops `path` on purpose, to stay independent of
1130
+ whose machine wrote it. `source_path` survives instead, and only entries
1131
+ outside the installation carry one: those are machine-specific by nature.
1132
+ """
1133
+ for key in ("path", "source_path"):
1134
+ own_path = str(item.get(key) or "")
1135
+ if own_path and Path(own_path).is_absolute():
1136
+ return own_path
1137
+ relative_path = item["relative_path"]
1138
+ if saves_root is None:
1139
+ return own_path or relative_path
1140
+ return str(saves_root / relative_path)
1141
+
1142
+
1115
1143
  @lru_cache(maxsize=8)
1116
1144
  def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
1117
1145
  raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
1118
1146
  items = enrich_catalog_items(raw_items)
1119
1147
  saves_root = get_packet_tracer_saves_root()
1120
1148
  return tuple(
1121
- _descriptor_from_item(
1122
- {
1123
- **item,
1124
- "path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
1125
- }
1126
- )
1149
+ _descriptor_from_item({**item, "path": _catalog_item_path(item, saves_root)})
1127
1150
  for item in items
1128
1151
  if "error" not in item
1129
1152
  )
1130
1153
 
1131
1154
 
1132
1155
  def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
1133
- return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
1156
+ """Both catalogues, so splitting the file did not shrink donor choice.
1157
+
1158
+ The installed samples are committed; the labs found on this machine are
1159
+ not. Ranking has always seen one list and still does.
1160
+ """
1161
+ catalog = list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
1162
+ if path is None and LOCAL_CATALOG_JSON.is_file():
1163
+ catalog.extend(_load_catalog_cached(str(LOCAL_CATALOG_JSON)))
1164
+ return catalog
1134
1165
 
1135
1166
 
1136
1167
  def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
@@ -1348,17 +1379,39 @@ def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[Referenc
1348
1379
  return patterns
1349
1380
 
1350
1381
 
1351
- def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
1382
+ def write_catalog_outputs(
1383
+ items: list[dict[str, Any]],
1384
+ json_path: Path | None = None,
1385
+ md_path: Path | None = None,
1386
+ local_json_path: Path | None = None,
1387
+ ) -> None:
1388
+ """Write the installed-sample catalogue, and the local one beside it.
1389
+
1390
+ `_local_donor_items` strips `path` from its entries so the committed
1391
+ catalogue would not depend on whose machine built it -- but that intent
1392
+ lived only there, and this writer, which is what actually persists the
1393
+ file, did not share it. So a rebuild on a machine with saved labs staged
1394
+ 350 entries naming the user and their classmates, each with an absolute
1395
+ path under their home directory, into a file bound for a public
1396
+ repository. Provenance is already on every entry; the split reads it
1397
+ rather than trusting the caller to have filtered first.
1398
+ """
1352
1399
  enriched = enrich_catalog_items(items)
1353
- compact_items: list[dict[str, Any]] = []
1400
+ installed: list[dict[str, Any]] = []
1401
+ local: list[dict[str, Any]] = []
1354
1402
  for item in enriched:
1355
1403
  saved = dict(item)
1356
1404
  saved.pop("path", None)
1357
- compact_items.append(saved)
1358
- (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
1405
+ (installed if saved.get("origin") == "cisco-local" else local).append(saved)
1406
+ (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(installed, indent=2, ensure_ascii=False), encoding="utf-8")
1407
+ local_path = local_json_path or LOCAL_CATALOG_JSON
1408
+ if local:
1409
+ local_path.write_text(json.dumps(local, indent=2, ensure_ascii=False), encoding="utf-8")
1410
+ elif local_path.exists():
1411
+ local_path.unlink()
1359
1412
  root_label = "<PACKET_TRACER_SAVES_ROOT>"
1360
1413
  lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
1361
- for item in enriched:
1414
+ for item in installed:
1362
1415
  if "error" in item:
1363
1416
  lines.append(f"- `{item['relative_path']}`")
1364
1417
  lines.append(f" decode error: `{item['error']}`")