packet-tracer-skill 0.1.0

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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
  3. package/README.md +687 -0
  4. package/SKILL.md +221 -0
  5. package/bin/packet-tracer-skill.js +635 -0
  6. package/examples/blueprint_minimal.json +46 -0
  7. package/package.json +42 -0
  8. package/references/packettracer-sample-catalog.json +21410 -0
  9. package/references/packettracer-sample-catalog.md +1124 -0
  10. package/references/pkt-format.md +57 -0
  11. package/references/xml-skeleton-notes.md +44 -0
  12. package/requirements-dev.txt +1 -0
  13. package/requirements.txt +6 -0
  14. package/scripts/build_sample_catalog.py +65 -0
  15. package/scripts/donor_diagnostics.py +35 -0
  16. package/scripts/generate_pkt.py +1264 -0
  17. package/scripts/install_skill.py +71 -0
  18. package/scripts/intent_parser.py +712 -0
  19. package/scripts/packet_tracer_env.py +278 -0
  20. package/scripts/pkt_builder.py +15 -0
  21. package/scripts/pkt_codec.py +181 -0
  22. package/scripts/pkt_editor.py +752 -0
  23. package/scripts/pkt_transformer.py +541 -0
  24. package/scripts/sample_catalog.py +385 -0
  25. package/scripts/sample_selector.py +156 -0
  26. package/scripts/setup.ps1 +26 -0
  27. package/scripts/twofish_diagnostics.py +91 -0
  28. package/scripts/vendor/README.md +46 -0
  29. package/scripts/vendor/twofish.py +81 -0
  30. package/scripts/workspace_repair.py +441 -0
  31. package/templates/pt900/base_empty.xml +21 -0
  32. package/templates/pt900/device_library/pc.xml +20 -0
  33. package/templates/pt900/device_library/printer.xml +432 -0
  34. package/templates/pt900/device_library/router.xml +16 -0
  35. package/templates/pt900/device_library/switch.xml +38 -0
@@ -0,0 +1,541 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from functools import lru_cache
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from packet_tracer_env import get_packet_tracer_compatibility_donor, get_packet_tracer_target_version, require_packet_tracer_compatibility_donor, resolve_sample_path
11
+ from pkt_codec import decode_pkt_modern
12
+ from sample_catalog import SampleDescriptor, load_catalog, normalize_device_type
13
+ from workspace_repair import sanitize_generated_physical_workspace, validate_workspace_integrity
14
+
15
+ SCRIPT_DIR = Path(__file__).resolve().parent
16
+ SKILL_ROOT = SCRIPT_DIR.parent
17
+ TEMPLATE_DIR = SKILL_ROOT / "templates" / "pt900" / "device_library"
18
+ FALLBACK_PROTOTYPE_SAMPLE = r"01 Networking\FTP\FTP.pkt"
19
+ DEVICE_TEMPLATE_FILES = {
20
+ "Printer": "printer.xml",
21
+ }
22
+ GENERIC_COPPER_HOST_TYPES = {"PC", "Server", "Printer", "Laptop"}
23
+
24
+
25
+ def load_sample_root(sample_path: str | Path) -> ET.Element:
26
+ return copy.deepcopy(_load_sample_root_cached(str(sample_path)))
27
+
28
+
29
+ @lru_cache(maxsize=512)
30
+ def _load_sample_root_cached(sample_path: str) -> ET.Element:
31
+ xml_bytes = decode_pkt_modern(Path(sample_path).read_bytes())
32
+ return ET.fromstring(xml_bytes)
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def generation_root_sample() -> str:
37
+ donor = get_packet_tracer_compatibility_donor()
38
+ if donor is not None:
39
+ return str(donor)
40
+ return str(resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE))
41
+
42
+
43
+ def generation_root_version() -> str:
44
+ donor = get_packet_tracer_compatibility_donor()
45
+ if donor is not None:
46
+ root = _load_sample_root_cached(str(donor))
47
+ return root.findtext("./VERSION", default=get_packet_tracer_target_version())
48
+ return get_packet_tracer_target_version()
49
+
50
+
51
+ def strict_compatibility_mode() -> bool:
52
+ return generation_root_version().startswith("9.")
53
+
54
+
55
+ def _device_type(device: ET.Element) -> str:
56
+ return normalize_device_type(device.findtext("./ENGINE/TYPE", default=""))
57
+
58
+
59
+ def _device_model(device: ET.Element) -> str:
60
+ node = device.find("./ENGINE/TYPE")
61
+ return node.get("model", "") if node is not None else ""
62
+
63
+
64
+ def _logical_position(device: ET.Element) -> tuple[int, int]:
65
+ x_node = device.find("./WORKSPACE/LOGICAL/X")
66
+ y_node = device.find("./WORKSPACE/LOGICAL/Y")
67
+ x_val = float(x_node.text) if x_node is not None and x_node.text else 200.0
68
+ y_val = float(y_node.text) if y_node is not None and y_node.text else 200.0
69
+ return int(round(x_val)), int(round(y_val))
70
+
71
+
72
+ def _set_device_name(device: ET.Element, new_name: str) -> None:
73
+ node = device.find("./ENGINE/NAME")
74
+ if node is not None:
75
+ node.text = new_name
76
+
77
+
78
+ def _set_device_model(device: ET.Element, model: str) -> None:
79
+ node = device.find("./ENGINE/TYPE")
80
+ if node is not None:
81
+ node.set("model", model)
82
+
83
+
84
+ def _set_position(device: ET.Element, x_pos: int, y_pos: int) -> None:
85
+ x_node = device.find("./WORKSPACE/LOGICAL/X")
86
+ y_node = device.find("./WORKSPACE/LOGICAL/Y")
87
+ if x_node is not None:
88
+ x_node.text = str(x_pos)
89
+ if y_node is not None:
90
+ y_node.text = str(y_pos)
91
+
92
+
93
+ def _set_workspace_ids(device: ET.Element, index: int) -> None:
94
+ mem_node = device.find("./WORKSPACE/LOGICAL/MEM_ADDR")
95
+ dev_node = device.find("./WORKSPACE/LOGICAL/DEV_ADDR")
96
+ if mem_node is not None:
97
+ mem_node.text = str(3_000_000_000_000 + index * 32)
98
+ if dev_node is not None:
99
+ dev_node.text = str(3_100_000_000_000 + index * 32)
100
+
101
+
102
+ def _set_runtime_ids(device: ET.Element, index: int) -> None:
103
+ save_ref = device.find("./ENGINE/SAVE_REF_ID")
104
+ serial = device.find("./ENGINE/SERIALNUMBER")
105
+ start_time = device.find("./ENGINE/STARTTIME")
106
+ coord_x = device.find("./ENGINE/COORD_SETTINGS/X_COORD")
107
+ coord_y = device.find("./ENGINE/COORD_SETTINGS/Y_COORD")
108
+ logical_x = device.findtext("./WORKSPACE/LOGICAL/X", default="")
109
+ logical_y = device.findtext("./WORKSPACE/LOGICAL/Y", default="")
110
+ if save_ref is not None:
111
+ save_ref.text = f"save-ref-id:{9_000_000_000_000_000_000 + index}"
112
+ if serial is not None:
113
+ prefix = (serial.text or "AUTO").split("-")[0][:8] or "AUTO"
114
+ serial.text = f"{prefix}{index:04d}-"
115
+ if start_time is not None:
116
+ start_time.text = str(800_000_000_000 + index)
117
+ if coord_x is not None and logical_x:
118
+ coord_x.text = logical_x
119
+ if coord_y is not None and logical_y:
120
+ coord_y.text = logical_y
121
+
122
+
123
+ def _build_workspace_reference_library(root: ET.Element) -> dict[str, list[ET.Element]]:
124
+ buckets: dict[str, list[ET.Element]] = {}
125
+ for device in root.findall(".//DEVICES/DEVICE"):
126
+ workspace = device.find("./WORKSPACE")
127
+ if workspace is None:
128
+ continue
129
+ buckets.setdefault(_device_type(device), []).append(workspace)
130
+ return buckets
131
+
132
+
133
+ def _normalize_workspace_physical(device: ET.Element, requested_type: str, reference_workspaces: dict[str, list[ET.Element]], usage_index: int) -> None:
134
+ candidates = reference_workspaces.get(requested_type, [])
135
+ if not candidates:
136
+ return
137
+ reference = copy.deepcopy(candidates[(usage_index - 1) % len(candidates)])
138
+ target_workspace = device.find("./WORKSPACE")
139
+ if target_workspace is None:
140
+ return
141
+
142
+ logical = target_workspace.find("./LOGICAL")
143
+ logical_copy = copy.deepcopy(logical) if logical is not None else None
144
+ target_workspace.clear()
145
+ for child in list(reference):
146
+ target_workspace.append(copy.deepcopy(child))
147
+ if logical_copy is not None:
148
+ logical_target = target_workspace.find("./LOGICAL")
149
+ if logical_target is not None:
150
+ target_workspace.remove(logical_target)
151
+ target_workspace.insert(0, logical_copy)
152
+
153
+
154
+ def _find_switch_config(device: ET.Element) -> ET.Element | None:
155
+ for node in device.findall(".//FILE_CONTENT/CONFIG"):
156
+ rendered = "\n".join(line.text or "" for line in node.findall("./LINE"))
157
+ if "interface FastEthernet0/1" in rendered:
158
+ return node
159
+ return None
160
+
161
+
162
+ def apply_host_ip(device: ET.Element, config: dict[str, Any]) -> None:
163
+ port = device.find("./ENGINE/MODULE/SLOT/MODULE/PORT")
164
+ if port is None:
165
+ return
166
+ for source_key, xml_key in [("ip", "IP"), ("mask", "SUBNET")]:
167
+ if source_key in config:
168
+ node = port.find(xml_key)
169
+ if node is not None:
170
+ node.text = str(config[source_key])
171
+ if "gw" in config:
172
+ node = device.find("./ENGINE/GATEWAY")
173
+ if node is not None:
174
+ node.text = str(config["gw"])
175
+ if "dns" in config:
176
+ node = device.find("./ENGINE/DNS_CLIENT/SERVER_IP")
177
+ if node is not None:
178
+ node.text = str(config["dns"])
179
+ port_dns = port.find("PORT_DNS")
180
+ if port_dns is not None:
181
+ port_dns.text = str(config["dns"])
182
+
183
+
184
+ def apply_router_config(device: ET.Element, lines: list[str]) -> None:
185
+ for target in [device.find("./ENGINE/RUNNINGCONFIG"), device.find("./ENGINE/STARTUPCONFIG")]:
186
+ if target is None:
187
+ continue
188
+ target.clear()
189
+ final_lines = [str(line) for line in lines]
190
+ if final_lines and final_lines[-1].strip().lower() != "end":
191
+ final_lines.append("end")
192
+ for line in final_lines:
193
+ node = ET.SubElement(target, "LINE")
194
+ node.text = line
195
+
196
+
197
+ def apply_switch_config(device: ET.Element, lines: list[str]) -> None:
198
+ target = _find_switch_config(device)
199
+ if target is None:
200
+ return
201
+ target.clear()
202
+ final_lines = [str(line) for line in lines]
203
+ if final_lines and final_lines[-1].strip().lower() != "end":
204
+ final_lines.append("end")
205
+ for line in final_lines:
206
+ node = ET.SubElement(target, "LINE")
207
+ node.text = line
208
+
209
+
210
+ def build_device_library(source_root: ET.Element) -> dict[str, list[ET.Element]]:
211
+ buckets: dict[str, list[ET.Element]] = {}
212
+ for device in source_root.findall(".//DEVICES/DEVICE"):
213
+ buckets.setdefault(_device_type(device), []).append(device)
214
+ return buckets
215
+
216
+
217
+ def _load_device_template(device_type: str, model: str | None = None) -> ET.Element | None:
218
+ normalized = normalize_device_type(device_type)
219
+ template_name = DEVICE_TEMPLATE_FILES.get(normalized)
220
+ if not template_name:
221
+ return None
222
+ template_path = TEMPLATE_DIR / template_name
223
+ if not template_path.exists():
224
+ return None
225
+ root = ET.fromstring(template_path.read_text(encoding="utf-8"))
226
+ actual_type = _device_type(root)
227
+ actual_model = _device_model(root)
228
+ if actual_type != normalized:
229
+ return None
230
+ if model and actual_model.lower() != str(model).lower():
231
+ return None
232
+ return root
233
+
234
+
235
+ def find_device_prototype(device_type: str, model: str | None, preferred_sample: SampleDescriptor) -> ET.Element:
236
+ return ET.fromstring(_find_device_prototype_xml(device_type, model, preferred_sample.path))
237
+
238
+
239
+ @lru_cache(maxsize=512)
240
+ def _find_device_prototype_xml(device_type: str, model: str | None, preferred_sample_path: str) -> str:
241
+ compatibility_root = generation_root_sample()
242
+ visited_paths: set[str] = set()
243
+ normalized_target = normalize_device_type(device_type)
244
+ wanted_model = (model or "").lower()
245
+ if strict_compatibility_mode():
246
+ candidate_paths = [compatibility_root]
247
+ else:
248
+ candidate_paths = [compatibility_root, preferred_sample_path]
249
+ candidate_paths.extend(sample.path for sample in load_catalog())
250
+ for candidate_path in candidate_paths:
251
+ if candidate_path in visited_paths:
252
+ continue
253
+ visited_paths.add(candidate_path)
254
+ root = _load_sample_root_cached(candidate_path)
255
+ for device in root.findall(".//DEVICES/DEVICE"):
256
+ if _device_type(device) != normalized_target:
257
+ continue
258
+ actual_model = _device_model(device).lower()
259
+ if wanted_model and actual_model != wanted_model:
260
+ continue
261
+ return ET.tostring(device, encoding="unicode")
262
+ if strict_compatibility_mode():
263
+ donor = require_packet_tracer_compatibility_donor()
264
+ target = f"{device_type} with model {model}" if model else device_type
265
+ raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype for {target}")
266
+ template = _load_device_template(normalized_target, model)
267
+ if template is not None:
268
+ return ET.tostring(template, encoding="unicode")
269
+ if model:
270
+ raise ValueError(f"No prototype found for device type {device_type} with model {model}")
271
+ raise ValueError(f"No prototype found for device type {device_type}")
272
+
273
+
274
+ def infer_default_links(devices: list[dict[str, Any]]) -> list[dict[str, Any]]:
275
+ routers = [device for device in devices if device["type"] == "Router"]
276
+ switches = [device for device in devices if device["type"] == "Switch"]
277
+ hosts = [device for device in devices if device["type"] in {"PC", "Server"}]
278
+ links: list[dict[str, Any]] = []
279
+ if switches:
280
+ uplink_switch = switches[0]
281
+ for index, host in enumerate(hosts, start=1):
282
+ links.append({"a": {"dev": host["name"], "port": "FastEthernet0"}, "b": {"dev": uplink_switch["name"], "port": f"FastEthernet0/{min(index, 24)}"}, "media": "copper"})
283
+ if routers:
284
+ links.append({"a": {"dev": uplink_switch["name"], "port": "FastEthernet0/24"}, "b": {"dev": routers[0]["name"], "port": "FastEthernet0/0"}, "media": "copper"})
285
+ for prev, nxt in zip(switches, switches[1:]):
286
+ links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/23"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/24"}, "media": "copper"})
287
+ elif len(routers) >= 2:
288
+ for prev, nxt in zip(routers, routers[1:]):
289
+ links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/0"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/1"}, "media": "copper"})
290
+ return links
291
+
292
+
293
+ def _effective_link_type(device_type: str) -> str:
294
+ normalized = normalize_device_type(device_type)
295
+ if normalized in GENERIC_COPPER_HOST_TYPES:
296
+ return "PC"
297
+ return normalized
298
+
299
+
300
+ def _prototype_link_by_pair(source_sample_path: str | Path, left_type: str, right_type: str) -> ET.Element:
301
+ return ET.fromstring(_prototype_link_xml(str(source_sample_path), left_type, right_type))
302
+
303
+
304
+ @lru_cache(maxsize=512)
305
+ def _prototype_link_xml(source_sample_path: str, left_type: str, right_type: str) -> str:
306
+ compatibility_root = generation_root_sample()
307
+ if strict_compatibility_mode():
308
+ candidate_paths = [str(compatibility_root)]
309
+ else:
310
+ candidate_paths = [str(compatibility_root), str(source_sample_path), str(resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE))]
311
+ candidate_paths.extend(sample.path for sample in load_catalog())
312
+ visited_paths: set[str] = set()
313
+ wanted_left = _effective_link_type(left_type)
314
+ wanted_right = _effective_link_type(right_type)
315
+ for candidate in candidate_paths:
316
+ root_key = str(candidate)
317
+ if root_key in visited_paths:
318
+ continue
319
+ visited_paths.add(root_key)
320
+ root = _load_sample_root_cached(root_key)
321
+ devices = root.findall(".//DEVICES/DEVICE")
322
+ index_to_type = {str(index): _effective_link_type(_device_type(device)) for index, device in enumerate(devices)}
323
+ save_ref_to_type = {
324
+ device.findtext("./ENGINE/SAVE_REF_ID", default=""): _effective_link_type(_device_type(device))
325
+ for device in devices
326
+ if device.findtext("./ENGINE/SAVE_REF_ID", default="")
327
+ }
328
+ for link in root.findall(".//LINKS/LINK"):
329
+ cable = link.find("./CABLE")
330
+ if cable is None:
331
+ continue
332
+ from_ref = cable.findtext("FROM", default="")
333
+ to_ref = cable.findtext("TO", default="")
334
+ from_type = save_ref_to_type.get(from_ref) or index_to_type.get(from_ref)
335
+ to_type = save_ref_to_type.get(to_ref) or index_to_type.get(to_ref)
336
+ if from_type == wanted_left and to_type == wanted_right:
337
+ return ET.tostring(link, encoding="unicode")
338
+ if from_type == wanted_right and to_type == wanted_left:
339
+ return ET.tostring(link, encoding="unicode")
340
+ if strict_compatibility_mode():
341
+ donor = require_packet_tracer_compatibility_donor()
342
+ raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype link for {left_type} <-> {right_type}")
343
+ raise ValueError(f"No prototype link found for type pair {left_type} <-> {right_type}")
344
+
345
+
346
+ def apply_cable_type(cable: ET.Element, media: str) -> None:
347
+ media_key = media.lower()
348
+ node = cable.find("TYPE")
349
+ if node is None:
350
+ return
351
+ mapping = {
352
+ "copper": "eStraightThrough",
353
+ "straight-through": "eStraightThrough",
354
+ "straight": "eStraightThrough",
355
+ "crossover": "eCrossOver",
356
+ "cross-over": "eCrossOver",
357
+ "serial": "eSerialDCE",
358
+ "fiber": "eFiber",
359
+ }
360
+ node.text = mapping.get(media_key, node.text or "eStraightThrough")
361
+
362
+
363
+ def _port_nodes(device: ET.Element) -> list[ET.Element]:
364
+ return [port for port in device.findall(".//PORT") if port.findtext("TYPE", "").startswith("eCopper")]
365
+
366
+
367
+ def _parse_port_index(port_name: str) -> int | None:
368
+ match = re.search(r"(\d+)$", port_name)
369
+ return int(match.group(1)) if match else None
370
+
371
+
372
+ def _canonical_port_name(port_name: str) -> str:
373
+ lowered = port_name.strip()
374
+ if lowered.lower().startswith("fa"):
375
+ return "FastEthernet" + lowered[2:]
376
+ if lowered.lower().startswith("gi"):
377
+ return "GigabitEthernet" + lowered[2:]
378
+ return lowered
379
+
380
+
381
+ def _port_address_for_name(device: ET.Element, port_name: str) -> str | None:
382
+ canonical = _canonical_port_name(port_name)
383
+ fast_nodes = [port for port in _port_nodes(device) if "FastEthernet" in port.findtext("TYPE", "")]
384
+ gig_nodes = [port for port in _port_nodes(device) if "GigabitEthernet" in port.findtext("TYPE", "")]
385
+ target: ET.Element | None = None
386
+ if canonical == "FastEthernet0":
387
+ target = fast_nodes[0] if fast_nodes else None
388
+ elif canonical.startswith("FastEthernet"):
389
+ index = _parse_port_index(canonical)
390
+ if index is not None and fast_nodes:
391
+ if _device_type(device) == "Router":
392
+ target = fast_nodes[index] if index < len(fast_nodes) else None
393
+ else:
394
+ target = fast_nodes[index - 1] if 0 < index <= len(fast_nodes) else None
395
+ elif canonical.startswith("GigabitEthernet"):
396
+ index = _parse_port_index(canonical)
397
+ if index is not None and gig_nodes:
398
+ if _device_type(device) == "Router" and "/" in canonical and canonical.count("/") == 1:
399
+ target = gig_nodes[index] if index < len(gig_nodes) else None
400
+ else:
401
+ target = gig_nodes[index - 1] if 0 < index <= len(gig_nodes) else None
402
+ if target is None:
403
+ return None
404
+ return target.findtext("MEM_ADDR")
405
+
406
+
407
+ def _sanitize_generated_runtime_sections(root: ET.Element) -> None:
408
+ scenario_set = root.find("./SCENARIOSET")
409
+ if scenario_set is not None:
410
+ scenario_set.clear()
411
+ scenario = ET.SubElement(scenario_set, "SCENARIO")
412
+ name = ET.SubElement(scenario, "NAME")
413
+ name.set("translate", "true")
414
+ name.text = "Scenario 0"
415
+ description = ET.SubElement(scenario, "DESCRIPTION")
416
+ description.set("translate", "true")
417
+ command_logs = root.find("./COMMAND_LOGS")
418
+ if command_logs is not None:
419
+ command_logs.clear()
420
+ ceps = root.find("./CEPS")
421
+ if ceps is not None:
422
+ ceps.clear()
423
+
424
+
425
+ def transform_from_blueprint(blueprint: dict[str, Any], sample: SampleDescriptor) -> bytes:
426
+ source_root = load_sample_root(sample.path)
427
+ root_sample_path = generation_root_sample()
428
+ target_root = copy.deepcopy(load_sample_root(root_sample_path))
429
+ version_node = target_root.find("./VERSION")
430
+ if version_node is not None:
431
+ version_node.text = generation_root_version()
432
+ devices_parent = target_root.find(".//DEVICES")
433
+ links_parent = target_root.find(".//LINKS")
434
+ if devices_parent is None or links_parent is None:
435
+ raise ValueError("Sample XML is missing DEVICES or LINKS")
436
+ devices_parent.clear()
437
+ links_parent.clear()
438
+ for tag in ["LINES", "RECTANGLES", "ELLIPSES", "POLYGONS", "GEOVIEW_GRAPHICSITEMS", "NOTES"]:
439
+ node = target_root.find(tag)
440
+ if node is not None:
441
+ node.clear()
442
+ compatibility_mode = strict_compatibility_mode()
443
+ library_source_root = load_sample_root(root_sample_path) if compatibility_mode else source_root
444
+ reference_workspaces = _build_workspace_reference_library(load_sample_root(root_sample_path))
445
+
446
+ device_library = build_device_library(library_source_root)
447
+ requested_devices = blueprint.get("devices", [])
448
+ if not requested_devices:
449
+ raise ValueError("Blueprint must include devices")
450
+ requested_links = blueprint.get("links") or infer_default_links(requested_devices)
451
+ configs = blueprint.get("configs", {})
452
+
453
+ built_devices: list[ET.Element] = []
454
+ name_to_device: dict[str, ET.Element] = {}
455
+ name_to_index: dict[str, int] = {}
456
+ type_usage: dict[str, int] = {}
457
+ workspace_usage: dict[str, int] = {}
458
+
459
+ for requested in requested_devices:
460
+ requested_type = normalize_device_type(str(requested["type"]))
461
+ requested_model = requested.get("model")
462
+ prototypes = device_library.get(requested_type, [])
463
+ use_index = type_usage.get(requested_type, 0)
464
+ workspace_index = workspace_usage.get(requested_type, 0) + 1
465
+ workspace_usage[requested_type] = workspace_index
466
+ matching_prototypes = prototypes
467
+ if requested_model:
468
+ matching_prototypes = [prototype for prototype in prototypes if _device_model(prototype).lower() == str(requested_model).lower()]
469
+ if use_index < len(matching_prototypes):
470
+ built = copy.deepcopy(matching_prototypes[use_index])
471
+ type_usage[requested_type] = use_index + 1
472
+ else:
473
+ if compatibility_mode:
474
+ donor = require_packet_tracer_compatibility_donor()
475
+ target = f"{requested_type} with model {requested_model}" if requested_model else requested_type
476
+ raise ValueError(
477
+ f"Strict 9.0 donor {donor} has only {len(matching_prototypes)} prototype(s) for {target}; requested more devices than donor supports."
478
+ )
479
+ if requested_model:
480
+ built = copy.deepcopy(find_device_prototype(requested_type, str(requested_model), sample))
481
+ else:
482
+ built = copy.deepcopy(find_device_prototype(requested_type, None, sample))
483
+ name = str(requested["name"])
484
+ _set_device_name(built, name)
485
+ if requested_model:
486
+ _set_device_model(built, str(requested_model))
487
+ default_x, default_y = _logical_position(built)
488
+ _set_position(built, int(requested.get("x", default_x)), int(requested.get("y", default_y)))
489
+ if not compatibility_mode:
490
+ _normalize_workspace_physical(built, requested_type, reference_workspaces, workspace_index)
491
+ _set_workspace_ids(built, len(built_devices) + 1)
492
+ _set_runtime_ids(built, len(built_devices) + 1)
493
+ device_config = configs.get(name, {})
494
+ if requested_type in {"PC", "Server"} and isinstance(device_config, dict):
495
+ apply_host_ip(built, device_config)
496
+ elif requested_type == "Router" and isinstance(device_config, list):
497
+ apply_router_config(built, device_config)
498
+ elif requested_type == "Switch" and isinstance(device_config, list):
499
+ apply_switch_config(built, device_config)
500
+ name_to_index[name] = len(built_devices)
501
+ name_to_device[name] = built
502
+ built_devices.append(built)
503
+ devices_parent.append(built)
504
+
505
+ requested_lookup = {str(device["name"]): normalize_device_type(str(device["type"])) for device in requested_devices}
506
+ for requested_link in requested_links:
507
+ left_name = str(requested_link["a"]["dev"])
508
+ right_name = str(requested_link["b"]["dev"])
509
+ prototype = copy.deepcopy(_prototype_link_by_pair(sample.path, requested_lookup[left_name], requested_lookup[right_name]))
510
+ cable = prototype.find("./CABLE")
511
+ if cable is None:
512
+ continue
513
+ from_device = name_to_device[left_name]
514
+ to_device = name_to_device[right_name]
515
+ if compatibility_mode:
516
+ cable.find("FROM").text = from_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[left_name]))
517
+ cable.find("TO").text = to_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[right_name]))
518
+ else:
519
+ cable.find("FROM").text = str(name_to_index[left_name])
520
+ cable.find("TO").text = str(name_to_index[right_name])
521
+ ports = cable.findall("PORT")
522
+ if len(ports) >= 2:
523
+ ports[0].text = str(requested_link["a"]["port"])
524
+ ports[1].text = str(requested_link["b"]["port"])
525
+ mappings = [
526
+ ("FROM_DEVICE_MEM_ADDR", from_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
527
+ ("TO_DEVICE_MEM_ADDR", to_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
528
+ ("FROM_PORT_MEM_ADDR", _port_address_for_name(from_device, str(requested_link["a"]["port"]))),
529
+ ("TO_PORT_MEM_ADDR", _port_address_for_name(to_device, str(requested_link["b"]["port"]))),
530
+ ]
531
+ for node_name, value in mappings:
532
+ node = cable.find(node_name)
533
+ if node is not None and value:
534
+ node.text = value
535
+ apply_cable_type(cable, str(requested_link.get("media", "copper")))
536
+ links_parent.append(prototype)
537
+
538
+ _sanitize_generated_runtime_sections(target_root)
539
+ sanitize_generated_physical_workspace(target_root)
540
+ validate_workspace_integrity(target_root)
541
+ return ET.tostring(target_root, encoding="utf-8", xml_declaration=False)