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.
- bytesim/__init__.py +14 -0
- bytesim/__main__.py +3 -0
- bytesim/cli.py +45 -0
- bytesim/constants.py +48 -0
- bytesim/demos.py +74 -0
- bytesim/model/__init__.py +5 -0
- bytesim/model/addressing.py +160 -0
- bytesim/model/base.py +57 -0
- bytesim/model/cloning.py +249 -0
- bytesim/model/equivalence.py +86 -0
- bytesim/model/groups.py +133 -0
- bytesim/model/ha.py +93 -0
- bytesim/model/layer2.py +106 -0
- bytesim/model/layout.py +203 -0
- bytesim/model/naming.py +114 -0
- bytesim/model/network.py +138 -0
- bytesim/model/routing.py +117 -0
- bytesim/model/stp.py +315 -0
- bytesim/model/types.py +107 -0
- bytesim/py.typed +0 -0
- bytesim/scripting.py +108 -0
- bytesim/synthesis.py +644 -0
- bytesim/topology.py +517 -0
- bytesim/tui/__init__.py +4 -0
- bytesim/tui/app.py +161 -0
- bytesim/tui/cables.py +58 -0
- bytesim/tui/canvas.py +489 -0
- bytesim/tui/command_input.py +29 -0
- bytesim/tui/commands/__init__.py +1 -0
- bytesim/tui/commands/addressing.py +235 -0
- bytesim/tui/commands/base.py +43 -0
- bytesim/tui/commands/groups.py +215 -0
- bytesim/tui/commands/history.py +134 -0
- bytesim/tui/commands/interpreter.py +85 -0
- bytesim/tui/commands/naming.py +68 -0
- bytesim/tui/commands/registry.py +28 -0
- bytesim/tui/commands/scripting.py +54 -0
- bytesim/tui/commands/switching.py +192 -0
- bytesim/tui/commands/topology.py +255 -0
- bytesim/tui/commands/traffic.py +312 -0
- bytesim/tui/help.py +50 -0
- bytesim/tui/inspector.py +115 -0
- bytesim/tui/theme.py +12 -0
- bytesim-0.1.0.dist-info/METADATA +178 -0
- bytesim-0.1.0.dist-info/RECORD +48 -0
- bytesim-0.1.0.dist-info/WHEEL +4 -0
- bytesim-0.1.0.dist-info/entry_points.txt +2 -0
- bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Canonical form of a network, and the differences between two of them.
|
|
2
|
+
|
|
3
|
+
Two networks are *equivalent* when they agree on everything ByteSim promises a
|
|
4
|
+
construction script rebuilds: device ids, kinds and port counts; id counters
|
|
5
|
+
(so the next new device gets the same id); groups, their nesting and member
|
|
6
|
+
order; every cable's endpoints, ports and role; HA clusters; STP, DHCP-server
|
|
7
|
+
and interface settings; and which devices and cables are failed.
|
|
8
|
+
|
|
9
|
+
Deliberately ignored: positions (layout is recomputed), learned addresses
|
|
10
|
+
(re-learned by DHCP and router autoconfiguration), MAC tables, STP's blocked
|
|
11
|
+
ports (recomputed), storms, and the order groups were created in.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..constants import KINDS
|
|
19
|
+
from .network import Network
|
|
20
|
+
|
|
21
|
+
DEFAULT_MODE = {"c": "dhcp", "g": "auto", "r": "auto"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def iface_config(net: Network, dev_id: str, port: int) -> tuple[str, Any]:
|
|
25
|
+
"""An interface's configuration: (mode, static address or None)."""
|
|
26
|
+
dev = net.devices[dev_id]
|
|
27
|
+
ifc = net.ifaces.get(f"{dev_id}:{port}")
|
|
28
|
+
mode = ifc.mode if ifc else DEFAULT_MODE[dev.kind]
|
|
29
|
+
static = (ifc.net, ifc.host, ifc.gw) if ifc and mode == "static" and ifc.addr else None
|
|
30
|
+
return mode, static
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def link_key(a: str, pa: int, b: str, pb: int, role: str) -> tuple:
|
|
34
|
+
return (*sorted([(a, pa), (b, pb)], key=lambda e: (e[0][0], int(e[0][1:]), e[1])), role)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonical(net: Network) -> dict[str, Any]:
|
|
38
|
+
nums = {k: [d.num for d in net.devices.values() if d.kind == k] for k in KINDS}
|
|
39
|
+
ifaces = {}
|
|
40
|
+
for d in net.sorted_devices():
|
|
41
|
+
if d.kind == "s":
|
|
42
|
+
continue
|
|
43
|
+
for p in range(1, d.nports + 1):
|
|
44
|
+
cfg = iface_config(net, d.id, p)
|
|
45
|
+
if cfg != (DEFAULT_MODE[d.kind], None):
|
|
46
|
+
ifaces[f"{d.id}:{p}"] = cfg
|
|
47
|
+
return {
|
|
48
|
+
"devices": {d.id: (d.kind, d.nports) for d in net.devices.values()},
|
|
49
|
+
"counters": {k: max([net.counters[k], *nums[k]]) for k in KINDS},
|
|
50
|
+
"cables": Counter(link_key(l.a, l.pa, l.b, l.pb, l.role) for l in net.links),
|
|
51
|
+
"groups": {g: tuple(ms) for g, ms in net.groups.items()},
|
|
52
|
+
"clusters": dict(net.clusters),
|
|
53
|
+
"stp_off": frozenset(net.stp_off),
|
|
54
|
+
"stp_priority": dict(net.stp_priority),
|
|
55
|
+
"dhcp_off": frozenset(net.dhcp_off),
|
|
56
|
+
"ifaces": ifaces,
|
|
57
|
+
"devices_down": frozenset(d.id for d in net.devices.values() if not d.up),
|
|
58
|
+
"cables_down": Counter(link_key(l.a, l.pa, l.b, l.pb, l.role) for l in net.links if not l.up),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def differences(want: Network, got: Network, limit: int = 8) -> list[str]:
|
|
63
|
+
"""Human-readable differences between two networks' canonical forms ([] = equivalent)."""
|
|
64
|
+
a, b = canonical(want), canonical(got)
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for key in a:
|
|
67
|
+
x, y = a[key], b[key]
|
|
68
|
+
if x == y:
|
|
69
|
+
continue
|
|
70
|
+
if isinstance(x, dict):
|
|
71
|
+
for k in sorted(set(x) | set(y), key=str):
|
|
72
|
+
if x.get(k) != y.get(k):
|
|
73
|
+
out.append(f"{key} {k}: expected {x.get(k)!r}, got {y.get(k)!r}")
|
|
74
|
+
elif isinstance(x, Counter):
|
|
75
|
+
for k in sorted((x - y).keys()):
|
|
76
|
+
out.append(f"{key}: missing {_cable(k)}")
|
|
77
|
+
for k in sorted((y - x).keys()):
|
|
78
|
+
out.append(f"{key}: unexpected {_cable(k)}")
|
|
79
|
+
else:
|
|
80
|
+
out.append(f"{key}: expected {sorted(x)}, got {sorted(y)}")
|
|
81
|
+
return out[:limit] + ([f"… and {len(out) - limit} more"] if len(out) > limit else [])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cable(key: tuple) -> str:
|
|
85
|
+
(a, pa), (b, pb), role = key
|
|
86
|
+
return f"{a}:p{pa}–{b}:p{pb}" + (f" ({role})" if role != "data" else "")
|
bytesim/model/groups.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Groups: named, nestable sets of devices and other groups."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..constants import AUTO, BOX_H, GROUP_NAME, REF
|
|
5
|
+
from .base import NetworkBase
|
|
6
|
+
from .types import NetError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GroupsMixin(NetworkBase):
|
|
10
|
+
# A group's members are device ids and/or other group names (never ambiguous:
|
|
11
|
+
# group names can't look like device ids). Membership is a tree.
|
|
12
|
+
|
|
13
|
+
def parent_of(self, member: str) -> str | None:
|
|
14
|
+
return next((g for g, ms in self.groups.items() if member in ms), None)
|
|
15
|
+
|
|
16
|
+
def group_of(self, dev_id: str) -> str | None:
|
|
17
|
+
"""The innermost group holding a device."""
|
|
18
|
+
return self.parent_of(dev_id)
|
|
19
|
+
|
|
20
|
+
def chain(self, member: str) -> list[str]:
|
|
21
|
+
"""Enclosing groups, outermost first."""
|
|
22
|
+
out, g = [], self.parent_of(member)
|
|
23
|
+
while g is not None:
|
|
24
|
+
out.append(g)
|
|
25
|
+
g = self.parent_of(g)
|
|
26
|
+
return out[::-1]
|
|
27
|
+
|
|
28
|
+
def devices_in(self, name: str) -> list[str]:
|
|
29
|
+
out: list[str] = []
|
|
30
|
+
for m in self.groups.get(name, []):
|
|
31
|
+
out.extend(self.devices_in(m) if m in self.groups else [m])
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
def subgroups(self, name: str) -> list[str]:
|
|
35
|
+
return [m for m in self.groups.get(name, []) if m in self.groups]
|
|
36
|
+
|
|
37
|
+
def height(self, name: str) -> int:
|
|
38
|
+
"""Levels of nested groups inside this one (0 = only devices)."""
|
|
39
|
+
return max((1 + self.height(g) for g in self.subgroups(name)), default=0)
|
|
40
|
+
|
|
41
|
+
def free_group_name(self, prefix: str, reserved: frozenset[str] = frozenset()) -> str:
|
|
42
|
+
"""prefix + the lowest number not already taken: fab → fab5 when fab1–fab4 exist.
|
|
43
|
+
``reserved`` holds names claimed explicitly elsewhere (not created yet)."""
|
|
44
|
+
prefix = prefix.lower()
|
|
45
|
+
if REF.match(f"{prefix}1"):
|
|
46
|
+
raise NetError(f"{prefix}(…) would make names like device ids ({prefix}1)")
|
|
47
|
+
n = 1
|
|
48
|
+
while f"{prefix}{n}" in self.groups or f"{prefix}{n}" in self.devices or f"{prefix}{n}" in reserved:
|
|
49
|
+
n += 1
|
|
50
|
+
name = f"{prefix}{n}"
|
|
51
|
+
if not GROUP_NAME.match(name):
|
|
52
|
+
raise NetError(f"bad group name {name!r} (start with a letter, at most 16 characters)")
|
|
53
|
+
return name
|
|
54
|
+
|
|
55
|
+
def resolve_group_name(self, name: str, reserved: frozenset[str] = frozenset()) -> str:
|
|
56
|
+
"""A group name, or an auto-number pattern like fab(1) turned into the next free one."""
|
|
57
|
+
m = AUTO.match(name)
|
|
58
|
+
if not m:
|
|
59
|
+
return name.lower()
|
|
60
|
+
if int(m[2]) != 1:
|
|
61
|
+
raise NetError(f"{name}: a group name stands for one group, so write {m[1]}(1)")
|
|
62
|
+
return self.free_group_name(m[1], reserved)
|
|
63
|
+
|
|
64
|
+
def group_add(self, name: str, members: list[str]) -> list[str]:
|
|
65
|
+
"""Add devices and/or groups to a group (creating it at the top level).
|
|
66
|
+
Returns members that were moved out of another group."""
|
|
67
|
+
if not GROUP_NAME.match(name) or REF.match(name):
|
|
68
|
+
raise NetError(f"bad group name {name!r}: start with a letter and must not look like a device id")
|
|
69
|
+
if name in self.devices:
|
|
70
|
+
raise NetError(f"{name} is a device")
|
|
71
|
+
ids = []
|
|
72
|
+
for m in members:
|
|
73
|
+
if m in self.groups:
|
|
74
|
+
if m == name:
|
|
75
|
+
raise NetError(f"can't put {name} inside itself")
|
|
76
|
+
if m in self.chain(name):
|
|
77
|
+
raise NetError(f"can't put {m} inside {name}: {name} is already inside {m}")
|
|
78
|
+
ids.append(m)
|
|
79
|
+
else:
|
|
80
|
+
ids.append(self.get(m).id)
|
|
81
|
+
moved = [m for m in ids if self.parent_of(m) not in (None, name)]
|
|
82
|
+
group = self.groups.setdefault(name, [])
|
|
83
|
+
for m in ids:
|
|
84
|
+
old = self.parent_of(m)
|
|
85
|
+
if old not in (None, name):
|
|
86
|
+
self.groups[old].remove(m)
|
|
87
|
+
if m not in group:
|
|
88
|
+
group.append(m)
|
|
89
|
+
self._drop_from_groups([])
|
|
90
|
+
return moved
|
|
91
|
+
|
|
92
|
+
def ungroup(self, name: str, members: list[str] | None = None) -> int:
|
|
93
|
+
"""Dissolve a group, or take some members out; either way they move up to its parent."""
|
|
94
|
+
if name not in self.groups:
|
|
95
|
+
raise NetError(f"no such group: {name}")
|
|
96
|
+
parent = self.parent_of(name)
|
|
97
|
+
if members is None:
|
|
98
|
+
kids = self.groups.pop(name)
|
|
99
|
+
if parent:
|
|
100
|
+
at = self.groups[parent].index(name)
|
|
101
|
+
self.groups[parent][at:at + 1] = kids
|
|
102
|
+
self.clusters.pop(name, None)
|
|
103
|
+
self._drop_from_groups([])
|
|
104
|
+
return len(kids)
|
|
105
|
+
ids = [m if m in self.groups else self.get(m).id for m in members]
|
|
106
|
+
out = [m for m in self.groups[name] if m in ids]
|
|
107
|
+
self.groups[name] = [m for m in self.groups[name] if m not in ids]
|
|
108
|
+
if parent:
|
|
109
|
+
self.groups[parent].extend(out)
|
|
110
|
+
self._drop_from_groups([])
|
|
111
|
+
return len(out)
|
|
112
|
+
|
|
113
|
+
def _drop_from_groups(self, ids: list[str]) -> None:
|
|
114
|
+
"""Remove members everywhere, then prune groups left empty (and their references)."""
|
|
115
|
+
gone = set(ids)
|
|
116
|
+
while True:
|
|
117
|
+
for g in self.groups:
|
|
118
|
+
self.groups[g] = [m for m in self.groups[g] if m not in gone]
|
|
119
|
+
empty = {g for g, ms in self.groups.items() if not ms}
|
|
120
|
+
if not empty:
|
|
121
|
+
break
|
|
122
|
+
for g in empty:
|
|
123
|
+
del self.groups[g]
|
|
124
|
+
gone = empty
|
|
125
|
+
self.clusters = {c: pol for c, pol in self.clusters.items() if c in self.groups}
|
|
126
|
+
|
|
127
|
+
def group_bounds(self, name: str) -> tuple[int, int, int, int]:
|
|
128
|
+
"""Frame rectangle (x0, y0, x1, y1), inclusive. Outer groups get wider margins
|
|
129
|
+
so nested frames sit inside each other."""
|
|
130
|
+
ds = [self.devices[d] for d in self.devices_in(name)]
|
|
131
|
+
h = self.height(name)
|
|
132
|
+
return (min(d.x for d in ds) - 2 - 2 * h, min(d.y for d in ds) - 1 - h,
|
|
133
|
+
max(d.x + d.width for d in ds) + 1 + 2 * h, max(d.y for d in ds) + BOX_H + h)
|
bytesim/model/ha.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""HA router clusters: coordination over control links, virtual gateways, split-brain."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
|
|
6
|
+
from .base import NetworkBase
|
|
7
|
+
from .types import Link
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HAMixin(NetworkBase):
|
|
11
|
+
def cluster_of(self, dev_id: str) -> str | None:
|
|
12
|
+
if not self.is_router(dev_id):
|
|
13
|
+
return None
|
|
14
|
+
return next((g for g in reversed(self.chain(dev_id)) if g in self.clusters), None)
|
|
15
|
+
|
|
16
|
+
def ha_state(self, name: str) -> dict:
|
|
17
|
+
"""Who is alive, who can coordinate, who is active, and who owns each virtual gateway.
|
|
18
|
+
|
|
19
|
+
Members coordinate only over control links. Each control-connected partition
|
|
20
|
+
elects its own active member per network; more than one active partition is
|
|
21
|
+
split-brain. The policy decides which partitions may stay active:
|
|
22
|
+
none (all), quorum (strict majority), witness (majority; a tie goes to the
|
|
23
|
+
partition holding the first surviving member)."""
|
|
24
|
+
members = [m for m in self.devices_in(name) if self.is_router(m)]
|
|
25
|
+
alive = [m for m in members if self.devices[m].up]
|
|
26
|
+
ctrl = [l for l in self.links if l.control and l.a in members and l.b in members]
|
|
27
|
+
ctrl_up = [l for l in ctrl if l.up and self.devices[l.a].up and self.devices[l.b].up]
|
|
28
|
+
|
|
29
|
+
def parts(links: list[Link]) -> list[list[str]]:
|
|
30
|
+
out, seen = [], set()
|
|
31
|
+
for m in alive:
|
|
32
|
+
if m in seen:
|
|
33
|
+
continue
|
|
34
|
+
comp, q = [m], deque([m])
|
|
35
|
+
seen.add(m)
|
|
36
|
+
while q:
|
|
37
|
+
u = q.popleft()
|
|
38
|
+
for l in links:
|
|
39
|
+
if l.touches(u) and (v := l.other(u)[0]) not in seen:
|
|
40
|
+
seen.add(v)
|
|
41
|
+
comp.append(v)
|
|
42
|
+
q.append(v)
|
|
43
|
+
out.append(sorted(comp, key=members.index))
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
partitions = parts(ctrl_up)
|
|
47
|
+
n, policy = len(members), self.clusters.get(name, "none")
|
|
48
|
+
|
|
49
|
+
def may_lead(part: list[str]) -> bool:
|
|
50
|
+
if policy == "quorum":
|
|
51
|
+
return 2 * len(part) > n
|
|
52
|
+
if policy == "witness":
|
|
53
|
+
# The witness sees who is alive and breaks ties for the first survivor.
|
|
54
|
+
return 2 * len(part) > n or (2 * len(part) == n and alive[0] in part)
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
active = [p for p in partitions if may_lead(p)]
|
|
58
|
+
owners: dict[int, list[tuple[str, int]]] = {}
|
|
59
|
+
for part in active:
|
|
60
|
+
claimed: set[int] = set()
|
|
61
|
+
for m in part:
|
|
62
|
+
for port, ifc in self.addressed(m):
|
|
63
|
+
li = self.link_index(m, port)
|
|
64
|
+
if li is not None and self.usable(self.links[li]) and ifc.net not in claimed:
|
|
65
|
+
claimed.add(ifc.net)
|
|
66
|
+
owners.setdefault(ifc.net, []).append((m, port))
|
|
67
|
+
# Redundant coordination: losing any single control link mustn't split anyone.
|
|
68
|
+
redundant = len(partitions) == 1 and all(
|
|
69
|
+
len(parts([k for k in ctrl_up if k is not l])) == 1 for l in ctrl_up)
|
|
70
|
+
dead = [m for m in members if m not in alive]
|
|
71
|
+
leaders = [p[0] for p in active]
|
|
72
|
+
if not alive:
|
|
73
|
+
level, text = "down", "DOWN · every member has failed"
|
|
74
|
+
elif len(active) > 1:
|
|
75
|
+
level, text = "split", ("SPLIT-BRAIN · " + " | ".join(p[0] for p in active)
|
|
76
|
+
+ " all active, no control link between them")
|
|
77
|
+
elif not active:
|
|
78
|
+
level, text = "down", "OUTAGE · no partition has quorum, all members stood down"
|
|
79
|
+
elif len(partitions) > 1:
|
|
80
|
+
standby = [m for p in partitions if p not in active for m in p]
|
|
81
|
+
level, text = "degraded", f"partitioned · {leaders[0]} active, {' '.join(standby)} stood down ({policy})"
|
|
82
|
+
elif dead:
|
|
83
|
+
level, text = "degraded", f"failover · {' '.join(dead)} down, {leaders[0]} active"
|
|
84
|
+
elif n > 1 and not redundant:
|
|
85
|
+
level, text = "degraded", f"no redundancy · control {len(ctrl_up)}/{len(ctrl)} up, one more failure splits it"
|
|
86
|
+
else:
|
|
87
|
+
level, text = "ok", f"healthy · {leaders[0]} active, control {len(ctrl_up)}/{len(ctrl)} up"
|
|
88
|
+
return {"members": members, "alive": alive, "partitions": partitions, "active": active,
|
|
89
|
+
"owners": owners, "ctrl": len(ctrl), "ctrl_up": len(ctrl_up),
|
|
90
|
+
"policy": policy, "level": level, "text": text}
|
|
91
|
+
|
|
92
|
+
def vip_owners(self, net: int) -> list[tuple[str, int]]:
|
|
93
|
+
return [o for c in self.clusters for o in self.ha_state(c)["owners"].get(net, [])]
|
bytesim/model/layer2.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Layer 2: which cables carry data, broadcast domains, frame paths, MAC learning."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
|
|
6
|
+
from .base import NetworkBase
|
|
7
|
+
from .types import Link
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Layer2Mixin(NetworkBase):
|
|
11
|
+
def usable(self, l: Link) -> bool:
|
|
12
|
+
"""Can this cable carry data? Not if it's down, a control link, an end is dead,
|
|
13
|
+
STP is blocking either end, or a broadcast storm has saturated it."""
|
|
14
|
+
return (l.up and not l.control and self.devices[l.a].up and self.devices[l.b].up
|
|
15
|
+
and (l.a, l.pa) not in self.stp_blocked and (l.b, l.pb) not in self.stp_blocked
|
|
16
|
+
and l.a not in self.storming and l.b not in self.storming)
|
|
17
|
+
|
|
18
|
+
def segment(self, dev_id: str, port: int) -> list[tuple[str, int]]:
|
|
19
|
+
"""Other interfaces in the same broadcast domain (reached through switches only)."""
|
|
20
|
+
li = self.link_index(dev_id, port)
|
|
21
|
+
if li is None or not self.usable(self.links[li]):
|
|
22
|
+
return []
|
|
23
|
+
v, vp = self.links[li].other(dev_id)
|
|
24
|
+
if self.devices[v].kind != "s":
|
|
25
|
+
return [(v, vp)]
|
|
26
|
+
seen, q, out = {v}, deque([v]), []
|
|
27
|
+
while q:
|
|
28
|
+
u = q.popleft()
|
|
29
|
+
for l in self.links:
|
|
30
|
+
if l.touches(u) and self.usable(l):
|
|
31
|
+
w, wp = l.other(u)
|
|
32
|
+
if (w, wp) == (dev_id, port):
|
|
33
|
+
continue
|
|
34
|
+
if self.devices[w].kind == "s":
|
|
35
|
+
if w not in seen:
|
|
36
|
+
seen.add(w)
|
|
37
|
+
q.append(w)
|
|
38
|
+
elif (w, wp) not in out:
|
|
39
|
+
out.append((w, wp))
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
def l2path(self, src: tuple[str, int], dst: tuple[str, int]) -> list[tuple[str, int]] | None:
|
|
43
|
+
"""Frame path from one interface to another, switching only through switches."""
|
|
44
|
+
a, ap = src
|
|
45
|
+
li = self.link_index(a, ap)
|
|
46
|
+
if li is None or not self.usable(self.links[li]):
|
|
47
|
+
return None
|
|
48
|
+
v, vp = self.links[li].other(a)
|
|
49
|
+
if (v, vp) == dst:
|
|
50
|
+
return [(a, -1), (v, li)]
|
|
51
|
+
if self.devices[v].kind != "s":
|
|
52
|
+
return None
|
|
53
|
+
prev: dict[str, tuple[str, int]] = {v: (a, li)}
|
|
54
|
+
q = deque([v])
|
|
55
|
+
while q:
|
|
56
|
+
u = q.popleft()
|
|
57
|
+
for i, l in enumerate(self.links):
|
|
58
|
+
if not l.touches(u) or not self.usable(l):
|
|
59
|
+
continue
|
|
60
|
+
w, wp = l.other(u)
|
|
61
|
+
if (w, wp) == dst:
|
|
62
|
+
out, node = [(w, i)], u
|
|
63
|
+
while node != a:
|
|
64
|
+
out.append((node, prev[node][1]))
|
|
65
|
+
node = prev[node][0]
|
|
66
|
+
return [(a, -1)] + out[::-1]
|
|
67
|
+
if self.devices[w].kind == "s" and w not in prev:
|
|
68
|
+
prev[w] = (u, i)
|
|
69
|
+
q.append(w)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def learn(self, src: tuple[str, int], path: list[tuple[str, int]]) -> None:
|
|
73
|
+
"""Switches along a frame's path learn the sender's MAC on the ingress port."""
|
|
74
|
+
mac = self.devices[src[0]].mac(src[1])
|
|
75
|
+
for dev_id, li in path[1:]:
|
|
76
|
+
if self.devices[dev_id].kind == "s":
|
|
77
|
+
self.mac_tables.setdefault(dev_id, {})[mac] = self.links[li].port_of(dev_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---- graph-only mode -----------------------------------------------
|
|
81
|
+
def tree(self, src: str) -> dict[str, tuple[str, int] | None]:
|
|
82
|
+
"""Shortest-path tree ignoring addresses. Computers never forward."""
|
|
83
|
+
prev: dict[str, tuple[str, int] | None] = {src: None}
|
|
84
|
+
q = deque([src] if self.devices[src].up else [])
|
|
85
|
+
while q:
|
|
86
|
+
u = q.popleft()
|
|
87
|
+
if u != src and self.devices[u].kind == "c":
|
|
88
|
+
continue
|
|
89
|
+
for i, l in enumerate(self.links):
|
|
90
|
+
if l.touches(u) and self.usable(l):
|
|
91
|
+
v = l.other(u)[0]
|
|
92
|
+
if v not in prev:
|
|
93
|
+
prev[v] = (u, i)
|
|
94
|
+
q.append(v)
|
|
95
|
+
return prev
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def path_in(tree: dict[str, tuple[str, int] | None], dst: str) -> list[tuple[str, int]] | None:
|
|
99
|
+
if dst not in tree:
|
|
100
|
+
return None
|
|
101
|
+
out, node = [], dst
|
|
102
|
+
while node is not None:
|
|
103
|
+
p = tree[node]
|
|
104
|
+
out.append((node, p[1] if p else -1))
|
|
105
|
+
node = p[0] if p else None
|
|
106
|
+
return out[::-1]
|
bytesim/model/layout.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Automatic arrangement: rows by device type, groups kept together, nested frames."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from ..constants import BOX_H, H_GAP, KINDS, LAYER_GAP, RANK
|
|
8
|
+
from .base import NetworkBase
|
|
9
|
+
from .types import Device
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LayoutMixin(NetworkBase):
|
|
13
|
+
def auto_layout(self, width: int) -> None:
|
|
14
|
+
if not self.devices:
|
|
15
|
+
return
|
|
16
|
+
adj: dict[str, set[str]] = {d: set() for d in self.devices}
|
|
17
|
+
for l in self.links:
|
|
18
|
+
adj[l.a].add(l.b)
|
|
19
|
+
adj[l.b].add(l.a)
|
|
20
|
+
|
|
21
|
+
# Rows follow device type (gateways on top, computers at the bottom);
|
|
22
|
+
# chains of the same type (r1-r2, s1-s2) get extra sub-rows by BFS depth.
|
|
23
|
+
key: dict[str, tuple[int, int]] = {}
|
|
24
|
+
for kind in KINDS:
|
|
25
|
+
same = {d.id for d in self.devices.values() if d.kind == kind}
|
|
26
|
+
roots = sorted((d for d in same
|
|
27
|
+
if any(RANK[self.devices[n].kind] < RANK[kind] for n in adj[d])),
|
|
28
|
+
key=lambda d: self.devices[d].num)
|
|
29
|
+
for seed in roots + sorted(same, key=lambda d: self.devices[d].num):
|
|
30
|
+
if seed in key:
|
|
31
|
+
continue
|
|
32
|
+
key[seed] = (RANK[kind], 0)
|
|
33
|
+
q = deque([seed])
|
|
34
|
+
while q:
|
|
35
|
+
u = q.popleft()
|
|
36
|
+
for v in sorted(adj[u] & same):
|
|
37
|
+
if v not in key:
|
|
38
|
+
key[v] = (RANK[kind], key[u][1] + 1)
|
|
39
|
+
q.append(v)
|
|
40
|
+
# Devices directly in the same group share a row (the highest any would get).
|
|
41
|
+
# Subgroups keep their own rows; nesting only keeps members side by side.
|
|
42
|
+
for ids in self.groups.values():
|
|
43
|
+
devs = [d for d in ids if d in self.devices]
|
|
44
|
+
if devs:
|
|
45
|
+
top = min(key[d] for d in devs)
|
|
46
|
+
for d in devs:
|
|
47
|
+
key[d] = top
|
|
48
|
+
layer = key
|
|
49
|
+
chains = {d: self.chain(d) for d in self.devices}
|
|
50
|
+
pad = {g: 2 * (1 + self.height(g)) for g in self.groups}
|
|
51
|
+
|
|
52
|
+
def grouped(ids: list[str], keyf: Callable[[int, str], float], depth: int = 0) -> list[str]:
|
|
53
|
+
"""Sort a row by keyf, keeping every group's members contiguous, at every level."""
|
|
54
|
+
keys = {d: keyf(i, d) for i, d in enumerate(ids)}
|
|
55
|
+
units: dict[str, list[str]] = {}
|
|
56
|
+
for d in ids:
|
|
57
|
+
c = chains[d]
|
|
58
|
+
units.setdefault(c[depth] if len(c) > depth else d, []).append(d)
|
|
59
|
+
ranked = sorted(units.values(), key=lambda ms: sum(keys[m] for m in ms) / len(ms))
|
|
60
|
+
out: list[str] = []
|
|
61
|
+
for ms in ranked:
|
|
62
|
+
if len(chains[ms[0]]) > depth and len(ms) > 1: # a group: same rule inside it
|
|
63
|
+
out += grouped(ms, lambda i, d: keys[d], depth + 1)
|
|
64
|
+
else:
|
|
65
|
+
out += ms
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
used = sorted(set(layer.values()))
|
|
69
|
+
layers: list[list[str]] = [[] for _ in used]
|
|
70
|
+
for dev in self.sorted_devices():
|
|
71
|
+
layers[used.index(layer[dev.id])].append(dev.id)
|
|
72
|
+
layers = [grouped(ids, lambda i, d: i) for ids in layers]
|
|
73
|
+
|
|
74
|
+
# Barycenter sweeps to reduce crossings.
|
|
75
|
+
def sweep(order: range, ref_offset: int) -> None:
|
|
76
|
+
for li in order:
|
|
77
|
+
ref = {d: i for i, d in enumerate(layers[li + ref_offset])}
|
|
78
|
+
|
|
79
|
+
def bary(i: int, d: str) -> float:
|
|
80
|
+
pos = [ref[n] for n in adj[d] if n in ref]
|
|
81
|
+
return sum(pos) / len(pos) if pos else i
|
|
82
|
+
|
|
83
|
+
layers[li] = grouped(layers[li], bary)
|
|
84
|
+
|
|
85
|
+
for _ in range(2):
|
|
86
|
+
sweep(range(1, len(layers)), -1)
|
|
87
|
+
sweep(range(len(layers) - 2, -1, -1), 1)
|
|
88
|
+
|
|
89
|
+
# Pack the widest row, then pull other boxes toward their neighbours.
|
|
90
|
+
# A group is placed as one block, with extra room for its frame.
|
|
91
|
+
rows = [[self.devices[d] for d in ids] for ids in layers]
|
|
92
|
+
|
|
93
|
+
def blocks(devs: list[Device]) -> list[list[Device]]:
|
|
94
|
+
"""Runs of devices under the same outermost group move as one block."""
|
|
95
|
+
out: list[list[Device]] = []
|
|
96
|
+
for d in devs:
|
|
97
|
+
root = chains[d.id][:1]
|
|
98
|
+
if out and root and chains[out[-1][0].id][:1] == root:
|
|
99
|
+
out[-1].append(d)
|
|
100
|
+
else:
|
|
101
|
+
out.append([d])
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
def gap1(a: Device, b: Device) -> int:
|
|
105
|
+
"""Room between neighbours for every frame that closes after a or opens before b."""
|
|
106
|
+
ca, cb = chains[a.id], chains[b.id]
|
|
107
|
+
k = 0
|
|
108
|
+
while k < min(len(ca), len(cb)) and ca[k] == cb[k]:
|
|
109
|
+
k += 1
|
|
110
|
+
return H_GAP + (pad[ca[k]] if len(ca) > k else 0) + (pad[cb[k]] if len(cb) > k else 0)
|
|
111
|
+
|
|
112
|
+
def gap(a: list[Device], b: list[Device]) -> int:
|
|
113
|
+
return gap1(a[-1], b[0])
|
|
114
|
+
|
|
115
|
+
def offsets(blk: list[Device]) -> list[int]:
|
|
116
|
+
offs, x = [0], 0
|
|
117
|
+
for a, b in zip(blk, blk[1:]):
|
|
118
|
+
x += a.width + gap1(a, b)
|
|
119
|
+
offs.append(x)
|
|
120
|
+
return offs
|
|
121
|
+
|
|
122
|
+
def span(devs: list[Device]) -> int:
|
|
123
|
+
return sum(d.width for d in devs) + sum(gap1(a, b) for a, b in zip(devs, devs[1:]))
|
|
124
|
+
|
|
125
|
+
widest = max(range(len(rows)), key=lambda i: span(rows[i]))
|
|
126
|
+
for li, devs in enumerate(rows):
|
|
127
|
+
x = max(2, (width - span(devs)) // 2)
|
|
128
|
+
prev = None
|
|
129
|
+
for b in blocks(devs):
|
|
130
|
+
x += gap(prev, b) if prev else 0
|
|
131
|
+
for d, off in zip(b, offsets(b)):
|
|
132
|
+
d.x, d.y = x + off, 1 + li * (BOX_H + LAYER_GAP)
|
|
133
|
+
x = b[-1].x + b[-1].width
|
|
134
|
+
prev = b
|
|
135
|
+
for _ in range(4):
|
|
136
|
+
for li in sorted(range(len(rows)), key=lambda i: abs(i - widest)):
|
|
137
|
+
if li == widest:
|
|
138
|
+
continue
|
|
139
|
+
want = []
|
|
140
|
+
for d in rows[li]:
|
|
141
|
+
# Line our port up with the far end of each cable.
|
|
142
|
+
nb = []
|
|
143
|
+
for l in self.links:
|
|
144
|
+
if l.touches(d.id):
|
|
145
|
+
o, op = l.other(d.id)
|
|
146
|
+
od = self.devices[o]
|
|
147
|
+
if od.y != d.y:
|
|
148
|
+
nb.append(od.port_x(op) - (d.port_x(l.port_of(d.id)) - d.x))
|
|
149
|
+
want.append(sum(nb) / len(nb) if nb else d.x)
|
|
150
|
+
wants = dict(zip((d.id for d in rows[li]), want))
|
|
151
|
+
end, prev = -10**6, None
|
|
152
|
+
for b in blocks(rows[li]):
|
|
153
|
+
offs = offsets(b)
|
|
154
|
+
w = sum(wants[d.id] - o for d, o in zip(b, offs)) / len(b)
|
|
155
|
+
x = max(round(w), end + gap(prev, b) if prev else -10**6)
|
|
156
|
+
for d, o in zip(b, offs):
|
|
157
|
+
d.x = x + o
|
|
158
|
+
end, prev = b[-1].x + b[-1].width, b
|
|
159
|
+
self._separate_siblings()
|
|
160
|
+
margin = 2 + max((2 * self.height(g) for g in self.groups), default=0)
|
|
161
|
+
shift = margin - min(d.x for d in self.devices.values())
|
|
162
|
+
if shift > 0:
|
|
163
|
+
for d in self.devices.values():
|
|
164
|
+
d.x += shift
|
|
165
|
+
|
|
166
|
+
def _separate_siblings(self) -> None:
|
|
167
|
+
"""A group spanning several rows can overlap a sibling on one of them;
|
|
168
|
+
push the right-hand sibling (and its contents) clear, until nothing overlaps."""
|
|
169
|
+
def rect(m: str) -> tuple[int, int, int, int]:
|
|
170
|
+
if m in self.groups:
|
|
171
|
+
return self.group_bounds(m)
|
|
172
|
+
d = self.devices[m]
|
|
173
|
+
return d.x, d.y, d.x + d.width - 1, d.y + BOX_H - 1
|
|
174
|
+
|
|
175
|
+
def contents(m: str) -> list[Device]:
|
|
176
|
+
return [self.devices[d] for d in (self.devices_in(m) if m in self.groups else [m])]
|
|
177
|
+
|
|
178
|
+
levels = [[m for m in list(self.devices) + list(self.groups) if self.parent_of(m) is None]]
|
|
179
|
+
levels += [ms for ms in self.groups.values()]
|
|
180
|
+
for _ in range(50):
|
|
181
|
+
moved = False
|
|
182
|
+
for sibs in levels:
|
|
183
|
+
rs = sorted(((rect(m), m) for m in sibs), key=lambda t: t[0][0])
|
|
184
|
+
for i, (ra, a) in enumerate(rs):
|
|
185
|
+
for rb, b in rs[i + 1:]:
|
|
186
|
+
if ra[0] <= rb[0] <= ra[2] + 1 and ra[1] <= rb[3] and rb[1] <= ra[3]:
|
|
187
|
+
for d in contents(b):
|
|
188
|
+
d.x += ra[2] + 2 - rb[0]
|
|
189
|
+
moved = True
|
|
190
|
+
break
|
|
191
|
+
if moved:
|
|
192
|
+
break
|
|
193
|
+
if moved:
|
|
194
|
+
break
|
|
195
|
+
if not moved:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
def place_new(self, dev: Device) -> None:
|
|
199
|
+
"""Drop a new device into free space on its type's row (manual mode)."""
|
|
200
|
+
y = 1 + RANK[dev.kind] * (BOX_H + LAYER_GAP)
|
|
201
|
+
right = [o.x + o.width for o in self.devices.values()
|
|
202
|
+
if o is not dev and o.y < y + BOX_H + 1 and y < o.y + BOX_H + 1]
|
|
203
|
+
dev.x, dev.y = (max(right) + H_GAP if right else 2), y
|