feat: tidy topology layout, symbols for all device roles, drop draw.io

Topology:
- Orthogonal routing: each parent drops to its own bus lane in the gap
  below it (interval coloring prevents overlaps), color-coded per
  parent; parallel links drawn thicker. Adjacent same-row links are
  straight, other same-row links use a lane above the row.
- Port labels sit at the target device, stacked per uplink, instead of
  piling up at line midpoints.
- Crossing reduction via repeated down/up barycenter sweeps; rows wrap
  at 12 devices.
- Wide diagrams scale to page width in HTML/PDF instead of being cut off.

Symbols:
- New built-in symbols: modem/NTU (RAD), radio link, PoE injector, NAS,
  tape library, UPS/ATS and PBX.
- Detection checks the device role first, then model/manufacturer, and
  covers the common typos "Acces Point", "Controler" and "Libary".
  Rooms and cable management are hidden; UPS devices are now shown.

Removed the draw.io export format.

Bump version to 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:55:06 +02:00
co-authored by Claude Opus 5.5
parent e477aa9b49
commit 062413a2cd
9 changed files with 362 additions and 188 deletions
+115 -21
View File
@@ -8,16 +8,23 @@ from io import BytesIO
KINDS = OrderedDict(
[
("cloud", ("Internet / Provider", 0)),
("modem", ("Modem / NTU / Medienkonverter", 1)),
("firewall", ("Firewall", 1)),
("router", ("Router", 1)),
("l3switch", ("Layer-3-Switch / Core", 2)),
("switch", ("Switch", 3)),
("radio", ("Funkstrecke / Richtfunk", 3)),
("wlc", ("WLAN-Controller", 3)),
("poe", ("PoE-Injektor", 4)),
("ap", ("Access Point", 4)),
("server", ("Server", 4)),
("storage", ("Storage / NAS", 4)),
("pc", ("PC / Client", 4)),
("storage", ("Storage / SAN (MSA)", 4)),
("nas", ("NAS", 4)),
("tape", ("Bandlaufwerk / Tape Library", 4)),
("ups", ("USV / ATS", 4)),
("pbx", ("Telefonanlage", 4)),
("phone", ("IP-Telefon", 4)),
("pc", ("PC / Client", 4)),
("printer", ("Drucker", 4)),
("camera", ("Kamera", 4)),
("generic", ("Sonstiges Gerät", 4)),
@@ -27,38 +34,50 @@ HIDDEN = "hidden"
KIND_CHOICES = [(k, v[0]) for k, v in KINDS.items() if k != "cloud"] + [(HIDDEN, "In Topologie ausblenden")]
# Reihenfolge wichtig: erster Treffer gewinnt. None = passiv, nicht zeichnen.
# Tippvarianten („Acces Point“, „Controler“, „Libary“) sind absichtlich mit abgedeckt.
_RULES = [
(r"patch|panel|pdu|steckdose|power|strom|kabel|cable|blank|blind|shelf|fachboden|ups|usv|rangier", None),
(r"patch|panel|\bpdu\b|steckdose|kabel|cable|blank|blind|shelf|fachboden|rangier|\braum\b|\broom\b|halterung|\bmount", None),
(r"\busv\b|\bups\b|\bats\b|smart-?ups|eaton|\bapc\b|online usv", "ups"),
(r"poe|injector|injektor", "poe"),
(r"funk|richtfunk|\bbridge\b|airmax|nanobeam|litebeam|powerbeam|\bptp\b|\bptmp\b|airfiber|siklu", "radio"),
(r"\brad\b|modem|\bntu\b|\bnt\b|\bont\b|media ?conver|medienkonverter|konverter|\betx-", "modem"),
(r"firewall|\bfw\b|fw-|sophos|fortigate|fortinet|palo ?alto|\butm\b|\bxgs\b|\bxg\b|watchguard|checkpoint|opnsense|pfsense", "firewall"),
(r"router|\brtr\b|gateway|\bgw\b|lancom|draytek|fritz|isr\d|asr\d|mikrotik", "router"),
(r"controller|\bwlc\b|mobility", "wlc"),
(r"access ?point|accesspoint|\bap\b|\bap-|-ap\b|\bwap\b|wlan|wifi|aironet|unifi ?ap|\biap\b|instant on ap", "ap"),
(r"control+er|\bwlc\b|mobility", "wlc"),
(r"acc?ess? ?point|\bap\b|\bap-|-ap\b|\bwap\b|wlan|wifi|aironet|unifi ?ap|\biap\b", "ap"),
(r"\bcore\b|\bl3\b|layer ?3|distribution|\bdist\b|backbone", "l3switch"),
(r"switch|\bsw\b|\bsw-|-sw\b|catalyst|aruba \d{4}|procurve|\bcbs\d|\bsg\d", "switch"),
(r"storage|\bnas\b|\bsan\b|synology|qnap|netapp", "storage"),
(r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|\bsrv\b|\bvm-?host", "server"),
(r"telefon|phone|\bvoip\b|yealink|snom|gigaset", "phone"),
(r"tape|band|\blto\b|libr?ary|storeever|\bmsl\d", "tape"),
(r"\bnas\b|diskstation|synology|qnap|terastation|readynas|\btvs-|\bds\d{3,4}", "nas"),
(r"storage|\bsan\b|\bmsa\b|netapp|powervault|eternus|\bme\d{4}", "storage"),
(r"server|\besx|hyper-?v|\bhost\b|proliant|poweredge|primergy|\bsrv\b|\bvm-?host", "server"),
(r"telefonanlage|\bpbx\b|\btk-?anlage|starface|3cx|innovaphone|agfeo|auerswald|unify|openscape", "pbx"),
(r"telefon|phone|\bvoip\b|yealink|snom|gigaset|grandstream", "phone"),
(r"drucker|printer|\bmfp\b|kyocera|brother|ricoh", "printer"),
(r"kamera|camera|\bcam\b|axis|hikvision|dahua", "camera"),
(r"\bpc\b|client|workstation|laptop|notebook|thin ?client|desktop", "pc"),
(r"sonstig|other|misc", "generic"),
]
def guess_kind(device):
"""Symbol aus Rolle, Gerätetyp und Hersteller raten. None = passives Gerät (nicht zeichnen)."""
dt = device.device_type
role = device.role
text = " ".join(
str(x or "")
for x in (
getattr(role, "slug", ""), getattr(role, "name", ""),
dt.model, dt.slug, getattr(dt.manufacturer, "name", ""),
)
).lower()
def _match(text):
"""(True, kind) beim ersten Treffer, sonst (False, None)."""
text = text.lower()
for pattern, kind in _RULES:
if re.search(pattern, text):
return kind
return "generic"
return True, kind
return False, None
def guess_kind(device):
"""Symbol raten: zuerst anhand der Rolle, dann Modell/Hersteller. None = passives Gerät (nicht zeichnen)."""
role = device.role
found, kind = _match(f"{getattr(role, 'name', '')} {getattr(role, 'slug', '')}")
if found:
return kind
dt = device.device_type
found, kind = _match(f"{dt.model} {dt.slug} {getattr(dt.manufacturer, 'name', '')}")
return kind if found else "generic"
# --- Zeichnen -----------------------------------------------------------------
@@ -210,6 +229,74 @@ def _draw_generic(d):
d.rectangle([26 + i * 18, 76, 38 + i * 18, 86], fill=(200, 208, 216))
def _draw_modem(d):
# flache Box mit LED-Reihe und Glasfaser-/Leitungsanschluss (Modem, NTU, RAD, Medienkonverter)
_iso_box(d, 12, 58, 100, 88, 12, (70, 84, 100), (130, 146, 162), (52, 64, 78))
for i, c in enumerate(((60, 200, 90), (60, 200, 90), (250, 190, 40), (60, 200, 90))):
d.ellipse([22 + i * 12, 68, 28 + i * 12, 74], fill=c)
d.rectangle([76, 66, 92, 80], fill=(30, 36, 44))
d.line([(84, 58), (84, 34), (112, 34)], fill=(250, 170, 30), width=4)
def _draw_radio(d):
# Richtfunk-Antenne auf Mast mit Funkwellen
d.rectangle([58, 64, 66, 116], fill=(120, 128, 138), outline=OUTLINE)
d.rectangle([40, 112, 84, 118], fill=(120, 128, 138), outline=OUTLINE)
d.ellipse([22, 26, 70, 90], fill=(236, 240, 244), outline=OUTLINE, width=3)
d.ellipse([36, 44, 56, 72], fill=(200, 208, 216), outline=OUTLINE, width=2)
for r in (14, 24, 34):
d.arc([74 - r, 58 - r, 74 + r, 58 + r], start=-50, end=50, fill=BLUE, width=5)
def _draw_poe(d):
# kleine Box mit Blitz
_iso_box(d, 24, 50, 96, 98, 12, (58, 70, 84), (120, 134, 150), (42, 52, 64))
d.polygon([(66, 56), (48, 78), (60, 78), (54, 94), (76, 70), (63, 70), (70, 56)], fill=(250, 200, 40), outline=(160, 110, 0))
d.rectangle([30, 86, 40, 92], fill=(180, 190, 200))
d.rectangle([80, 86, 90, 92], fill=(180, 190, 200))
def _draw_nas(d):
# kompakte Box mit vier senkrechten Laufwerksschächten
_iso_box(d, 22, 30, 98, 108, 12, (54, 60, 70), (110, 118, 130), (38, 44, 52))
for i in range(4):
x = 30 + i * 17
d.rounded_rectangle([x, 40, x + 12, 96], radius=2, fill=(150, 158, 168))
d.ellipse([x + 4, 88, x + 8, 92], fill=(60, 200, 90))
def _draw_tape(d):
# Laufwerk mit Kassettenschacht, davor eine LTO-Kassette
_iso_box(d, 10, 44, 102, 82, 12, (84, 98, 112), (150, 162, 176), (60, 72, 84))
d.rectangle([22, 56, 72, 64], fill=(24, 28, 34))
d.ellipse([84, 56, 92, 64], fill=(60, 200, 90))
d.rounded_rectangle([36, 84, 92, 118], radius=4, fill=(40, 44, 50), outline=OUTLINE, width=2)
d.rectangle([44, 90, 84, 100], fill=(250, 190, 40))
d.ellipse([52, 104, 62, 114], fill=(150, 158, 168))
d.ellipse([68, 104, 78, 114], fill=(150, 158, 168))
def _draw_ups(d):
# Tower mit Batterie-Symbol
_iso_box(d, 32, 22, 88, 112, 12, (52, 58, 66), (110, 118, 128), (36, 42, 50))
d.rounded_rectangle([42, 36, 78, 60], radius=3, fill=(30, 36, 44))
d.rectangle([46, 42, 70, 54], outline=(60, 200, 90), width=2)
d.rectangle([70, 45, 74, 51], fill=(60, 200, 90))
d.rectangle([48, 44, 62, 52], fill=(60, 200, 90))
d.polygon([(62, 70), (50, 88), (58, 88), (54, 102), (70, 82), (62, 82), (68, 70)], fill=(250, 200, 40))
def _draw_pbx(d):
# Anlage (Box) mit Hörer-Symbol
_iso_box(d, 12, 50, 104, 104, 12, (62, 70, 80), (126, 136, 148), (44, 52, 60))
d.rounded_rectangle([34, 62, 82, 92], radius=12, fill=(236, 240, 244))
d.rounded_rectangle([40, 70, 50, 86], radius=4, fill=(40, 44, 50))
d.rounded_rectangle([66, 70, 76, 86], radius=4, fill=(40, 44, 50))
d.rectangle([46, 70, 70, 76], fill=(40, 44, 50))
for i in range(3):
d.ellipse([88, 60 + i * 12, 96, 68 + i * 12], fill=(60, 200, 90))
_DRAWERS = {
"router": _draw_router,
"switch": _draw_switch,
@@ -225,6 +312,13 @@ _DRAWERS = {
"camera": _draw_camera,
"cloud": _draw_cloud,
"generic": _draw_generic,
"modem": _draw_modem,
"radio": _draw_radio,
"poe": _draw_poe,
"nas": _draw_nas,
"tape": _draw_tape,
"ups": _draw_ups,
"pbx": _draw_pbx,
}