packet-tracer-skill 0.2.2 → 0.3.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.
- package/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -1,727 +1,1072 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import copy
|
|
4
|
-
from
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
serial.text
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def
|
|
412
|
-
return ET.fromstring(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
"
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from packet_tracer_env import get_packet_tracer_compatibility_donor, get_packet_tracer_target_version, require_packet_tracer_compatibility_donor, resolve_sample_path
|
|
12
|
+
from pkt_codec import decode_pkt_modern
|
|
13
|
+
from sample_catalog import SampleDescriptor, load_catalog, normalize_device_type
|
|
14
|
+
from workspace_repair import sanitize_generated_physical_workspace, validate_workspace_integrity
|
|
15
|
+
|
|
16
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
17
|
+
SKILL_ROOT = SCRIPT_DIR.parent
|
|
18
|
+
TEMPLATE_DIR = SKILL_ROOT / "templates" / "pt900" / "device_library"
|
|
19
|
+
BASE_TEMPLATE_ROOT = SKILL_ROOT / "templates" / "pt900" / "base_empty.xml"
|
|
20
|
+
FALLBACK_PROTOTYPE_SAMPLE = r"01 Networking\FTP\FTP.pkt"
|
|
21
|
+
DEVICE_TEMPLATE_FILES = {
|
|
22
|
+
"PC": "pc.xml",
|
|
23
|
+
"Printer": "printer.xml",
|
|
24
|
+
"Router": "router.xml",
|
|
25
|
+
"Switch": "switch.xml",
|
|
26
|
+
}
|
|
27
|
+
GENERIC_COPPER_HOST_TYPES = {"PC", "Server", "Printer", "Laptop"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_sample_root(sample_path: str | Path) -> ET.Element:
|
|
31
|
+
return copy.deepcopy(_load_sample_root_cached(str(sample_path)))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=512)
|
|
35
|
+
def _load_sample_root_cached(sample_path: str) -> ET.Element:
|
|
36
|
+
path = Path(sample_path)
|
|
37
|
+
if path.suffix.lower() == ".xml":
|
|
38
|
+
xml_bytes = path.read_bytes()
|
|
39
|
+
else:
|
|
40
|
+
xml_bytes = decode_pkt_modern(path.read_bytes())
|
|
41
|
+
return ET.fromstring(xml_bytes)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=1)
|
|
45
|
+
def generation_root_sample() -> str:
|
|
46
|
+
donor = get_packet_tracer_compatibility_donor()
|
|
47
|
+
if donor is not None:
|
|
48
|
+
return str(donor)
|
|
49
|
+
try:
|
|
50
|
+
fallback = resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
fallback = None
|
|
53
|
+
if fallback is not None and fallback.exists():
|
|
54
|
+
return str(fallback)
|
|
55
|
+
return str(BASE_TEMPLATE_ROOT)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generation_root_version() -> str:
|
|
59
|
+
donor = get_packet_tracer_compatibility_donor()
|
|
60
|
+
if donor is not None:
|
|
61
|
+
root = _load_sample_root_cached(str(donor))
|
|
62
|
+
return root.findtext("./VERSION", default=get_packet_tracer_target_version())
|
|
63
|
+
return get_packet_tracer_target_version()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def strict_compatibility_mode() -> bool:
|
|
67
|
+
root_sample = Path(generation_root_sample())
|
|
68
|
+
return root_sample.suffix.lower() == ".pkt" and generation_root_version().startswith("9.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _device_type(device: ET.Element) -> str:
|
|
72
|
+
return normalize_device_type(device.findtext("./ENGINE/TYPE", default=""))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _device_model(device: ET.Element) -> str:
|
|
76
|
+
node = device.find("./ENGINE/TYPE")
|
|
77
|
+
return node.get("model", "") if node is not None else ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _logical_position(device: ET.Element) -> tuple[int, int]:
|
|
81
|
+
x_node = device.find("./WORKSPACE/LOGICAL/X")
|
|
82
|
+
y_node = device.find("./WORKSPACE/LOGICAL/Y")
|
|
83
|
+
x_val = float(x_node.text) if x_node is not None and x_node.text else 200.0
|
|
84
|
+
y_val = float(y_node.text) if y_node is not None and y_node.text else 200.0
|
|
85
|
+
return int(round(x_val)), int(round(y_val))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _set_device_name(device: ET.Element, new_name: str) -> None:
|
|
89
|
+
node = device.find("./ENGINE/NAME")
|
|
90
|
+
if node is not None:
|
|
91
|
+
node.text = new_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _set_device_model(device: ET.Element, model: str) -> None:
|
|
95
|
+
node = device.find("./ENGINE/TYPE")
|
|
96
|
+
if node is not None:
|
|
97
|
+
node.set("model", model)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _set_position(device: ET.Element, x_pos: int, y_pos: int) -> None:
|
|
101
|
+
x_node = device.find("./WORKSPACE/LOGICAL/X")
|
|
102
|
+
y_node = device.find("./WORKSPACE/LOGICAL/Y")
|
|
103
|
+
if x_node is not None:
|
|
104
|
+
x_node.text = str(x_pos)
|
|
105
|
+
if y_node is not None:
|
|
106
|
+
y_node.text = str(y_pos)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _set_workspace_ids(device: ET.Element, index: int) -> None:
|
|
110
|
+
mem_node = device.find("./WORKSPACE/LOGICAL/MEM_ADDR")
|
|
111
|
+
dev_node = device.find("./WORKSPACE/LOGICAL/DEV_ADDR")
|
|
112
|
+
if mem_node is not None:
|
|
113
|
+
mem_node.text = str(3_000_000_000_000 + index * 32)
|
|
114
|
+
if dev_node is not None:
|
|
115
|
+
dev_node.text = str(3_100_000_000_000 + index * 32)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _set_runtime_ids(device: ET.Element, index: int) -> None:
|
|
119
|
+
save_ref = device.find("./ENGINE/SAVE_REF_ID")
|
|
120
|
+
serial = device.find("./ENGINE/SERIALNUMBER")
|
|
121
|
+
start_time = device.find("./ENGINE/STARTTIME")
|
|
122
|
+
coord_x = device.find("./ENGINE/COORD_SETTINGS/X_COORD")
|
|
123
|
+
coord_y = device.find("./ENGINE/COORD_SETTINGS/Y_COORD")
|
|
124
|
+
logical_x = device.findtext("./WORKSPACE/LOGICAL/X", default="")
|
|
125
|
+
logical_y = device.findtext("./WORKSPACE/LOGICAL/Y", default="")
|
|
126
|
+
if save_ref is not None:
|
|
127
|
+
save_ref.text = f"save-ref-id:{9_000_000_000_000_000_000 + index}"
|
|
128
|
+
if serial is not None:
|
|
129
|
+
prefix = (serial.text or "AUTO").split("-")[0][:8] or "AUTO"
|
|
130
|
+
serial.text = f"{prefix}{index:04d}-"
|
|
131
|
+
if start_time is not None:
|
|
132
|
+
start_time.text = str(800_000_000_000 + index)
|
|
133
|
+
if coord_x is not None and logical_x:
|
|
134
|
+
coord_x.text = logical_x
|
|
135
|
+
if coord_y is not None and logical_y:
|
|
136
|
+
coord_y.text = logical_y
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_workspace_reference_library(root: ET.Element) -> dict[str, list[ET.Element]]:
|
|
140
|
+
buckets: dict[str, list[ET.Element]] = {}
|
|
141
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
142
|
+
workspace = device.find("./WORKSPACE")
|
|
143
|
+
if workspace is None:
|
|
144
|
+
continue
|
|
145
|
+
buckets.setdefault(_device_type(device), []).append(workspace)
|
|
146
|
+
return buckets
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_workspace_physical(device: ET.Element, requested_type: str, reference_workspaces: dict[str, list[ET.Element]], usage_index: int) -> None:
|
|
150
|
+
candidates = reference_workspaces.get(requested_type, [])
|
|
151
|
+
if not candidates:
|
|
152
|
+
return
|
|
153
|
+
reference = copy.deepcopy(candidates[(usage_index - 1) % len(candidates)])
|
|
154
|
+
target_workspace = device.find("./WORKSPACE")
|
|
155
|
+
if target_workspace is None:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
logical = target_workspace.find("./LOGICAL")
|
|
159
|
+
logical_copy = copy.deepcopy(logical) if logical is not None else None
|
|
160
|
+
target_workspace.clear()
|
|
161
|
+
for child in list(reference):
|
|
162
|
+
target_workspace.append(copy.deepcopy(child))
|
|
163
|
+
if logical_copy is not None:
|
|
164
|
+
logical_target = target_workspace.find("./LOGICAL")
|
|
165
|
+
if logical_target is not None:
|
|
166
|
+
target_workspace.remove(logical_target)
|
|
167
|
+
target_workspace.insert(0, logical_copy)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _find_switch_config(device: ET.Element) -> ET.Element | None:
|
|
171
|
+
for node in device.findall(".//FILE_CONTENT/CONFIG"):
|
|
172
|
+
rendered = "\n".join(line.text or "" for line in node.findall("./LINE"))
|
|
173
|
+
if "interface FastEthernet0/1" in rendered:
|
|
174
|
+
return node
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ensure_text(parent: ET.Element, tag: str, value: str) -> None:
|
|
179
|
+
"""Set a child element's text, creating the element if it is absent."""
|
|
180
|
+
node = parent.find(tag)
|
|
181
|
+
if node is None:
|
|
182
|
+
node = ET.SubElement(parent, tag)
|
|
183
|
+
node.text = value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def apply_host_ip(device: ET.Element, config: dict[str, Any]) -> None:
|
|
187
|
+
"""Set a host's addressing.
|
|
188
|
+
|
|
189
|
+
A static address on a port still marked `PORT_DHCP_ENABLE=true` is ignored:
|
|
190
|
+
Packet Tracer keeps asking for a lease and the interface reports 0.0.0.0.
|
|
191
|
+
Measured against a live Packet Tracer -- the file carried 192.168.1.20 and
|
|
192
|
+
the device reported nothing, which opening the file could never reveal.
|
|
193
|
+
"""
|
|
194
|
+
port = device.find("./ENGINE/MODULE/SLOT/MODULE/PORT")
|
|
195
|
+
if port is None:
|
|
196
|
+
return
|
|
197
|
+
if config.get("ip"):
|
|
198
|
+
for node in device.findall(".//PORT"):
|
|
199
|
+
if node.find("PORT_DHCP_ENABLE") is not None:
|
|
200
|
+
_ensure_text(node, "PORT_DHCP_ENABLE", "false")
|
|
201
|
+
for source_key, xml_key in [("ip", "IP"), ("mask", "SUBNET")]:
|
|
202
|
+
if source_key in config:
|
|
203
|
+
node = port.find(xml_key)
|
|
204
|
+
if node is not None:
|
|
205
|
+
node.text = str(config[source_key])
|
|
206
|
+
if "gw" in config:
|
|
207
|
+
# A host stores its gateway twice: once on the device and once on the
|
|
208
|
+
# port. Writing only the device copy left the port holding the donor's
|
|
209
|
+
# value -- a lab addressed 192.168.1.21 with the port still pointing at
|
|
210
|
+
# 2.1.1.1, which no file-level check notices.
|
|
211
|
+
engine = device.find("./ENGINE")
|
|
212
|
+
_ensure_text(device if engine is None else engine, "GATEWAY", str(config["gw"]))
|
|
213
|
+
_ensure_text(port, "PORT_GATEWAY", str(config["gw"]))
|
|
214
|
+
if "dns" in config:
|
|
215
|
+
node = device.find("./ENGINE/DNS_CLIENT/SERVER_IP")
|
|
216
|
+
if node is not None:
|
|
217
|
+
node.text = str(config["dns"])
|
|
218
|
+
port_dns = port.find("PORT_DNS")
|
|
219
|
+
if port_dns is not None:
|
|
220
|
+
port_dns.text = str(config["dns"])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def apply_router_config(device: ET.Element, lines: list[str]) -> None:
|
|
224
|
+
for target in [device.find("./ENGINE/RUNNINGCONFIG"), device.find("./ENGINE/STARTUPCONFIG")]:
|
|
225
|
+
if target is None:
|
|
226
|
+
continue
|
|
227
|
+
target.clear()
|
|
228
|
+
final_lines = [str(line) for line in lines]
|
|
229
|
+
if final_lines and final_lines[-1].strip().lower() != "end":
|
|
230
|
+
final_lines.append("end")
|
|
231
|
+
for line in final_lines:
|
|
232
|
+
node = ET.SubElement(target, "LINE")
|
|
233
|
+
node.text = line
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def apply_switch_config(device: ET.Element, lines: list[str]) -> None:
|
|
237
|
+
target = _find_switch_config(device)
|
|
238
|
+
if target is None:
|
|
239
|
+
return
|
|
240
|
+
target.clear()
|
|
241
|
+
final_lines = [str(line) for line in lines]
|
|
242
|
+
if final_lines and final_lines[-1].strip().lower() != "end":
|
|
243
|
+
final_lines.append("end")
|
|
244
|
+
for line in final_lines:
|
|
245
|
+
node = ET.SubElement(target, "LINE")
|
|
246
|
+
node.text = line
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_device_library(source_root: ET.Element) -> dict[str, list[ET.Element]]:
|
|
250
|
+
buckets: dict[str, list[ET.Element]] = {}
|
|
251
|
+
for device in source_root.findall(".//DEVICES/DEVICE"):
|
|
252
|
+
buckets.setdefault(_device_type(device), []).append(device)
|
|
253
|
+
return buckets
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _load_device_template(device_type: str, model: str | None = None) -> ET.Element | None:
|
|
257
|
+
normalized = normalize_device_type(device_type)
|
|
258
|
+
template_name = DEVICE_TEMPLATE_FILES.get(normalized, f"{normalized.lower()}.xml")
|
|
259
|
+
if not template_name:
|
|
260
|
+
return None
|
|
261
|
+
template_path = TEMPLATE_DIR / template_name
|
|
262
|
+
if not template_path.exists():
|
|
263
|
+
return None
|
|
264
|
+
root = ET.fromstring(template_path.read_text(encoding="utf-8"))
|
|
265
|
+
if root.find("./WORKSPACE") is None and root.find("./POSITION") is not None:
|
|
266
|
+
root = _synthesize_template_device(root)
|
|
267
|
+
actual_type = _device_type(root)
|
|
268
|
+
actual_model = _device_model(root)
|
|
269
|
+
if actual_type != normalized and not (not actual_type and actual_model):
|
|
270
|
+
return None
|
|
271
|
+
if model and actual_model.lower() != str(model).lower():
|
|
272
|
+
return None
|
|
273
|
+
return root
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _template_text(node: ET.Element | None, default: str) -> str:
|
|
277
|
+
if node is None or node.text is None:
|
|
278
|
+
return default
|
|
279
|
+
value = node.text.strip()
|
|
280
|
+
if not value or (value.startswith("__") and value.endswith("__")):
|
|
281
|
+
return default
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _synthesize_template_device(template_root: ET.Element) -> ET.Element:
|
|
286
|
+
engine_type = template_root.find("./ENGINE/TYPE")
|
|
287
|
+
device_type = normalize_device_type(template_root.findtext("./ENGINE/TYPE", default=""))
|
|
288
|
+
model = engine_type.get("model", "") if engine_type is not None else ""
|
|
289
|
+
device_name = _template_text(template_root.find("./ENGINE/NAME"), f"{device_type}0")
|
|
290
|
+
x_pos = _template_text(template_root.find("./POSITION/X"), "200")
|
|
291
|
+
y_pos = _template_text(template_root.find("./POSITION/Y"), "200")
|
|
292
|
+
|
|
293
|
+
device = ET.Element("DEVICE")
|
|
294
|
+
engine = ET.SubElement(device, "ENGINE")
|
|
295
|
+
type_node = ET.SubElement(engine, "TYPE")
|
|
296
|
+
type_node.text = device_type
|
|
297
|
+
type_node.set("model", model)
|
|
298
|
+
type_node.set("customModel", "")
|
|
299
|
+
name_node = ET.SubElement(engine, "NAME")
|
|
300
|
+
name_node.set("translate", "true")
|
|
301
|
+
name_node.text = device_name
|
|
302
|
+
save_ref = ET.SubElement(engine, "SAVE_REF_ID")
|
|
303
|
+
save_ref.text = "save-ref-id:template-0"
|
|
304
|
+
serial = ET.SubElement(engine, "SERIALNUMBER")
|
|
305
|
+
serial.text = "TEMPLATE0000-"
|
|
306
|
+
start_time = ET.SubElement(engine, "STARTTIME")
|
|
307
|
+
start_time.text = "800000000000"
|
|
308
|
+
coord = ET.SubElement(engine, "COORD_SETTINGS")
|
|
309
|
+
coord_x = ET.SubElement(coord, "X_COORD")
|
|
310
|
+
coord_x.text = x_pos
|
|
311
|
+
coord_y = ET.SubElement(coord, "Y_COORD")
|
|
312
|
+
coord_y.text = y_pos
|
|
313
|
+
coord_z = ET.SubElement(coord, "Z_COORD")
|
|
314
|
+
coord_z.text = "0"
|
|
315
|
+
|
|
316
|
+
port_specs = [
|
|
317
|
+
(
|
|
318
|
+
port.get("name", f"Port{index}"),
|
|
319
|
+
port.get("kind", "eCopperFastEthernet"),
|
|
320
|
+
str(4_000_000_000_000 + index * 64),
|
|
321
|
+
)
|
|
322
|
+
for index, port in enumerate(template_root.findall("./PORTS/PORT"), start=1)
|
|
323
|
+
]
|
|
324
|
+
if not port_specs:
|
|
325
|
+
port_specs = [("FastEthernet0", "eCopperFastEthernet", "4000000000064")]
|
|
326
|
+
|
|
327
|
+
if device_type in GENERIC_COPPER_HOST_TYPES:
|
|
328
|
+
module = ET.SubElement(engine, "MODULE")
|
|
329
|
+
module_type = ET.SubElement(module, "TYPE")
|
|
330
|
+
module_type.text = "eNonRemovableModule"
|
|
331
|
+
slot = ET.SubElement(module, "SLOT")
|
|
332
|
+
slot_type = ET.SubElement(slot, "TYPE")
|
|
333
|
+
slot_type.text = "ePtHostModule"
|
|
334
|
+
slot_module = ET.SubElement(slot, "MODULE")
|
|
335
|
+
slot_module_type = ET.SubElement(slot_module, "TYPE")
|
|
336
|
+
slot_module_type.text = "ePtHostModule"
|
|
337
|
+
slot_module_model = ET.SubElement(slot_module, "MODEL")
|
|
338
|
+
slot_module_model.text = "PT-HOST-NM-1CFE"
|
|
339
|
+
port_name, port_kind, port_mem = port_specs[0]
|
|
340
|
+
port = ET.SubElement(slot_module, "PORT")
|
|
341
|
+
port_type = ET.SubElement(port, "TYPE")
|
|
342
|
+
port_type.text = port_kind
|
|
343
|
+
port_mem_node = ET.SubElement(port, "MEM_ADDR")
|
|
344
|
+
port_mem_node.text = port_mem
|
|
345
|
+
ip_node = ET.SubElement(port, "IP")
|
|
346
|
+
ip_node.text = _template_text(template_root.find("./CONFIG/IP"), "")
|
|
347
|
+
mask_node = ET.SubElement(port, "SUBNET")
|
|
348
|
+
mask_node.text = _template_text(template_root.find("./CONFIG/MASK"), "")
|
|
349
|
+
gateway_node = ET.SubElement(port, "PORT_GATEWAY")
|
|
350
|
+
gateway_node.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
|
|
351
|
+
dns_node = ET.SubElement(port, "PORT_DNS")
|
|
352
|
+
dns_node.text = _template_text(template_root.find("./CONFIG/DNS"), "")
|
|
353
|
+
port_name_node = ET.SubElement(port, "NAME")
|
|
354
|
+
port_name_node.text = port_name
|
|
355
|
+
gateway = ET.SubElement(engine, "GATEWAY")
|
|
356
|
+
gateway.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
|
|
357
|
+
dns_client = ET.SubElement(engine, "DNS_CLIENT")
|
|
358
|
+
dns_server_ip = ET.SubElement(dns_client, "SERVER_IP")
|
|
359
|
+
dns_server_ip.text = _template_text(template_root.find("./CONFIG/DNS"), "")
|
|
360
|
+
else:
|
|
361
|
+
ports_parent = ET.SubElement(engine, "PORTS")
|
|
362
|
+
for port_name, port_kind, port_mem in port_specs:
|
|
363
|
+
port = ET.SubElement(ports_parent, "PORT")
|
|
364
|
+
port_type = ET.SubElement(port, "TYPE")
|
|
365
|
+
port_type.text = port_kind
|
|
366
|
+
port_mem_node = ET.SubElement(port, "MEM_ADDR")
|
|
367
|
+
port_mem_node.text = port_mem
|
|
368
|
+
port_name_node = ET.SubElement(port, "NAME")
|
|
369
|
+
port_name_node.text = port_name
|
|
370
|
+
|
|
371
|
+
if device_type == "Router":
|
|
372
|
+
for tag in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
373
|
+
config_root = ET.SubElement(engine, tag)
|
|
374
|
+
for line in (f"hostname {device_name}", "end"):
|
|
375
|
+
line_node = ET.SubElement(config_root, "LINE")
|
|
376
|
+
line_node.text = line
|
|
377
|
+
if device_type == "Switch":
|
|
378
|
+
file_content = ET.SubElement(engine, "FILE_CONTENT")
|
|
379
|
+
config = ET.SubElement(file_content, "CONFIG")
|
|
380
|
+
for line in (f"hostname {device_name}", "interface FastEthernet0/1", "end"):
|
|
381
|
+
line_node = ET.SubElement(config, "LINE")
|
|
382
|
+
line_node.text = line
|
|
383
|
+
|
|
384
|
+
workspace = ET.SubElement(device, "WORKSPACE")
|
|
385
|
+
logical = ET.SubElement(workspace, "LOGICAL")
|
|
386
|
+
logical_x = ET.SubElement(logical, "X")
|
|
387
|
+
logical_x.text = x_pos
|
|
388
|
+
logical_y = ET.SubElement(logical, "Y")
|
|
389
|
+
logical_y.text = y_pos
|
|
390
|
+
devcluster = ET.SubElement(logical, "DEVCLUSTERID")
|
|
391
|
+
devcluster.text = "1-1"
|
|
392
|
+
custom_logical = ET.SubElement(logical, "CUSTOM_IMAGE_LOGICAL")
|
|
393
|
+
custom_logical.text = ""
|
|
394
|
+
custom_physical = ET.SubElement(logical, "CUSTOM_IMAGE_PHYSICAL")
|
|
395
|
+
custom_physical.text = ""
|
|
396
|
+
mem_addr = ET.SubElement(logical, "MEM_ADDR")
|
|
397
|
+
mem_addr.text = "3000000000032"
|
|
398
|
+
dev_addr = ET.SubElement(logical, "DEV_ADDR")
|
|
399
|
+
dev_addr.text = "3100000000032"
|
|
400
|
+
physical = ET.SubElement(workspace, "PHYSICAL")
|
|
401
|
+
physical.set("translate", "true")
|
|
402
|
+
physical.text = f"Intercity,Home City,Corporate Office,Main Wiring Closet,Table,{device_name}"
|
|
403
|
+
return device
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _existing_sample_path(sample_path: str | Path) -> Path | None:
|
|
407
|
+
path = Path(sample_path)
|
|
408
|
+
return path if path.exists() else None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _generic_link_prototype() -> ET.Element:
|
|
412
|
+
return ET.fromstring(
|
|
413
|
+
"""
|
|
414
|
+
<LINK>
|
|
415
|
+
<TYPE>Cable</TYPE>
|
|
416
|
+
<CABLE>
|
|
417
|
+
<FROM>0</FROM>
|
|
418
|
+
<TO>1</TO>
|
|
419
|
+
<PORT>FastEthernet0</PORT>
|
|
420
|
+
<PORT>FastEthernet0/1</PORT>
|
|
421
|
+
<TYPE>eStraightThrough</TYPE>
|
|
422
|
+
<FROM_DEVICE_MEM_ADDR>0</FROM_DEVICE_MEM_ADDR>
|
|
423
|
+
<TO_DEVICE_MEM_ADDR>1</TO_DEVICE_MEM_ADDR>
|
|
424
|
+
<FROM_PORT_MEM_ADDR>0</FROM_PORT_MEM_ADDR>
|
|
425
|
+
<TO_PORT_MEM_ADDR>1</TO_PORT_MEM_ADDR>
|
|
426
|
+
<FUNCTIONAL>true</FUNCTIONAL>
|
|
427
|
+
<GEO_VIEW_COLOR>#6ba72e</GEO_VIEW_COLOR>
|
|
428
|
+
<IS_MANAGED_IN_RACK_VIEW>false</IS_MANAGED_IN_RACK_VIEW>
|
|
429
|
+
</CABLE>
|
|
430
|
+
</LINK>
|
|
431
|
+
""".strip()
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def find_device_prototype(device_type: str, model: str | None, preferred_sample: SampleDescriptor) -> ET.Element:
|
|
436
|
+
return ET.fromstring(_find_device_prototype_xml(device_type, model, preferred_sample.path))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@lru_cache(maxsize=512)
|
|
440
|
+
def _find_device_prototype_xml(device_type: str, model: str | None, preferred_sample_path: str) -> str:
|
|
441
|
+
compatibility_root = generation_root_sample()
|
|
442
|
+
visited_paths: set[str] = set()
|
|
443
|
+
normalized_target = normalize_device_type(device_type)
|
|
444
|
+
wanted_model = (model or "").lower()
|
|
445
|
+
if strict_compatibility_mode():
|
|
446
|
+
candidate_paths = [compatibility_root]
|
|
447
|
+
else:
|
|
448
|
+
candidate_paths = [compatibility_root, preferred_sample_path]
|
|
449
|
+
candidate_paths.extend(sample.path for sample in load_catalog())
|
|
450
|
+
for candidate_path in candidate_paths:
|
|
451
|
+
if candidate_path in visited_paths:
|
|
452
|
+
continue
|
|
453
|
+
visited_paths.add(candidate_path)
|
|
454
|
+
if not _existing_sample_path(candidate_path):
|
|
455
|
+
continue
|
|
456
|
+
root = _load_sample_root_cached(candidate_path)
|
|
457
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
458
|
+
if _device_type(device) != normalized_target:
|
|
459
|
+
continue
|
|
460
|
+
actual_model = _device_model(device).lower()
|
|
461
|
+
if wanted_model and actual_model != wanted_model:
|
|
462
|
+
continue
|
|
463
|
+
return ET.tostring(device, encoding="unicode")
|
|
464
|
+
if strict_compatibility_mode():
|
|
465
|
+
donor = require_packet_tracer_compatibility_donor()
|
|
466
|
+
target = f"{device_type} with model {model}" if model else device_type
|
|
467
|
+
raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype for {target}")
|
|
468
|
+
template = _load_device_template(normalized_target, model)
|
|
469
|
+
if template is not None:
|
|
470
|
+
return ET.tostring(template, encoding="unicode")
|
|
471
|
+
if model:
|
|
472
|
+
raise ValueError(f"No prototype found for device type {device_type} with model {model}")
|
|
473
|
+
raise ValueError(f"No prototype found for device type {device_type}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def infer_default_links(devices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
477
|
+
routers = [device for device in devices if device["type"] == "Router"]
|
|
478
|
+
switches = [device for device in devices if device["type"] == "Switch"]
|
|
479
|
+
hosts = [device for device in devices if device["type"] in {"PC", "Server"}]
|
|
480
|
+
links: list[dict[str, Any]] = []
|
|
481
|
+
if switches:
|
|
482
|
+
uplink_switch = switches[0]
|
|
483
|
+
for index, host in enumerate(hosts, start=1):
|
|
484
|
+
links.append({"a": {"dev": host["name"], "port": "FastEthernet0"}, "b": {"dev": uplink_switch["name"], "port": f"FastEthernet0/{min(index, 24)}"}, "media": "copper"})
|
|
485
|
+
if routers:
|
|
486
|
+
links.append({"a": {"dev": uplink_switch["name"], "port": "FastEthernet0/24"}, "b": {"dev": routers[0]["name"], "port": "FastEthernet0/0"}, "media": "copper"})
|
|
487
|
+
for prev, nxt in zip(switches, switches[1:]):
|
|
488
|
+
links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/23"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/24"}, "media": "copper"})
|
|
489
|
+
elif len(routers) >= 2:
|
|
490
|
+
for prev, nxt in zip(routers, routers[1:]):
|
|
491
|
+
links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/0"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/1"}, "media": "copper"})
|
|
492
|
+
return links
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _effective_link_type(device_type: str) -> str:
|
|
496
|
+
normalized = normalize_device_type(device_type)
|
|
497
|
+
if normalized in GENERIC_COPPER_HOST_TYPES:
|
|
498
|
+
return "PC"
|
|
499
|
+
return normalized
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _prototype_link_by_pair(source_sample_path: str | Path, left_type: str, right_type: str) -> ET.Element:
|
|
503
|
+
return ET.fromstring(_prototype_link_xml(str(source_sample_path), left_type, right_type))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@lru_cache(maxsize=512)
|
|
507
|
+
def _prototype_link_xml(source_sample_path: str, left_type: str, right_type: str) -> str:
|
|
508
|
+
compatibility_root = generation_root_sample()
|
|
509
|
+
if strict_compatibility_mode():
|
|
510
|
+
candidate_paths = [str(compatibility_root)]
|
|
511
|
+
else:
|
|
512
|
+
candidate_paths = [str(compatibility_root), str(source_sample_path)]
|
|
513
|
+
try:
|
|
514
|
+
candidate_paths.append(str(resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)))
|
|
515
|
+
except FileNotFoundError:
|
|
516
|
+
pass
|
|
517
|
+
candidate_paths.extend(sample.path for sample in load_catalog())
|
|
518
|
+
visited_paths: set[str] = set()
|
|
519
|
+
wanted_left = _effective_link_type(left_type)
|
|
520
|
+
wanted_right = _effective_link_type(right_type)
|
|
521
|
+
for candidate in candidate_paths:
|
|
522
|
+
root_key = str(candidate)
|
|
523
|
+
if root_key in visited_paths:
|
|
524
|
+
continue
|
|
525
|
+
visited_paths.add(root_key)
|
|
526
|
+
if not _existing_sample_path(root_key):
|
|
527
|
+
continue
|
|
528
|
+
root = _load_sample_root_cached(root_key)
|
|
529
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
530
|
+
index_to_type = {str(index): _effective_link_type(_device_type(device)) for index, device in enumerate(devices)}
|
|
531
|
+
save_ref_to_type = {
|
|
532
|
+
device.findtext("./ENGINE/SAVE_REF_ID", default=""): _effective_link_type(_device_type(device))
|
|
533
|
+
for device in devices
|
|
534
|
+
if device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
535
|
+
}
|
|
536
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
537
|
+
cable = link.find("./CABLE")
|
|
538
|
+
if cable is None:
|
|
539
|
+
continue
|
|
540
|
+
from_ref = cable.findtext("FROM", default="")
|
|
541
|
+
to_ref = cable.findtext("TO", default="")
|
|
542
|
+
from_type = save_ref_to_type.get(from_ref) or index_to_type.get(from_ref)
|
|
543
|
+
to_type = save_ref_to_type.get(to_ref) or index_to_type.get(to_ref)
|
|
544
|
+
if from_type == wanted_left and to_type == wanted_right:
|
|
545
|
+
return ET.tostring(link, encoding="unicode")
|
|
546
|
+
if from_type == wanted_right and to_type == wanted_left:
|
|
547
|
+
return ET.tostring(link, encoding="unicode")
|
|
548
|
+
if strict_compatibility_mode():
|
|
549
|
+
donor = require_packet_tracer_compatibility_donor()
|
|
550
|
+
raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype link for {left_type} <-> {right_type}")
|
|
551
|
+
return ET.tostring(_generic_link_prototype(), encoding="unicode")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
#
|
|
555
|
+
# A cable has two halves in the file, and they are not interchangeable.
|
|
556
|
+
# Measured across 140 bundled labs:
|
|
557
|
+
#
|
|
558
|
+
# LINK/TYPE eCopper 393, eSerial 55, eCoaxial 12, eOctal 11, ePhoneLine 3
|
|
559
|
+
# CABLE/TYPE eStraightThrough 274, eCrossOver 119, absent 81
|
|
560
|
+
#
|
|
561
|
+
# `LINK/TYPE` is the family; `CABLE/TYPE` is only the copper sub-type and is
|
|
562
|
+
# absent for everything else. Writing `eSerialDCE` or `eFiber` into `CABLE/TYPE`
|
|
563
|
+
# -- which is what this did -- put the value in the wrong element and never set
|
|
564
|
+
# the family, so a serial or fibre link came out as plain copper.
|
|
565
|
+
CABLE_FAMILIES = {
|
|
566
|
+
"copper": ("eCopper", "eStraightThrough"),
|
|
567
|
+
"straight-through": ("eCopper", "eStraightThrough"),
|
|
568
|
+
"straight": ("eCopper", "eStraightThrough"),
|
|
569
|
+
"crossover": ("eCopper", "eCrossOver"),
|
|
570
|
+
"cross-over": ("eCopper", "eCrossOver"),
|
|
571
|
+
"fiber": ("eFiber", ""),
|
|
572
|
+
"fibre": ("eFiber", ""),
|
|
573
|
+
"serial": ("eSerial", ""),
|
|
574
|
+
"serial-dce": ("eSerial", ""),
|
|
575
|
+
"serial-dte": ("eSerial", ""),
|
|
576
|
+
"coaxial": ("eCoaxial", ""),
|
|
577
|
+
"coax": ("eCoaxial", ""),
|
|
578
|
+
"console": ("eOctal", ""),
|
|
579
|
+
"octal": ("eOctal", ""),
|
|
580
|
+
"phone": ("ePhoneLine", ""),
|
|
581
|
+
"phoneline": ("ePhoneLine", ""),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def apply_cable_type(cable: ET.Element, media: str, link: ET.Element | None = None) -> None:
|
|
586
|
+
"""Set the cable family on the link and the copper sub-type on the cable.
|
|
587
|
+
|
|
588
|
+
`link` is optional so existing callers keep working, but without it only the
|
|
589
|
+
copper sub-type can be set -- a serial cable needs the family, which lives
|
|
590
|
+
one level up.
|
|
591
|
+
"""
|
|
592
|
+
family, subtype = CABLE_FAMILIES.get(media.strip().lower(), ("", ""))
|
|
593
|
+
|
|
594
|
+
if link is not None and family:
|
|
595
|
+
node = link.find("TYPE")
|
|
596
|
+
if node is None:
|
|
597
|
+
node = ET.SubElement(link, "TYPE")
|
|
598
|
+
node.text = family
|
|
599
|
+
|
|
600
|
+
node = cable.find("TYPE")
|
|
601
|
+
if node is None:
|
|
602
|
+
return
|
|
603
|
+
if subtype:
|
|
604
|
+
node.text = subtype
|
|
605
|
+
elif family and family != "eCopper":
|
|
606
|
+
# Non-copper links carry no cable sub-type at all in a real save.
|
|
607
|
+
cable.remove(node)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
#
|
|
611
|
+
# Port `TYPE` values as they actually appear in saved labs, counted across the
|
|
612
|
+
# local collection: eCopperFastEthernet 1148, eBluetooth 291,
|
|
613
|
+
# eCopperGigabitEthernet 116, eHostWirelessN 51, eAccessPointWirelessAC 20,
|
|
614
|
+
# eAccessPointWirelessG 18, eSerial 12, eFiberFastEthernet 10.
|
|
615
|
+
#
|
|
616
|
+
# Only the two eCopper families were recognised, so serial, fibre and wireless
|
|
617
|
+
# interfaces were invisible: 33 of 782 real link endpoints -- `Serial2/0` on a
|
|
618
|
+
# router, `Port 0` on an access point -- were reported as not existing.
|
|
619
|
+
PORT_TYPE_FAMILIES = {
|
|
620
|
+
"eCopperFastEthernet": "FastEthernet",
|
|
621
|
+
"eFiberFastEthernet": "FastEthernet",
|
|
622
|
+
"eCopperGigabitEthernet": "GigabitEthernet",
|
|
623
|
+
"eFiberGigabitEthernet": "GigabitEthernet",
|
|
624
|
+
"eSerial": "Serial",
|
|
625
|
+
# A smart-serial port is a serial port. Measured: a router carrying an
|
|
626
|
+
# HWIC-2T reports `eSmartSerial` on its two interfaces and cables to another
|
|
627
|
+
# over `Serial0/0/0` with an `eSerial` link -- Packet Tracer's own doing.
|
|
628
|
+
# Missing here, `port_capacity` answered `Serial: 0` for exactly the routers
|
|
629
|
+
# that have serial, so `port_exists` rejected `Serial0/0/0`, the port repair
|
|
630
|
+
# moved the WAN cable onto Ethernet, and media reconciliation demoted it to
|
|
631
|
+
# copper. Every serial prompt came out over copper for this one line.
|
|
632
|
+
#
|
|
633
|
+
# It hides 196 ports across the local donor pool, against 110 spelled
|
|
634
|
+
# `eSerial`, so most of the serial hardware available was invisible.
|
|
635
|
+
"eSmartSerial": "Serial",
|
|
636
|
+
"eHostWirelessN": "Wireless",
|
|
637
|
+
"eHostWirelessG": "Wireless",
|
|
638
|
+
"eHostWirelessAC": "Wireless",
|
|
639
|
+
"eBluetooth": "Bluetooth",
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _port_nodes(device: ET.Element) -> list[ET.Element]:
|
|
644
|
+
"""Wired interfaces that can carry a cable.
|
|
645
|
+
|
|
646
|
+
Restricted to the families whose numbering this module models; wireless
|
|
647
|
+
radios and Bluetooth are counted separately by `port_capacity` and are never
|
|
648
|
+
candidates for a cable.
|
|
649
|
+
"""
|
|
650
|
+
return [port for port in device.findall(".//PORT") if port.findtext("TYPE", "").startswith("eCopper")]
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _parse_port_index(port_name: str) -> int | None:
|
|
654
|
+
match = re.search(r"(\d+)$", port_name)
|
|
655
|
+
return int(match.group(1)) if match else None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _canonical_port_name(port_name: str) -> str:
|
|
659
|
+
"""Expand an abbreviated interface name, and leave a full one alone.
|
|
660
|
+
|
|
661
|
+
This assumed its input was always the short form and sliced two characters
|
|
662
|
+
off unconditionally, so `FastEthernet0` came back as
|
|
663
|
+
`FastEthernetstEthernet0`. Every caller that passed a full name -- which is
|
|
664
|
+
the form stored in a saved lab -- got a nonsense string, and `port_exists`
|
|
665
|
+
then reported real interfaces as missing.
|
|
666
|
+
"""
|
|
667
|
+
trimmed = port_name.strip()
|
|
668
|
+
lowered = trimmed.lower()
|
|
669
|
+
for prefix, expanded in (("fastethernet", "FastEthernet"), ("gigabitethernet", "GigabitEthernet")):
|
|
670
|
+
if lowered.startswith(prefix):
|
|
671
|
+
return expanded + trimmed[len(prefix):]
|
|
672
|
+
if lowered.startswith("fa"):
|
|
673
|
+
return "FastEthernet" + trimmed[2:]
|
|
674
|
+
if lowered.startswith("gi"):
|
|
675
|
+
return "GigabitEthernet" + trimmed[2:]
|
|
676
|
+
return trimmed
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def port_capacity(device: ET.Element) -> dict[str, int]:
|
|
680
|
+
"""How many FastEthernet and GigabitEthernet interfaces a device really has.
|
|
681
|
+
|
|
682
|
+
`_port_address_for_name` is a MEM_ADDR lookup, not an existence test: it
|
|
683
|
+
returns None whenever the donor's port nodes carry no address, which is
|
|
684
|
+
common. Using it to decide whether a port exists made every port look
|
|
685
|
+
missing, and inventing `GigabitEthernet0/3` on a 2960-24TT produced a file
|
|
686
|
+
Packet Tracer refused to open.
|
|
687
|
+
"""
|
|
688
|
+
nodes = device.findall(".//PORT")
|
|
689
|
+
counts = {"FastEthernet": 0, "GigabitEthernet": 0, "Serial": 0}
|
|
690
|
+
for node in nodes:
|
|
691
|
+
family = PORT_TYPE_FAMILIES.get((node.findtext("TYPE") or "").strip())
|
|
692
|
+
if family in counts:
|
|
693
|
+
counts[family] += 1
|
|
694
|
+
return counts
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# Hosts carry a single unslotted interface. Switches and routers slot theirs.
|
|
698
|
+
HOST_DEVICE_TYPES = {"PC", "Server", "Printer", "Laptop", "Tablet", "Smartphone", "WirelessEndDevice"}
|
|
699
|
+
# Devices whose interfaces are numbered straight through, with no slot: a hub's
|
|
700
|
+
# `FastEthernet0`, an access point's `GigabitEthernet0`, a controller's
|
|
701
|
+
# `GigabitEthernet1`.
|
|
702
|
+
#
|
|
703
|
+
# The four after the first three were measured by scanning every cable in 130
|
|
704
|
+
# donor labs, and by asking what happened to a lab that wired one. A device
|
|
705
|
+
# missing from this set falls to the slotted rule, `0 < index <= count`, which
|
|
706
|
+
# rejects index 0 and accepts index 1 -- so for an access point carrying a
|
|
707
|
+
# single `GigabitEthernet0`, the real port was reported absent and the
|
|
708
|
+
# nonexistent `GigabitEthernet0/1` was reported present. The port repair then
|
|
709
|
+
# dutifully replaced the good name with the bad one, and Packet Tracer refused
|
|
710
|
+
# the file.
|
|
711
|
+
UNSLOTTED_MULTIPORT_TYPES = {
|
|
712
|
+
"Hub",
|
|
713
|
+
"Repeater",
|
|
714
|
+
"CoaxialSplitter",
|
|
715
|
+
"LightWeightAccessPoint",
|
|
716
|
+
"WirelessLanController",
|
|
717
|
+
"NetworkController",
|
|
718
|
+
"MerakiServer",
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def donor_interface_names(device: ET.Element) -> list[str]:
|
|
723
|
+
"""The interfaces a donor device really has, in the order it lists them.
|
|
724
|
+
|
|
725
|
+
A `PORT` element carries no name -- names are positional and depend on the
|
|
726
|
+
model -- so the device's own running config is the authoritative source.
|
|
727
|
+
Subinterfaces and `Vlan` interfaces are excluded: they are configuration,
|
|
728
|
+
not hardware, and cannot take a cable.
|
|
729
|
+
|
|
730
|
+
This exists because guessing from the model name cannot work. A PT8200
|
|
731
|
+
router matched no entry in the prefix table and was handed
|
|
732
|
+
`FastEthernet0/1`, while its real interfaces are `GigabitEthernet0/0/0`
|
|
733
|
+
through `0/0/2`. Packet Tracer refuses to open a lab naming an interface a
|
|
734
|
+
device does not have, so that one wrong name cost the whole file.
|
|
735
|
+
"""
|
|
736
|
+
for tag in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
737
|
+
config = device.find(f"./ENGINE/{tag}")
|
|
738
|
+
if config is None:
|
|
739
|
+
continue
|
|
740
|
+
names: list[str] = []
|
|
741
|
+
for line in config.findall("LINE"):
|
|
742
|
+
text = (line.text or "").strip()
|
|
743
|
+
if not text.startswith("interface "):
|
|
744
|
+
continue
|
|
745
|
+
name = text.split(" ", 1)[1].strip()
|
|
746
|
+
if "." in name or name.lower().startswith("vlan"):
|
|
747
|
+
continue
|
|
748
|
+
if name not in names:
|
|
749
|
+
names.append(name)
|
|
750
|
+
if names:
|
|
751
|
+
return names
|
|
752
|
+
return []
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _name_contradicts_device_shape(device: ET.Element, canonical: str, kind: str) -> bool:
|
|
756
|
+
"""Whether this interface name disagrees with how the device names its own.
|
|
757
|
+
|
|
758
|
+
Counting ports says how many exist, not what they are called. A stacked 3650
|
|
759
|
+
numbers its uplinks `GigabitEthernet1/0/1`, and asking for
|
|
760
|
+
`GigabitEthernet0/1` on it passed the count test -- twenty-eight gigabit
|
|
761
|
+
ports, index one -- while naming an interface the switch does not have.
|
|
762
|
+
Packet Tracer refused the lab.
|
|
763
|
+
|
|
764
|
+
The device's own configuration is the only place its naming shape is written
|
|
765
|
+
down, since a PORT node carries no name at all. Membership in the
|
|
766
|
+
configuration is not enough either: a stacked switch whose sockets are all
|
|
767
|
+
`GigabitEthernet1/0/N` still carried a stale `interface GigabitEthernet0/1`
|
|
768
|
+
line, so accepting any name the config mentions let a cable land on it --
|
|
769
|
+
the one link, out of twelve, that made `four_switch` the last corpus lab
|
|
770
|
+
Packet Tracer refused.
|
|
771
|
+
|
|
772
|
+
So the shape is compared, not the set. Two axes matter:
|
|
773
|
+
|
|
774
|
+
* Depth. `FastEthernet0/1` and `GigabitEthernet1/0/1` are different shapes,
|
|
775
|
+
and the shape the device really uses is the one most of its interfaces of
|
|
776
|
+
that kind share.
|
|
777
|
+
* Which component varies. A 2960 numbers within one slot -- `FastEthernet0/1`
|
|
778
|
+
.. `0/24` -- while a modular switch numbers by slot: `FastEthernet0/1`,
|
|
779
|
+
`1/1`, ... `9/1`. Both have one slash, so depth cannot tell them apart,
|
|
780
|
+
and the generator's `FastEthernet0/{index}` asked such a switch for
|
|
781
|
+
`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
|
|
783
|
+
`FastEthernet2/1` opens.
|
|
784
|
+
|
|
785
|
+
Two names are required before concluding anything about the second axis, so
|
|
786
|
+
a device with one configured interface keeps the benefit of the doubt rather
|
|
787
|
+
than having its whole numbering inferred from a single sample.
|
|
788
|
+
"""
|
|
789
|
+
named = [name for name in donor_interface_names(device) if name.startswith(kind)]
|
|
790
|
+
if not named:
|
|
791
|
+
return False
|
|
792
|
+
|
|
793
|
+
depths = Counter(name.count("/") for name in named)
|
|
794
|
+
if canonical.count("/") != depths.most_common(1)[0][0]:
|
|
795
|
+
return True
|
|
796
|
+
|
|
797
|
+
slotted = [name for name in named if "/" in name]
|
|
798
|
+
if len(slotted) > 1 and "/" in canonical:
|
|
799
|
+
tails = {name.rsplit("/", 1)[-1] for name in slotted}
|
|
800
|
+
heads = {name.rsplit("/", 1)[0] for name in slotted}
|
|
801
|
+
if len(tails) == 1 and len(heads) > 1:
|
|
802
|
+
return canonical.rsplit("/", 1)[-1] not in tails
|
|
803
|
+
return False
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def port_exists(device: ET.Element, port_name: str) -> bool:
|
|
807
|
+
"""Whether `port_name` names an interface this device actually has.
|
|
808
|
+
|
|
809
|
+
Numbering differs by device kind, and getting it backwards is how invalid
|
|
810
|
+
interfaces reach a saved lab -- Packet Tracer then refuses to open it.
|
|
811
|
+
|
|
812
|
+
A host's interface is bare: `FastEthernet0`. The slotted form the switch
|
|
813
|
+
rule accepts, `FastEthernet0/1`, does not exist on a PC. This used to answer
|
|
814
|
+
exactly the wrong way round for hosts, calling the real name missing and the
|
|
815
|
+
imaginary one present, while every lab on disk links its PCs on
|
|
816
|
+
`FastEthernet0`.
|
|
817
|
+
"""
|
|
818
|
+
canonical = _canonical_port_name(port_name)
|
|
819
|
+
device_type = _device_type(device)
|
|
820
|
+
|
|
821
|
+
# Logical interfaces are configuration, not sockets: nothing plugs into a
|
|
822
|
+
# Port-channel, a PRP-channel, a VLAN interface or a subinterface. They fell
|
|
823
|
+
# through the permissive branch below and were treated as real, so wiring a
|
|
824
|
+
# multilayer switch from an industrial donor put a cable on `PRP-channel 1`
|
|
825
|
+
# and Packet Tracer refused to open the lab at all.
|
|
826
|
+
lowered = canonical.lower()
|
|
827
|
+
if "channel" in lowered or lowered.startswith("vlan") or "." in canonical:
|
|
828
|
+
return False
|
|
829
|
+
|
|
830
|
+
# Serial is modelled now, so it gets a real answer: a router with no serial
|
|
831
|
+
# card cannot carry `Serial0/0/0`, and letting that through produced a WAN
|
|
832
|
+
# lab whose PPP configuration sat on an interface that was never cabled.
|
|
833
|
+
#
|
|
834
|
+
# Owning serial hardware is not the same as owning *that* interface. A pair
|
|
835
|
+
# of routers whose only serial ports are `Serial2/0` and `Serial3/0` passed
|
|
836
|
+
# this test for `Serial0/0/0` -- two serial ports, so yes -- and the lab
|
|
837
|
+
# built from them was refused. The naming shape is checked here for the same
|
|
838
|
+
# reason it is checked for Ethernet below.
|
|
839
|
+
if canonical.startswith("Serial"):
|
|
840
|
+
if port_capacity(device).get("Serial", 0) <= 0:
|
|
841
|
+
return False
|
|
842
|
+
return not _name_contradicts_device_shape(device, canonical, "Serial")
|
|
843
|
+
|
|
844
|
+
# Everything else is genuinely outside this module -- `Port 0` on an access
|
|
845
|
+
# point, `RS 232` on a laptop, the `Switch` pass-through on an IP phone.
|
|
846
|
+
# Reporting those as missing made real links look invalid, which is the
|
|
847
|
+
# damaging direction: a legitimate link gets dropped.
|
|
848
|
+
if not canonical.startswith(("FastEthernet", "GigabitEthernet")):
|
|
849
|
+
return True
|
|
850
|
+
|
|
851
|
+
for kind, count in port_capacity(device).items():
|
|
852
|
+
if not canonical.startswith(kind):
|
|
853
|
+
continue
|
|
854
|
+
# `port_capacity` reports every kind it models, including the ones the
|
|
855
|
+
# device has none of, so a match here means the *name* is of this kind,
|
|
856
|
+
# not that the hardware exists. Without this guard the multi-slot router
|
|
857
|
+
# branch below answered `0 <= 0 <= 0` and declared
|
|
858
|
+
# `GigabitEthernet0/0/0` present on a 2811 that has two FastEthernet
|
|
859
|
+
# ports and no gigabit at all. The link kept that interface, the repair
|
|
860
|
+
# pass found nothing to fix, and Packet Tracer refused the file --
|
|
861
|
+
# measured as "Incompatible File" on every generated lab.
|
|
862
|
+
if count <= 0:
|
|
863
|
+
return False
|
|
864
|
+
# Counting ports says how many exist, not what they are called. A
|
|
865
|
+
# stacked 3650 numbers its uplinks `GigabitEthernet1/0/1`, and asking
|
|
866
|
+
# for `GigabitEthernet0/1` on it passed the count test -- twenty-eight
|
|
867
|
+
# gigabit ports, index one -- while naming an interface the switch does
|
|
868
|
+
# not have. Packet Tracer refused the lab.
|
|
869
|
+
#
|
|
870
|
+
# The device's own configuration is the only place its naming shape is
|
|
871
|
+
# written down, since a PORT node carries no name at all. Compare the
|
|
872
|
+
# slot depth rather than requiring an exact match: a config lists the
|
|
873
|
+
# interfaces someone touched, not every socket on the device.
|
|
874
|
+
# Membership in the configuration is not enough. A stacked switch whose
|
|
875
|
+
# sockets are all `GigabitEthernet1/0/N` still carried a stale
|
|
876
|
+
# `interface GigabitEthernet0/1` line, so accepting any name the config
|
|
877
|
+
# mentions let a cable land on `GigabitEthernet0/1` -- the one link, out
|
|
878
|
+
# of twelve, that made `four_switch` the last corpus lab Packet Tracer
|
|
879
|
+
# refused. Removing only that link opens the file.
|
|
880
|
+
#
|
|
881
|
+
# The shape the device really uses is the one most of its interfaces of
|
|
882
|
+
# that kind share, so compare against that rather than against the set.
|
|
883
|
+
if _name_contradicts_device_shape(device, canonical, kind):
|
|
884
|
+
return False
|
|
885
|
+
if device_type in HOST_DEVICE_TYPES:
|
|
886
|
+
# `FastEthernet0` and, for tolerance, a bare `FastEthernet`.
|
|
887
|
+
return count > 0 and canonical in {kind, f"{kind}0"}
|
|
888
|
+
if device_type in UNSLOTTED_MULTIPORT_TYPES:
|
|
889
|
+
# A hub numbers its ports `FastEthernet0`, `FastEthernet1`, ... --
|
|
890
|
+
# unslotted like a host but many of them.
|
|
891
|
+
index = _parse_port_index(canonical)
|
|
892
|
+
return index is not None and 0 <= index < max(count, 1)
|
|
893
|
+
index = _parse_port_index(canonical)
|
|
894
|
+
if index is None:
|
|
895
|
+
return canonical == kind or canonical == f"{kind}0"
|
|
896
|
+
if device_type == "Router":
|
|
897
|
+
# Routers number from zero, in every form. The multi-slot spelling
|
|
898
|
+
# used to be bounded inclusively -- `0 <= index <= count` -- on the
|
|
899
|
+
# theory that the trailing number is a position within a slot. On an
|
|
900
|
+
# ISR4331 carrying three gigabit ports, `GigabitEthernet0/0/0`
|
|
901
|
+
# through `0/0/2`, that admitted `GigabitEthernet0/0/3`, and the
|
|
902
|
+
# generated lab named an interface the router does not have. Packet
|
|
903
|
+
# Tracer refused it. Measured on the donor: three PORT nodes, three
|
|
904
|
+
# interfaces in the configuration, highest index two.
|
|
905
|
+
return 0 <= index < count
|
|
906
|
+
return 0 < index <= count
|
|
907
|
+
return False
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _port_address_for_name(device: ET.Element, port_name: str) -> str | None:
|
|
911
|
+
canonical = _canonical_port_name(port_name)
|
|
912
|
+
fast_nodes = [port for port in _port_nodes(device) if "FastEthernet" in port.findtext("TYPE", "")]
|
|
913
|
+
gig_nodes = [port for port in _port_nodes(device) if "GigabitEthernet" in port.findtext("TYPE", "")]
|
|
914
|
+
target: ET.Element | None = None
|
|
915
|
+
if canonical == "FastEthernet0":
|
|
916
|
+
target = fast_nodes[0] if fast_nodes else None
|
|
917
|
+
elif canonical.startswith("FastEthernet"):
|
|
918
|
+
index = _parse_port_index(canonical)
|
|
919
|
+
if index is not None and fast_nodes:
|
|
920
|
+
if _device_type(device) == "Router":
|
|
921
|
+
target = fast_nodes[index] if index < len(fast_nodes) else None
|
|
922
|
+
else:
|
|
923
|
+
target = fast_nodes[index - 1] if 0 < index <= len(fast_nodes) else None
|
|
924
|
+
elif canonical.startswith("GigabitEthernet"):
|
|
925
|
+
index = _parse_port_index(canonical)
|
|
926
|
+
if index is not None and gig_nodes:
|
|
927
|
+
if _device_type(device) == "Router" and "/" in canonical and canonical.count("/") == 1:
|
|
928
|
+
target = gig_nodes[index] if index < len(gig_nodes) else None
|
|
929
|
+
else:
|
|
930
|
+
target = gig_nodes[index - 1] if 0 < index <= len(gig_nodes) else None
|
|
931
|
+
if target is None:
|
|
932
|
+
return None
|
|
933
|
+
return target.findtext("MEM_ADDR")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _sanitize_generated_runtime_sections(root: ET.Element) -> None:
|
|
937
|
+
scenario_set = root.find("./SCENARIOSET")
|
|
938
|
+
if scenario_set is not None:
|
|
939
|
+
scenario_set.clear()
|
|
940
|
+
scenario = ET.SubElement(scenario_set, "SCENARIO")
|
|
941
|
+
name = ET.SubElement(scenario, "NAME")
|
|
942
|
+
name.set("translate", "true")
|
|
943
|
+
name.text = "Scenario 0"
|
|
944
|
+
description = ET.SubElement(scenario, "DESCRIPTION")
|
|
945
|
+
description.set("translate", "true")
|
|
946
|
+
command_logs = root.find("./COMMAND_LOGS")
|
|
947
|
+
if command_logs is not None:
|
|
948
|
+
command_logs.clear()
|
|
949
|
+
ceps = root.find("./CEPS")
|
|
950
|
+
if ceps is not None:
|
|
951
|
+
ceps.clear()
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def transform_from_blueprint(blueprint: dict[str, Any], sample: SampleDescriptor) -> bytes:
|
|
955
|
+
root_sample_path = generation_root_sample()
|
|
956
|
+
target_root = copy.deepcopy(load_sample_root(root_sample_path))
|
|
957
|
+
version_node = target_root.find("./VERSION")
|
|
958
|
+
if version_node is not None:
|
|
959
|
+
version_node.text = generation_root_version()
|
|
960
|
+
devices_parent = target_root.find(".//DEVICES")
|
|
961
|
+
links_parent = target_root.find(".//LINKS")
|
|
962
|
+
if devices_parent is None or links_parent is None:
|
|
963
|
+
raise ValueError("Sample XML is missing DEVICES or LINKS")
|
|
964
|
+
devices_parent.clear()
|
|
965
|
+
links_parent.clear()
|
|
966
|
+
for tag in ["LINES", "RECTANGLES", "ELLIPSES", "POLYGONS", "GEOVIEW_GRAPHICSITEMS", "NOTES"]:
|
|
967
|
+
node = target_root.find(tag)
|
|
968
|
+
if node is not None:
|
|
969
|
+
node.clear()
|
|
970
|
+
compatibility_mode = strict_compatibility_mode()
|
|
971
|
+
preferred_source = _existing_sample_path(sample.path)
|
|
972
|
+
library_source_root = load_sample_root(root_sample_path)
|
|
973
|
+
if not compatibility_mode and preferred_source is not None:
|
|
974
|
+
library_source_root = load_sample_root(preferred_source)
|
|
975
|
+
reference_workspaces = _build_workspace_reference_library(load_sample_root(root_sample_path))
|
|
976
|
+
|
|
977
|
+
device_library = build_device_library(library_source_root)
|
|
978
|
+
requested_devices = blueprint.get("devices", [])
|
|
979
|
+
if not requested_devices:
|
|
980
|
+
raise ValueError("Blueprint must include devices")
|
|
981
|
+
requested_links = blueprint.get("links") or infer_default_links(requested_devices)
|
|
982
|
+
configs = blueprint.get("configs", {})
|
|
983
|
+
|
|
984
|
+
built_devices: list[ET.Element] = []
|
|
985
|
+
name_to_device: dict[str, ET.Element] = {}
|
|
986
|
+
name_to_index: dict[str, int] = {}
|
|
987
|
+
type_usage: dict[str, int] = {}
|
|
988
|
+
workspace_usage: dict[str, int] = {}
|
|
989
|
+
|
|
990
|
+
for requested in requested_devices:
|
|
991
|
+
requested_type = normalize_device_type(str(requested["type"]))
|
|
992
|
+
requested_model = requested.get("model")
|
|
993
|
+
prototypes = device_library.get(requested_type, [])
|
|
994
|
+
use_index = type_usage.get(requested_type, 0)
|
|
995
|
+
workspace_index = workspace_usage.get(requested_type, 0) + 1
|
|
996
|
+
workspace_usage[requested_type] = workspace_index
|
|
997
|
+
matching_prototypes = prototypes
|
|
998
|
+
if requested_model:
|
|
999
|
+
matching_prototypes = [prototype for prototype in prototypes if _device_model(prototype).lower() == str(requested_model).lower()]
|
|
1000
|
+
if use_index < len(matching_prototypes):
|
|
1001
|
+
built = copy.deepcopy(matching_prototypes[use_index])
|
|
1002
|
+
type_usage[requested_type] = use_index + 1
|
|
1003
|
+
else:
|
|
1004
|
+
if compatibility_mode:
|
|
1005
|
+
donor = require_packet_tracer_compatibility_donor()
|
|
1006
|
+
target = f"{requested_type} with model {requested_model}" if requested_model else requested_type
|
|
1007
|
+
raise ValueError(
|
|
1008
|
+
f"Strict 9.0 donor {donor} has only {len(matching_prototypes)} prototype(s) for {target}; requested more devices than donor supports."
|
|
1009
|
+
)
|
|
1010
|
+
if requested_model:
|
|
1011
|
+
built = copy.deepcopy(find_device_prototype(requested_type, str(requested_model), sample))
|
|
1012
|
+
else:
|
|
1013
|
+
built = copy.deepcopy(find_device_prototype(requested_type, None, sample))
|
|
1014
|
+
name = str(requested["name"])
|
|
1015
|
+
_set_device_name(built, name)
|
|
1016
|
+
if requested_model:
|
|
1017
|
+
_set_device_model(built, str(requested_model))
|
|
1018
|
+
default_x, default_y = _logical_position(built)
|
|
1019
|
+
_set_position(built, int(requested.get("x", default_x)), int(requested.get("y", default_y)))
|
|
1020
|
+
if not compatibility_mode:
|
|
1021
|
+
_normalize_workspace_physical(built, requested_type, reference_workspaces, workspace_index)
|
|
1022
|
+
_set_workspace_ids(built, len(built_devices) + 1)
|
|
1023
|
+
_set_runtime_ids(built, len(built_devices) + 1)
|
|
1024
|
+
device_config = configs.get(name, {})
|
|
1025
|
+
if requested_type in {"PC", "Server"} and isinstance(device_config, dict):
|
|
1026
|
+
apply_host_ip(built, device_config)
|
|
1027
|
+
elif requested_type == "Router" and isinstance(device_config, list):
|
|
1028
|
+
apply_router_config(built, device_config)
|
|
1029
|
+
elif requested_type == "Switch" and isinstance(device_config, list):
|
|
1030
|
+
apply_switch_config(built, device_config)
|
|
1031
|
+
name_to_index[name] = len(built_devices)
|
|
1032
|
+
name_to_device[name] = built
|
|
1033
|
+
built_devices.append(built)
|
|
1034
|
+
devices_parent.append(built)
|
|
1035
|
+
|
|
1036
|
+
requested_lookup = {str(device["name"]): normalize_device_type(str(device["type"])) for device in requested_devices}
|
|
1037
|
+
for requested_link in requested_links:
|
|
1038
|
+
left_name = str(requested_link["a"]["dev"])
|
|
1039
|
+
right_name = str(requested_link["b"]["dev"])
|
|
1040
|
+
prototype = copy.deepcopy(_prototype_link_by_pair(sample.path, requested_lookup[left_name], requested_lookup[right_name]))
|
|
1041
|
+
cable = prototype.find("./CABLE")
|
|
1042
|
+
if cable is None:
|
|
1043
|
+
continue
|
|
1044
|
+
from_device = name_to_device[left_name]
|
|
1045
|
+
to_device = name_to_device[right_name]
|
|
1046
|
+
if compatibility_mode:
|
|
1047
|
+
cable.find("FROM").text = from_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[left_name]))
|
|
1048
|
+
cable.find("TO").text = to_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[right_name]))
|
|
1049
|
+
else:
|
|
1050
|
+
cable.find("FROM").text = str(name_to_index[left_name])
|
|
1051
|
+
cable.find("TO").text = str(name_to_index[right_name])
|
|
1052
|
+
ports = cable.findall("PORT")
|
|
1053
|
+
if len(ports) >= 2:
|
|
1054
|
+
ports[0].text = str(requested_link["a"]["port"])
|
|
1055
|
+
ports[1].text = str(requested_link["b"]["port"])
|
|
1056
|
+
mappings = [
|
|
1057
|
+
("FROM_DEVICE_MEM_ADDR", from_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
|
|
1058
|
+
("TO_DEVICE_MEM_ADDR", to_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
|
|
1059
|
+
("FROM_PORT_MEM_ADDR", _port_address_for_name(from_device, str(requested_link["a"]["port"]))),
|
|
1060
|
+
("TO_PORT_MEM_ADDR", _port_address_for_name(to_device, str(requested_link["b"]["port"]))),
|
|
1061
|
+
]
|
|
1062
|
+
for node_name, value in mappings:
|
|
1063
|
+
node = cable.find(node_name)
|
|
1064
|
+
if node is not None and value:
|
|
1065
|
+
node.text = value
|
|
1066
|
+
apply_cable_type(cable, str(requested_link.get("media", "copper")))
|
|
1067
|
+
links_parent.append(prototype)
|
|
1068
|
+
|
|
1069
|
+
_sanitize_generated_runtime_sections(target_root)
|
|
1070
|
+
sanitize_generated_physical_workspace(target_root)
|
|
1071
|
+
validate_workspace_integrity(target_root)
|
|
1072
|
+
return ET.tostring(target_root, encoding="utf-8", xml_declaration=False)
|