bytesim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. bytesim/__init__.py +14 -0
  2. bytesim/__main__.py +3 -0
  3. bytesim/cli.py +45 -0
  4. bytesim/constants.py +48 -0
  5. bytesim/demos.py +74 -0
  6. bytesim/model/__init__.py +5 -0
  7. bytesim/model/addressing.py +160 -0
  8. bytesim/model/base.py +57 -0
  9. bytesim/model/cloning.py +249 -0
  10. bytesim/model/equivalence.py +86 -0
  11. bytesim/model/groups.py +133 -0
  12. bytesim/model/ha.py +93 -0
  13. bytesim/model/layer2.py +106 -0
  14. bytesim/model/layout.py +203 -0
  15. bytesim/model/naming.py +114 -0
  16. bytesim/model/network.py +138 -0
  17. bytesim/model/routing.py +117 -0
  18. bytesim/model/stp.py +315 -0
  19. bytesim/model/types.py +107 -0
  20. bytesim/py.typed +0 -0
  21. bytesim/scripting.py +108 -0
  22. bytesim/synthesis.py +644 -0
  23. bytesim/topology.py +517 -0
  24. bytesim/tui/__init__.py +4 -0
  25. bytesim/tui/app.py +161 -0
  26. bytesim/tui/cables.py +58 -0
  27. bytesim/tui/canvas.py +489 -0
  28. bytesim/tui/command_input.py +29 -0
  29. bytesim/tui/commands/__init__.py +1 -0
  30. bytesim/tui/commands/addressing.py +235 -0
  31. bytesim/tui/commands/base.py +43 -0
  32. bytesim/tui/commands/groups.py +215 -0
  33. bytesim/tui/commands/history.py +134 -0
  34. bytesim/tui/commands/interpreter.py +85 -0
  35. bytesim/tui/commands/naming.py +68 -0
  36. bytesim/tui/commands/registry.py +28 -0
  37. bytesim/tui/commands/scripting.py +54 -0
  38. bytesim/tui/commands/switching.py +192 -0
  39. bytesim/tui/commands/topology.py +255 -0
  40. bytesim/tui/commands/traffic.py +312 -0
  41. bytesim/tui/help.py +50 -0
  42. bytesim/tui/inspector.py +115 -0
  43. bytesim/tui/theme.py +12 -0
  44. bytesim-0.1.0.dist-info/METADATA +178 -0
  45. bytesim-0.1.0.dist-info/RECORD +48 -0
  46. bytesim-0.1.0.dist-info/WHEEL +4 -0
  47. bytesim-0.1.0.dist-info/entry_points.txt +2 -0
  48. bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,114 @@
1
+ """Renumbering devices in traversal order (BFS or DFS over the cabling)."""
2
+ from __future__ import annotations
3
+
4
+ from collections import deque
5
+
6
+ from ..constants import KINDS, RANK
7
+ from .base import NetworkBase
8
+ from .types import NetError
9
+
10
+ ORDERS = ("bfs", "dfs")
11
+
12
+
13
+ class NamingMixin(NetworkBase):
14
+ def default_root(self) -> str | None:
15
+ """Where a traversal starts unless told otherwise: the first gateway,
16
+ else the first router, switch, then computer."""
17
+ devs = self.sorted_devices()
18
+ return devs[0].id if devs else None
19
+
20
+ def _neighbours(self, dev_id: str) -> list[str]:
21
+ """Devices cabled to dev_id, in the order of dev_id's own ports.
22
+
23
+ Naming follows the physical cabling, so every cable counts (failed or
24
+ control cables too) and computers are walked through like anything else.
25
+ """
26
+ ends = sorted((l.port_of(dev_id), l.other(dev_id)[0]) for l in self.links if l.touches(dev_id))
27
+ return [other for _, other in ends]
28
+
29
+ def traversal(self, order: str, root: str | None = None) -> list[str]:
30
+ """Every device id, in the order a BFS or DFS from root first reaches it.
31
+
32
+ Neighbours are taken in port order. Devices the root can't reach are
33
+ picked up by starting again from the next one in the old (kind, number)
34
+ order, so the result always covers the whole network.
35
+ """
36
+ if order not in ORDERS:
37
+ raise NetError(f"order must be one of: {', '.join(ORDERS)}")
38
+ root = self.get(root).id if root else self.default_root()
39
+ if root is None:
40
+ return []
41
+ starts = [root] + [d.id for d in self.sorted_devices() if d.id != root]
42
+ seen: set[str] = set()
43
+ out: list[str] = []
44
+ for start in starts:
45
+ if start in seen:
46
+ continue
47
+ if order == "bfs":
48
+ seen.add(start)
49
+ q = deque([start])
50
+ while q:
51
+ u = q.popleft()
52
+ out.append(u)
53
+ for v in self._neighbours(u):
54
+ if v not in seen:
55
+ seen.add(v)
56
+ q.append(v)
57
+ else:
58
+ # Iterative pre-order DFS: visit, then go deep through the first
59
+ # unvisited neighbour before trying the next port.
60
+ seen.add(start)
61
+ out.append(start)
62
+ stack = [iter(self._neighbours(start))]
63
+ while stack:
64
+ v = next(stack[-1], None)
65
+ if v is None:
66
+ stack.pop()
67
+ elif v not in seen:
68
+ seen.add(v)
69
+ out.append(v)
70
+ stack.append(iter(self._neighbours(v)))
71
+ return out
72
+
73
+ def renumber(self, order: str, root: str | None = None) -> dict[str, str]:
74
+ """Renumber each kind 1..n in traversal order. Returns {old id: new id}
75
+ for every device whose id changed.
76
+
77
+ Cables, groups, addresses and DHCP settings follow their device. Switch
78
+ MAC tables are cleared, since MACs are derived from device ids. Counters
79
+ are reset so the next new device of each kind gets n+1.
80
+ """
81
+ visit = self.traversal(order, root)
82
+ counts = {k: 0 for k in KINDS}
83
+ new_num: dict[str, int] = {}
84
+ for did in visit:
85
+ dev = self.devices[did]
86
+ counts[dev.kind] += 1
87
+ new_num[did] = counts[dev.kind]
88
+ mapping = {did: f"{self.devices[did].kind}{n}" for did, n in new_num.items()}
89
+ changed = {old: new for old, new in mapping.items() if old != new}
90
+ self.counters = counts
91
+ if not changed:
92
+ return {}
93
+
94
+ def ren(did: str) -> str:
95
+ return mapping.get(did, did)
96
+
97
+ for did, n in new_num.items():
98
+ self.devices[did].num = n
99
+ self.devices = {d.id: d for d in sorted(self.devices.values(), key=lambda d: (RANK[d.kind], d.num))}
100
+ for l in self.links:
101
+ l.a, l.b = ren(l.a), ren(l.b)
102
+ self.groups = {g: [ren(m) if m in mapping else m for m in ms] for g, ms in self.groups.items()}
103
+ self.ifaces = {f"{ren(k.split(':')[0])}:{k.split(':')[1]}": v for k, v in self.ifaces.items()}
104
+ self.dhcp_off = {ren(d) for d in self.dhcp_off}
105
+ self.stp_off = {ren(d) for d in self.stp_off}
106
+ self.stp_priority = {ren(d): v for d, v in self.stp_priority.items()}
107
+ self.stp_blocked = {(ren(d), p) for d, p in self.stp_blocked}
108
+ self.stp_roots = {ren(d): ren(r) for d, r in self.stp_roots.items()}
109
+ self.stp_root_ports = {ren(d): p for d, p in self.stp_root_ports.items()}
110
+ self.storming = {ren(d) for d in self.storming}
111
+ self.mac_flaps = {ren(d): f for d, f in self.mac_flaps.items()}
112
+ self.mac_tables = {}
113
+ self.notes = []
114
+ return changed
@@ -0,0 +1,138 @@
1
+ """The Network: every mixin combined, plus device/cable editing and JSON persistence."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ from ..constants import KINDS, MAX_PORTS
8
+ from .addressing import AddressingMixin
9
+ from .cloning import CloningMixin
10
+ from .groups import GroupsMixin
11
+ from .ha import HAMixin
12
+ from .layer2 import Layer2Mixin
13
+ from .layout import LayoutMixin
14
+ from .naming import NamingMixin
15
+ from .routing import RoutingMixin
16
+ from .stp import SpanningTreeMixin
17
+ from .types import Device, Iface, Link, NetError
18
+
19
+
20
+ class Network(GroupsMixin, Layer2Mixin, AddressingMixin, RoutingMixin, HAMixin, LayoutMixin,
21
+ NamingMixin, SpanningTreeMixin, CloningMixin):
22
+ """A simulated network: devices, cables, groups, addresses and HA clusters.
23
+
24
+ Pure model, no UI: everything here can be driven from Python or tests.
25
+ """
26
+
27
+ def add(self, kind: str, nports: int | None = None) -> Device:
28
+ nports = KINDS[kind][1] if nports is None else nports
29
+ if not 1 <= nports <= MAX_PORTS:
30
+ raise NetError(f"port count must be 1..{MAX_PORTS}")
31
+ self.counters[kind] += 1
32
+ dev = Device(kind, self.counters[kind], nports)
33
+ self.devices[dev.id] = dev
34
+ return dev
35
+
36
+ def remove(self, dev_id: str) -> int:
37
+ dev = self.get(dev_id)
38
+ before = len(self.links)
39
+ self.links = [l for l in self.links if not l.touches(dev.id)]
40
+ del self.devices[dev.id]
41
+ self._drop_from_groups([dev.id])
42
+ for key in [k for k in self.ifaces if k.split(":")[0] == dev.id]:
43
+ del self.ifaces[key]
44
+ self.dhcp_off.discard(dev.id)
45
+ self.stp_off.discard(dev.id)
46
+ self.stp_priority.pop(dev.id, None)
47
+ self.storming.discard(dev.id)
48
+ self.mac_flaps.pop(dev.id, None)
49
+ self.mac_tables = {}
50
+ return before - len(self.links)
51
+
52
+ def resize(self, dev_id: str, nports: int) -> None:
53
+ dev = self.get(dev_id)
54
+ if not 1 <= nports <= MAX_PORTS:
55
+ raise NetError(f"port count must be 1..{MAX_PORTS}")
56
+ used = [l.port_of(dev.id) for l in self.links if l.touches(dev.id)]
57
+ if used and max(used) > nports:
58
+ raise NetError(f"{dev.id} port {max(used)} is in use; unlink it first")
59
+ dev.nports = nports
60
+ for p in range(nports + 1, MAX_PORTS + 1):
61
+ self.ifaces.pop(f"{dev.id}:{p}", None)
62
+
63
+ def connect(self, a: str, pa: int | None, b: str, pb: int | None, role: str = "data") -> Link:
64
+ da, db = self.get(a), self.get(b)
65
+ if da is db:
66
+ raise NetError("can't cable a device to itself")
67
+ ports = []
68
+ for dev, p in ((da, pa), (db, pb)):
69
+ if p is None:
70
+ p = self.free_port(dev)
71
+ if p is None:
72
+ raise NetError(f"{dev.id} has no free ports (try `ports {dev.id} {dev.nports + 1}`)")
73
+ elif not 1 <= p <= dev.nports:
74
+ raise NetError(f"{dev.id} has no port {p} (it has {dev.nports})")
75
+ elif (l := self.link_on(dev.id, p)) is not None:
76
+ o, op = l.other(dev.id)
77
+ raise NetError(f"{dev.id}:{dev.port_name(p)} already cabled to {o}")
78
+ ports.append(p)
79
+ link = Link(da.id, ports[0], db.id, ports[1], role)
80
+ self.links.append(link)
81
+ return link
82
+
83
+ def disconnect(self, a: str, pa: int | None, b: str | None) -> int:
84
+ da = self.get(a)
85
+ db = self.get(b) if b else None
86
+
87
+ def match(l: Link) -> bool:
88
+ if not l.touches(da.id):
89
+ return False
90
+ if pa is not None and l.port_of(da.id) != pa:
91
+ return False
92
+ return db is None or l.other(da.id)[0] == db.id
93
+
94
+ before = len(self.links)
95
+ self.links = [l for l in self.links if not match(l)]
96
+ if len(self.links) != before:
97
+ self.mac_tables = {} # topology changed: switches forget what they learned
98
+ return before - len(self.links)
99
+
100
+
101
+ # ---- persistence ---------------------------------------------------
102
+ def to_json(self) -> str:
103
+ return json.dumps({
104
+ "counters": self.counters,
105
+ "devices": [asdict(d) for d in self.devices.values()],
106
+ "links": [asdict(l) for l in self.links],
107
+ "groups": self.groups,
108
+ "ifaces": {k: asdict(v) for k, v in self.ifaces.items()},
109
+ "dhcp_off": sorted(self.dhcp_off),
110
+ "clusters": self.clusters,
111
+ "stp_off": sorted(self.stp_off, key=lambda d: (d[0], int(d[1:]))),
112
+ "stp_priority": self.stp_priority,
113
+ }, indent=2)
114
+
115
+ @classmethod
116
+ def from_json(cls, raw: str) -> "Network":
117
+ data = json.loads(raw)
118
+ net = cls()
119
+ net.counters.update(data["counters"])
120
+ for d in data["devices"]:
121
+ dev = Device(**d)
122
+ net.devices[dev.id] = dev
123
+ net.links = [Link(**l) for l in data["links"]]
124
+ names = set(data.get("groups", {}))
125
+ net.groups = {g: [d for d in ids if d in net.devices or d in names]
126
+ for g, ids in data.get("groups", {}).items()}
127
+ net._drop_from_groups([])
128
+ for k, v in data.get("ifaces", {}).items():
129
+ if k.split(":")[0] in net.devices:
130
+ try:
131
+ net.ifaces[k] = Iface(**v)
132
+ except TypeError:
133
+ pass # address from an older format: re-learned on load
134
+ net.dhcp_off = set(data.get("dhcp_off", [])) & set(net.devices)
135
+ net.clusters = {c: p for c, p in data.get("clusters", {}).items() if c in net.groups}
136
+ net.stp_off = set(data.get("stp_off", [])) & set(net.devices)
137
+ net.stp_priority = {d: int(v) for d, v in data.get("stp_priority", {}).items() if d in net.devices}
138
+ return net
@@ -0,0 +1,117 @@
1
+ """Forwarding: hosts deliver locally or via their gateway; routers find the cheapest path."""
2
+ from __future__ import annotations
3
+
4
+ import heapq
5
+
6
+ from ..constants import VIP_HOST
7
+ from .base import NetworkBase
8
+ from .types import Leg, NetError, fmt
9
+
10
+
11
+ class RoutingMixin(NetworkBase):
12
+ def route(self, src: str, dst: tuple[str, int] | None) -> list[Leg]:
13
+ """Path from a device to an interface (or to the internet if dst is None).
14
+
15
+ Each leg is one layer-2 hop between addressed interfaces. A host delivers
16
+ directly on its own network, otherwise via its gateway; routers know every
17
+ network of every router they share a network with (a toy IGP).
18
+ """
19
+ dev = self.devices[src]
20
+ if dev.kind == "s":
21
+ raise NetError(f"{src} is a switch: layer 2 only, it has no address to send from")
22
+ if not dev.up:
23
+ raise NetError(f"{src} is down")
24
+ if dst and dst[0] == src:
25
+ return []
26
+ dst_addr = self.addr_of(dst) if dst else None
27
+ if dev.kind in "rg":
28
+ return self._router_route(src, dst, dst_addr)
29
+ nics = self.addressed(src)
30
+ if not nics:
31
+ raise NetError(f"{src} has no address (try: dhcp {src})")
32
+ if dst:
33
+ for p, ifc in nics:
34
+ if ifc.net == dst_addr[0] and dst in self.segment(src, p):
35
+ return [((src, p), dst, self.l2path((src, p), dst))]
36
+ problems = []
37
+ for p, ifc in nics:
38
+ if ifc.gw is None:
39
+ continue
40
+ seg = self.segment(src, p)
41
+ if ifc.gw == VIP_HOST:
42
+ owners = self.vip_owners(ifc.net)
43
+ here = [o for o in owners if o in seg]
44
+ if len(owners) > 1:
45
+ self.notes.append(f"⚠ virtual gateway {fmt((ifc.net, VIP_HOST))} claimed by "
46
+ f"{' and '.join(o[0] for o in owners)} (ARP conflict: "
47
+ f"{src} happens to reach {here[0][0] if here else 'neither'})")
48
+ elif here:
49
+ self.notes.append(f"gateway {fmt((ifc.net, VIP_HOST))} is virtual, "
50
+ f"owned by {here[0][0]} (active)")
51
+ gw = here[0] if here else None
52
+ else:
53
+ gw = next((e for e in seg if self.addr_of(e) == (ifc.net, ifc.gw)), None)
54
+ if gw is None:
55
+ problems.append(f"gateway {fmt((ifc.net, ifc.gw))} unreachable from {src}:{dev.port_name(p)}")
56
+ continue
57
+ try:
58
+ return [((src, p), gw, self.l2path((src, p), gw))] + self._router_route(gw[0], dst, dst_addr)
59
+ except NetError as e:
60
+ problems.append(str(e))
61
+ if problems:
62
+ raise NetError("; ".join(problems))
63
+ where = f"network {dst_addr[0]}" if dst_addr else "the internet"
64
+ raise NetError(f"{where} isn't local to {src} and {src} has no gateway")
65
+
66
+ def _router_route(self, start: str, dst: tuple[str, int] | None,
67
+ dst_addr: tuple[int, int] | None) -> list[Leg]:
68
+ """Cheapest path in cable hops from a router to dst (or to any gateway).
69
+
70
+ Routers are nodes; two routers are adjacent when they share a network.
71
+ Counting cables rather than routers lets a direct fabric link win over
72
+ a detour back through a switched network."""
73
+ best: dict[str, int] = {start: 0}
74
+ prev: dict[str, tuple[str, Leg] | None] = {start: None}
75
+ heap: list[tuple[int, int, str, Leg | None]] = [(0, 0, start, None)]
76
+ tick = 0
77
+ done: set[str] = set()
78
+
79
+ def legs_to(u: str) -> list[Leg]:
80
+ out = []
81
+ while prev[u] is not None:
82
+ parent, leg = prev[u]
83
+ out.append(leg)
84
+ u = parent
85
+ return out[::-1]
86
+
87
+ while heap:
88
+ cost, _, u, final = heapq.heappop(heap)
89
+ if final is not None: # popped the destination itself
90
+ return legs_to(u) + [final]
91
+ if u in done:
92
+ continue
93
+ done.add(u)
94
+ if dst is None and self.devices[u].kind == "g":
95
+ return legs_to(u)
96
+ if dst is not None and dst[0] == u:
97
+ return legs_to(u)
98
+ for p, ifc in self.addressed(u):
99
+ seg = self.segment(u, p)
100
+ if dst is not None and dst in seg and ifc.net == dst_addr[0]:
101
+ path = self.l2path((u, p), dst)
102
+ tick += 1
103
+ heapq.heappush(heap, (cost + len(path) - 1, tick, u, ((u, p), dst, path)))
104
+ for e in seg:
105
+ a = self.addr_of(e)
106
+ if not (self.is_router(e[0]) and a and a[0] == ifc.net) or e[0] in done:
107
+ continue
108
+ path = self.l2path((u, p), e)
109
+ c = cost + len(path) - 1
110
+ if c < best.get(e[0], 10**9):
111
+ best[e[0]] = c
112
+ prev[e[0]] = (u, ((u, p), e, path))
113
+ tick += 1
114
+ heapq.heappush(heap, (c, tick, e[0], None))
115
+ if dst is None:
116
+ raise NetError(f"no route from {start} to a gateway (internet)")
117
+ raise NetError(f"no route to network {dst_addr[0]} from {start}")
bytesim/model/stp.py ADDED
@@ -0,0 +1,315 @@
1
+ """Switching loops: spanning tree (STP), loop detection, flooding, broadcast storms.
2
+
3
+ Switches run STP unless it is switched off for them. STP picks a root bridge
4
+ in every switched area, keeps the shortest path tree to it, and blocks one end
5
+ of every other switch-to-switch cable, so no loop forwards frames. A loop with
6
+ no STP switch able to block it stays live: the next broadcast into it circles
7
+ forever and multiplies. That is a broadcast storm, and it saturates every
8
+ cable it touches until the loop is broken.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter, deque
13
+ from dataclasses import dataclass, field
14
+
15
+ from ..constants import RANK
16
+ from .base import NetworkBase
17
+ from .types import Link
18
+
19
+ DEFAULT_PRIORITY = 32768
20
+
21
+
22
+ @dataclass
23
+ class Flood:
24
+ """What happened to one broadcast frame, generation by generation."""
25
+ source: tuple[str, int]
26
+ generations: list[dict[tuple[int, str], int]] = field(default_factory=list) # (link, arriving at) -> copies
27
+ delivered: Counter = field(default_factory=Counter) # (device, port) -> copies received
28
+ storm: bool = False
29
+ storm_switches: set[str] = field(default_factory=set)
30
+ storm_links: set[int] = field(default_factory=set)
31
+ flaps: dict[str, set[int]] = field(default_factory=dict) # switch -> ports the source MAC arrived on
32
+
33
+ @property
34
+ def totals(self) -> list[int]:
35
+ return [sum(g.values()) for g in self.generations]
36
+
37
+
38
+ class SpanningTreeMixin(NetworkBase):
39
+ # ---- configuration -------------------------------------------------
40
+ def stp_enabled(self, switch: str) -> bool:
41
+ return switch not in self.stp_off
42
+
43
+ def bridge_id(self, switch: str) -> tuple[int, int, int]:
44
+ """Lower wins the root election: priority, then kind and number."""
45
+ d = self.devices[switch]
46
+ return self.stp_priority.get(switch, DEFAULT_PRIORITY), RANK[d.kind], d.num
47
+
48
+ # ---- the switch-to-switch graph -------------------------------------
49
+ def _carries(self, l: Link, honor_stp: bool) -> bool:
50
+ """Could this cable forward frames, storms aside? (STP blocks optional.)"""
51
+ if not (l.up and not l.control and self.devices[l.a].up and self.devices[l.b].up):
52
+ return False
53
+ return not (honor_stp and ((l.a, l.pa) in self.stp_blocked or (l.b, l.pb) in self.stp_blocked))
54
+
55
+ def _trunks(self, honor_stp: bool) -> list[int]:
56
+ """Indices of cables joining two switches that can forward frames."""
57
+ return [i for i, l in enumerate(self.links)
58
+ if self.devices[l.a].kind == "s" and self.devices[l.b].kind == "s"
59
+ and self._carries(l, honor_stp)]
60
+
61
+ def _areas(self, trunks: list[int]) -> list[set[str]]:
62
+ """Switches grouped into areas joined by the given trunks."""
63
+ adj: dict[str, set[str]] = {d.id: set() for d in self.devices.values() if d.kind == "s"}
64
+ for i in trunks:
65
+ l = self.links[i]
66
+ adj[l.a].add(l.b)
67
+ adj[l.b].add(l.a)
68
+ out, seen = [], set()
69
+ for s in sorted(adj, key=lambda s: self.bridge_id(s)):
70
+ if s in seen:
71
+ continue
72
+ comp, q = {s}, deque([s])
73
+ while q:
74
+ for v in adj[q.popleft()]:
75
+ if v not in comp:
76
+ comp.add(v)
77
+ q.append(v)
78
+ seen |= comp
79
+ out.append(comp)
80
+ return out
81
+
82
+ def loop_links(self, honor_stp: bool = True) -> set[int]:
83
+ """Trunks that lie on a loop (any cable that isn't a bridge in the graph theory sense)."""
84
+ trunks = self._trunks(honor_stp)
85
+ adj: dict[str, list[tuple[str, int]]] = {}
86
+ for i in trunks:
87
+ l = self.links[i]
88
+ adj.setdefault(l.a, []).append((l.b, i))
89
+ adj.setdefault(l.b, []).append((l.a, i))
90
+ disc: dict[str, int] = {}
91
+ low: dict[str, int] = {}
92
+ bridges: set[int] = set()
93
+ timer = [0]
94
+
95
+ def dfs(u: str, via: int) -> None: # Tarjan's bridge finding (multigraph-safe)
96
+ disc[u] = low[u] = timer[0]
97
+ timer[0] += 1
98
+ for v, i in adj.get(u, []):
99
+ if i == via:
100
+ continue
101
+ if v in disc:
102
+ low[u] = min(low[u], disc[v])
103
+ else:
104
+ dfs(v, i)
105
+ low[u] = min(low[u], low[v])
106
+ if low[v] > disc[u]:
107
+ bridges.add(i)
108
+
109
+ for s in adj:
110
+ if s not in disc:
111
+ dfs(s, -1)
112
+ return set(trunks) - bridges
113
+
114
+ # ---- spanning tree ---------------------------------------------------
115
+ def stp_compute(self) -> tuple[set[tuple[str, int]], dict[str, str], dict[str, int]]:
116
+ """Blocked ports, each switch's root bridge, and each switch's root port.
117
+
118
+ Per switched area, the root is the STP switch with the lowest bridge id.
119
+ Switches without STP pass STP frames straight through (as real ones do),
120
+ so they can't block anything but don't hide loops either. Cables with
121
+ no STP end always forward; every other cable is added nearest-the-root
122
+ first, and one that would close a loop is blocked at its end farther
123
+ from the root (or its only STP end). What's left is loop-free except
124
+ for loops made entirely of non-STP switches, which nothing can break.
125
+ """
126
+ trunks = self._trunks(honor_stp=False)
127
+ blocked: set[tuple[str, int]] = set()
128
+ roots: dict[str, str] = {}
129
+ root_ports: dict[str, int] = {}
130
+ for area in self._areas(trunks):
131
+ bridges = [s for s in area if self.stp_enabled(s)]
132
+ if not bridges:
133
+ continue
134
+ root = min(bridges, key=self.bridge_id)
135
+ for s in area:
136
+ roots[s] = root
137
+ here = [i for i in trunks if self.links[i].a in area]
138
+ cost = self._hops_from(root, here)
139
+
140
+ parent = {s: s for s in area} # union-find over what forwards
141
+
142
+ def find(s: str) -> str:
143
+ while parent[s] != s:
144
+ parent[s] = parent[parent[s]]
145
+ s = parent[s]
146
+ return s
147
+
148
+ def rank(i: int) -> tuple:
149
+ l = self.links[i]
150
+ near, far = sorted([(l.a, l.pa), (l.b, l.pb)],
151
+ key=lambda e: (cost[e[0]], self.bridge_id(e[0])))
152
+ return (cost[far[0]], cost[near[0]], self.bridge_id(near[0]), near[1])
153
+
154
+ forced = [i for i in here if not (self.stp_enabled(self.links[i].a)
155
+ or self.stp_enabled(self.links[i].b))]
156
+ for i in forced + sorted((i for i in here if i not in forced), key=rank):
157
+ l = self.links[i]
158
+ ra, rb = find(l.a), find(l.b)
159
+ if ra != rb:
160
+ parent[ra] = rb
161
+ continue
162
+ if i in forced:
163
+ continue # an unbreakable loop: no STP end
164
+ ends = sorted([(l.a, l.pa), (l.b, l.pb)],
165
+ key=lambda e: (cost[e[0]], self.bridge_id(e[0])), reverse=True)
166
+ blocked.add(next(e for e in ends if self.stp_enabled(e[0])))
167
+ # Root port: the forwarding cable that leads one hop closer to the root.
168
+ fwd = [i for i in here if (self.links[i].a, self.links[i].pa) not in blocked
169
+ and (self.links[i].b, self.links[i].pb) not in blocked]
170
+ dist = self._hops_from(root, fwd)
171
+ for s in area:
172
+ if s == root or not self.stp_enabled(s) or s not in dist:
173
+ continue
174
+ best = min(((self.bridge_id(self.links[i].other(s)[0]), self.links[i].port_of(s))
175
+ for i in fwd if self.links[i].touches(s)
176
+ and dist.get(self.links[i].other(s)[0], 10**9) < dist[s]), default=None)
177
+ if best:
178
+ root_ports[s] = best[1]
179
+ return blocked, roots, root_ports
180
+
181
+ def _hops_from(self, start: str, trunks: list[int]) -> dict[str, int]:
182
+ dist = {start: 0}
183
+ q = deque([start])
184
+ while q:
185
+ u = q.popleft()
186
+ for i in trunks:
187
+ l = self.links[i]
188
+ if l.touches(u) and (v := l.other(u)[0]) not in dist:
189
+ dist[v] = dist[u] + 1
190
+ q.append(v)
191
+ return dist
192
+
193
+ def port_role(self, switch: str, port: int) -> str:
194
+ """root / designated / blocking / edge, or '' (unplugged, or STP off)."""
195
+ li = self.link_index(switch, port)
196
+ if li is None:
197
+ return ""
198
+ if (switch, port) in self.stp_blocked:
199
+ return "blocking"
200
+ if self.devices[self.links[li].other(switch)[0]].kind != "s":
201
+ return "edge"
202
+ if not self.stp_enabled(switch) or switch not in self.stp_roots:
203
+ return ""
204
+ return "root" if self.stp_root_ports.get(switch) == port else "designated"
205
+
206
+ def stp_update(self) -> list[str]:
207
+ """Re-run STP and end storms whose loop is gone. Returns notes on what changed."""
208
+ notes = []
209
+ before = self.stp_blocked
210
+ self.stp_blocked, self.stp_roots, self.stp_root_ports = self.stp_compute()
211
+ for sw, p in sorted(before - self.stp_blocked):
212
+ li = self.link_index(sw, p) if sw in self.devices else None
213
+ if li is not None and self._carries(self.links[li], honor_stp=False):
214
+ notes.append(f"STP {sw}:p{p} blocking → forwarding")
215
+ for sw, p in sorted(self.stp_blocked - before):
216
+ notes.append(f"STP {sw}:p{p} → blocking")
217
+ if self.storming:
218
+ # A storm lives as long as its area still has a forwarding loop.
219
+ looped = {d for i in self.loop_links(honor_stp=True)
220
+ for d in (self.links[i].a, self.links[i].b)}
221
+ still: set[str] = set()
222
+ for area in self._areas(self._trunks(honor_stp=True)):
223
+ if area & self.storming and area & looped:
224
+ still |= area & self.storming
225
+ ended = self.storming - still
226
+ if ended:
227
+ self.storming = still
228
+ for s in ended:
229
+ self.mac_flaps.pop(s, None)
230
+ notes.append(f"broadcast storm over: {' '.join(sorted(ended, key=self.bridge_id))} "
231
+ f"(loop broken)")
232
+ return notes
233
+
234
+ # ---- flooding ----------------------------------------------------------
235
+ def flood(self, src: tuple[str, int]) -> Flood:
236
+ """Send one broadcast frame out of an interface and follow every copy.
237
+
238
+ Switches copy it out of every forwarding port except the one it came in
239
+ on; anything else just receives it. Copies are counted exactly per
240
+ (cable, direction), so even an exponential storm stays cheap to model.
241
+ A loop-free area empties within as many hops as there are cables; if
242
+ frames are still alive after that, they are circling: a storm.
243
+ """
244
+ out = Flood(source=src)
245
+ li0 = self.link_index(*src)
246
+ if li0 is None:
247
+ return out
248
+
249
+ def ok(l: Link) -> bool:
250
+ return (self._carries(l, honor_stp=True)
251
+ and l.a not in self.storming and l.b not in self.storming)
252
+
253
+ if not ok(self.links[li0]):
254
+ return out
255
+ state: Counter = Counter({(li0, self.links[li0].other(src[0])[0]): 1})
256
+ for _ in range(len(self.links) + 2):
257
+ out.generations.append(dict(state))
258
+ nxt: Counter = Counter()
259
+ for (li, dev), n in state.items():
260
+ port = self.links[li].port_of(dev)
261
+ if self.devices[dev].kind != "s":
262
+ out.delivered[(dev, port)] += n
263
+ continue
264
+ out.flaps.setdefault(dev, set()).add(port)
265
+ for lj, l in enumerate(self.links):
266
+ if lj != li and l.touches(dev) and ok(l):
267
+ nxt[(lj, l.other(dev)[0])] += n
268
+ if not nxt:
269
+ break
270
+ state = nxt
271
+ else:
272
+ out.storm = True
273
+ # Everything the circling frames touched over the last lap (a loop can't
274
+ # be longer than the number of cables), not just where they are now.
275
+ lap = out.generations[-(len(self.links) + 1):] + [dict(state)]
276
+ out.storm_links = {li for gen in lap for li, _ in gen}
277
+ out.storm_switches = {d for li in out.storm_links for d in (self.links[li].a, self.links[li].b)
278
+ if self.devices[d].kind == "s"}
279
+ out.flaps = {s: ps for s, ps in out.flaps.items() if len(ps) > 1}
280
+ return out
281
+
282
+ def start_storm(self, flood: Flood) -> None:
283
+ """Mark a storming area: its cables carry nothing but the storm from now on."""
284
+ mac = self.devices[flood.source[0]].mac(flood.source[1])
285
+ area = set()
286
+ for a in self._areas(self._trunks(honor_stp=True)):
287
+ if a & flood.storm_switches:
288
+ area |= a
289
+ self.storming |= area
290
+ for s, ports in flood.flaps.items():
291
+ self.mac_flaps.setdefault(s, {})[mac] = ports
292
+
293
+ def learn_flood(self, flood: Flood) -> None:
294
+ """Switches a (loop-free) broadcast passed through learn its sender's MAC."""
295
+ mac = self.devices[flood.source[0]].mac(flood.source[1])
296
+ for gen in flood.generations:
297
+ for li, dev in gen:
298
+ if self.devices[dev].kind == "s":
299
+ self.mac_tables.setdefault(dev, {})[mac] = self.links[li].port_of(dev)
300
+
301
+ def storm_near(self, dev_id: str) -> set[str]:
302
+ """Storming switches in the switched area(s) this device is plugged into."""
303
+ seen, q, hit = {dev_id}, deque([dev_id]), set()
304
+ while q:
305
+ u = q.popleft()
306
+ for l in self.links:
307
+ if l.touches(u) and self._carries(l, honor_stp=True):
308
+ v = l.other(u)[0]
309
+ if v in seen or self.devices[v].kind != "s":
310
+ continue
311
+ seen.add(v)
312
+ q.append(v)
313
+ if v in self.storming:
314
+ hit.add(v)
315
+ return hit