Files
MrBlakeandClaude Opus 5.5 062413a2cd 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>
2026-09-23 14:55:06 +02:00

390 lines
16 KiB
Python

"""Topologie-Symbole im Stil von Cisco Packet Tracer (mit Pillow gezeichnet) und ihre Zuordnung zu Geräten."""
import re
from collections import OrderedDict
from functools import lru_cache
from io import BytesIO
# Schlüssel -> (Bezeichnung, Ebene im Diagramm; kleiner = weiter oben)
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 / 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)),
]
)
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|\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"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"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 _match(text):
"""(True, kind) beim ersten Treffer, sonst (False, None)."""
text = text.lower()
for pattern, kind in _RULES:
if re.search(pattern, text):
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 -----------------------------------------------------------------
SIZE = 128 # Kantenlänge eines Symbols in Pixeln
BLUE = (29, 96, 158)
BLUE_LIGHT = (86, 149, 206)
BLUE_TOP = (122, 178, 226)
WHITE = (255, 255, 255)
OUTLINE = (20, 55, 95)
def _arrow(d, start, end, color=WHITE, width=5, head=10):
import math
d.line([start, end], fill=color, width=width)
ang = math.atan2(end[1] - start[1], end[0] - start[0])
left = (end[0] - head * math.cos(ang - 0.5), end[1] - head * math.sin(ang - 0.5))
right = (end[0] - head * math.cos(ang + 0.5), end[1] - head * math.sin(ang + 0.5))
d.polygon([end, left, right], fill=color)
def _iso_box(d, x1, y1, x2, y2, depth, front, top, side=None):
"""Quader in leichter Aufsicht: Front + Oberseite."""
d.polygon([(x1, y1), (x1 + depth, y1 - depth), (x2 + depth, y1 - depth), (x2, y1)], fill=top, outline=OUTLINE)
if side:
d.polygon([(x2, y1), (x2 + depth, y1 - depth), (x2 + depth, y2 - depth), (x2, y2)], fill=side, outline=OUTLINE)
d.rectangle([x1, y1, x2, y2], fill=front, outline=OUTLINE, width=2)
def _cylinder(d, x1, y1, x2, y2, ry, body, top):
d.rectangle([x1, y1 + ry, x2, y2 - ry], fill=body)
d.ellipse([x1, y2 - 2 * ry, x2, y2], fill=body, outline=OUTLINE, width=2)
d.rectangle([x1 + 1, y1 + ry, x2 - 1, y2 - ry], fill=body)
d.line([(x1, y1 + ry), (x1, y2 - ry)], fill=OUTLINE, width=2)
d.line([(x2, y1 + ry), (x2, y2 - ry)], fill=OUTLINE, width=2)
d.ellipse([x1, y1, x2, y1 + 2 * ry], fill=top, outline=OUTLINE, width=2)
def _draw_router(d):
_cylinder(d, 14, 34, 114, 100, 18, BLUE, BLUE_TOP)
# Packet-Tracer-typisch: zwei Pfeile nach außen, zwei nach innen (auf der Oberseite)
cx, cy = 64, 52
_arrow(d, (cx + 8, cy + 3), (cx + 40, cy + 12), width=4, head=9)
_arrow(d, (cx - 8, cy - 3), (cx - 40, cy - 12), width=4, head=9)
_arrow(d, (cx + 40, cy - 12), (cx + 10, cy - 3), width=4, head=9)
_arrow(d, (cx - 40, cy + 12), (cx - 10, cy + 3), width=4, head=9)
def _draw_switch(d, l3=False):
_iso_box(d, 8, 64, 98, 98, 24, BLUE, BLUE_TOP, BLUE_LIGHT)
# Doppelpfeile auf der Oberseite (Packet-Tracer-Switch)
_arrow(d, (28, 47), (90, 47), width=5, head=11)
_arrow(d, (92, 56), (30, 56), width=5, head=11)
if l3:
from PIL import ImageFont
try:
font = ImageFont.load_default(size=20)
except TypeError: # Pillow < 10.1
font = ImageFont.load_default()
d.text((53, 81), "L3", fill=WHITE, font=font, anchor="mm")
else:
for i in range(8): # Ports
d.rectangle([16 + i * 10, 84, 22 + i * 10, 90], fill=BLUE_TOP)
def _draw_firewall(d):
red, mortar = (196, 64, 44), (240, 220, 200)
_iso_box(d, 16, 40, 100, 104, 12, red, (226, 110, 88), (160, 50, 36))
for row, y in enumerate(range(40, 104, 13)):
d.line([(16, y), (100, y)], fill=mortar, width=2)
off = 0 if row % 2 else 14
for x in range(16 + off, 100, 28):
d.line([(x, y), (x, min(y + 13, 104))], fill=mortar, width=2)
d.rectangle([16, 40, 100, 104], outline=OUTLINE, width=2)
def _draw_ap(d):
d.rounded_rectangle([22, 72, 106, 96], radius=10, fill=BLUE, outline=OUTLINE, width=2)
d.ellipse([56, 78, 72, 90], fill=BLUE_TOP)
for r in (16, 28, 40):
d.arc([64 - r, 62 - r, 64 + r, 62 + r], start=220, end=320, fill=BLUE, width=5)
def _draw_wlc(d):
_iso_box(d, 10, 66, 104, 96, 12, BLUE, BLUE_TOP, BLUE_LIGHT)
for r in (12, 22, 32):
d.arc([57 - r, 52 - r, 57 + r, 52 + r], start=215, end=325, fill=BLUE, width=5)
def _draw_server(d):
d.rectangle([38, 14, 90, 114], fill=(84, 98, 112), outline=OUTLINE, width=2)
d.polygon([(38, 14), (48, 6), (100, 6), (90, 14)], fill=(140, 154, 168), outline=OUTLINE)
d.polygon([(90, 14), (100, 6), (100, 106), (90, 114)], fill=(60, 72, 84), outline=OUTLINE)
for y in range(24, 96, 14):
d.rectangle([45, y, 83, y + 8], fill=(200, 208, 216))
d.ellipse([76, y + 2, 80, y + 6], fill=(60, 200, 90))
def _draw_storage(d):
grey, top = (104, 116, 130), (176, 188, 200)
for y in (70, 46, 22):
_cylinder(d, 22, y, 106, y + 36, 10, grey, top)
def _draw_pc(d):
d.rounded_rectangle([18, 16, 110, 82], radius=6, fill=(50, 56, 64), outline=OUTLINE, width=2)
d.rectangle([26, 24, 102, 74], fill=BLUE_LIGHT)
d.polygon([(54, 82), (74, 82), (80, 100), (48, 100)], fill=(84, 92, 100))
d.rounded_rectangle([34, 100, 94, 108], radius=3, fill=(84, 92, 100))
def _draw_phone(d):
d.rounded_rectangle([30, 52, 104, 106], radius=8, fill=(60, 66, 74), outline=OUTLINE, width=2)
d.rectangle([62, 60, 96, 76], fill=BLUE_LIGHT)
for i in range(3):
for j in range(3):
d.rectangle([64 + j * 11, 82 + i * 7, 70 + j * 11, 86 + i * 7], fill=(200, 200, 200))
d.rounded_rectangle([18, 32, 50, 100], radius=10, fill=(40, 44, 50), outline=OUTLINE, width=2)
def _draw_printer(d):
d.rectangle([40, 18, 88, 50], fill=WHITE, outline=OUTLINE, width=2)
_iso_box(d, 16, 50, 104, 94, 10, (96, 104, 114), (150, 158, 168))
d.rectangle([40, 86, 88, 108], fill=WHITE, outline=OUTLINE, width=2)
d.ellipse([90, 60, 98, 68], fill=(60, 200, 90))
def _draw_camera(d):
d.rounded_rectangle([20, 44, 92, 80], radius=10, fill=(230, 232, 236), outline=OUTLINE, width=2)
d.ellipse([84, 48, 110, 76], fill=(40, 44, 50), outline=OUTLINE, width=2)
d.ellipse([92, 56, 102, 68], fill=BLUE_LIGHT)
d.rectangle([48, 80, 58, 104], fill=(150, 156, 164))
d.rectangle([34, 104, 74, 110], fill=(150, 156, 164))
def _draw_cloud(d):
blobs = [(14, 52, 58, 96), (36, 30, 84, 78), (68, 38, 112, 86), (46, 60, 100, 104), (22, 64, 62, 104)]
for x1, y1, x2, y2 in blobs:
d.ellipse([x1 - 3, y1 - 3, x2 + 3, y2 + 3], fill=BLUE)
for x1, y1, x2, y2 in blobs:
d.ellipse([x1, y1, x2, y2], fill=(236, 244, 252))
def _draw_generic(d):
_iso_box(d, 16, 44, 104, 96, 12, (120, 132, 146), (176, 186, 198), (96, 106, 118))
for i in range(4):
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,
"l3switch": lambda d: _draw_switch(d, l3=True),
"firewall": _draw_firewall,
"ap": _draw_ap,
"wlc": _draw_wlc,
"server": _draw_server,
"storage": _draw_storage,
"pc": _draw_pc,
"phone": _draw_phone,
"printer": _draw_printer,
"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,
}
@lru_cache(maxsize=None)
def builtin_icon(kind):
"""PNG-Bytes eines eingebauten Symbols (SIZE x SIZE, transparent)."""
from PIL import Image, ImageDraw
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
_DRAWERS.get(kind, _draw_generic)(ImageDraw.Draw(img))
out = BytesIO()
img.save(out, "PNG")
return out.getvalue()
def fit_icon(data):
"""Beliebiges Bild (Bytes) auf SIZE x SIZE mit Seitenverhältnis einpassen, transparent auffüllen."""
from PIL import Image
src = Image.open(BytesIO(data)).convert("RGBA")
src.thumbnail((SIZE, SIZE), Image.LANCZOS)
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
img.paste(src, ((SIZE - src.width) // 2, (SIZE - src.height) // 2), src)
out = BytesIO()
img.save(out, "PNG")
return out.getvalue()
class SymbolResolver:
"""Ermittelt Symbol (kind) und Icon für ein Gerät: Gerätetyp-Zuordnung > Rollen-Zuordnung > Heuristik."""
def __init__(self):
from django.db import DatabaseError
from .models import TopologySymbol
self.by_type, self.by_role = {}, {}
try:
for s in TopologySymbol.objects.all():
if s.device_type_id:
self.by_type[s.device_type_id] = s
elif s.role_id:
self.by_role[s.role_id] = s
except DatabaseError: # Migration noch nicht ausgeführt
pass
self._icons = {}
def resolve(self, device):
"""(kind, icon_png_bytes) oder (None, None), wenn das Gerät nicht gezeichnet wird."""
sym = self.by_type.get(device.device_type_id) or self.by_role.get(device.role_id)
if sym:
if sym.kind == HIDDEN:
return None, None
kind = sym.kind or guess_kind(device) or "generic"
if sym.icon:
return kind, self._custom_icon(sym)
return kind, builtin_icon(kind)
kind = guess_kind(device)
return (kind, builtin_icon(kind)) if kind else (None, None)
def _custom_icon(self, sym):
if sym.pk not in self._icons:
try:
with sym.icon.open("rb") as f:
self._icons[sym.pk] = fit_icon(f.read())
except (OSError, ValueError):
self._icons[sym.pk] = builtin_icon(sym.kind or "generic")
return self._icons[sym.pk]