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,85 @@
|
|
|
1
|
+
"""The command interpreter: tokens → handler. Shared by the app and the headless runner."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...constants import REF
|
|
5
|
+
from ...model import NetError
|
|
6
|
+
from .base import CommandHost
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Interpreter(CommandHost):
|
|
10
|
+
_commands: dict[str, str]
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def tokens(line: str) -> list[str]:
|
|
14
|
+
"""Split on spaces, except inside parentheses: link1(rfab1, rfab2) is one token."""
|
|
15
|
+
out, cur, depth = [], "", 0
|
|
16
|
+
for ch in line:
|
|
17
|
+
depth += (ch == "(") - (ch == ")")
|
|
18
|
+
if ch.isspace() and depth <= 0:
|
|
19
|
+
if cur:
|
|
20
|
+
out.append(cur)
|
|
21
|
+
cur, depth = "", 0
|
|
22
|
+
elif not (ch.isspace() and depth > 0):
|
|
23
|
+
cur += ch
|
|
24
|
+
if depth > 0:
|
|
25
|
+
raise NetError(f"unclosed parenthesis in {cur!r}")
|
|
26
|
+
return out + ([cur] if cur else [])
|
|
27
|
+
|
|
28
|
+
def dispatch(self, toks: list[str]) -> None:
|
|
29
|
+
"""Route tokens to a handler. Shortcuts: ``r1`` selects, ``r1 s2`` cables,
|
|
30
|
+
a bare group name selects the group. ``key=value`` tokens become options."""
|
|
31
|
+
if not toks:
|
|
32
|
+
return
|
|
33
|
+
opts = {k.lower(): v.lower() for k, _, v in (t.partition("=") for t in toks if "=" in t)}
|
|
34
|
+
toks = [t for t in toks if "=" not in t]
|
|
35
|
+
if not toks:
|
|
36
|
+
raise NetError("missing command")
|
|
37
|
+
word, args = toks[0].lower(), toks[1:]
|
|
38
|
+
|
|
39
|
+
if REF.match(toks[0]):
|
|
40
|
+
if len(toks) == 1:
|
|
41
|
+
did, _ = self._ref(toks[0])
|
|
42
|
+
self.net.get(did)
|
|
43
|
+
self.canvas.select(did)
|
|
44
|
+
self.canvas.focus()
|
|
45
|
+
return
|
|
46
|
+
if len(toks) != 2:
|
|
47
|
+
raise NetError("to cable devices type two ids, e.g. r1 s2")
|
|
48
|
+
word, args = "link", toks
|
|
49
|
+
elif word not in self._commands and word in self.net.groups and not args:
|
|
50
|
+
word, args = "group", [word]
|
|
51
|
+
|
|
52
|
+
handler = self._commands.get(word)
|
|
53
|
+
if handler is None:
|
|
54
|
+
raise NetError(f"unknown command {word!r} — type help")
|
|
55
|
+
getattr(self, handler)(args, opts)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def script_lines(text: str) -> list[tuple[int, str]]:
|
|
59
|
+
"""(line number, command) for every non-blank line, with # comments removed."""
|
|
60
|
+
out = []
|
|
61
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
62
|
+
line = raw.split("#", 1)[0].strip()
|
|
63
|
+
if line:
|
|
64
|
+
out.append((n, line))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
def run_script(self, text: str, origin: str = "script") -> int:
|
|
68
|
+
"""Run a script's commands in order; stop at the first error, saying where.
|
|
69
|
+
|
|
70
|
+
While a script runs, the host may batch its usual per-command work (redraws,
|
|
71
|
+
re-addressing, DHCP animations) until the end. Returns how many commands ran."""
|
|
72
|
+
self.replaying += 1
|
|
73
|
+
ran = 0
|
|
74
|
+
try:
|
|
75
|
+
for n, line in self.script_lines(text):
|
|
76
|
+
try:
|
|
77
|
+
self.dispatch(self.tokens(line))
|
|
78
|
+
except NetError as e:
|
|
79
|
+
raise NetError(f"{origin}, line {n} ({line}): {e}") from None
|
|
80
|
+
ran += 1
|
|
81
|
+
finally:
|
|
82
|
+
self.replaying -= 1
|
|
83
|
+
if not self.replaying:
|
|
84
|
+
self.changed()
|
|
85
|
+
return ran
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Renumbering devices by BFS or DFS, once or automatically after every change."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...model import Device, NetError
|
|
5
|
+
from ...model.naming import ORDERS
|
|
6
|
+
from .base import CommandHost
|
|
7
|
+
from .registry import command
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NamingCommands(CommandHost):
|
|
11
|
+
# (order, root device or None) while auto-renumbering is on. The root is held
|
|
12
|
+
# as a Device so it survives being renamed itself.
|
|
13
|
+
naming: tuple[str, Device | None] | None = None
|
|
14
|
+
|
|
15
|
+
@command("renumber", "rename")
|
|
16
|
+
def cmd_renumber(self, args: list[str], opts: dict[str, str]) -> None:
|
|
17
|
+
usage = "usage: renumber bfs|dfs [root] · renumber auto bfs|dfs [root] · renumber off"
|
|
18
|
+
if not args:
|
|
19
|
+
self.log_msg(f"auto-renumber: [b]{self.naming_text()}[/] ({usage[7:]})")
|
|
20
|
+
return
|
|
21
|
+
words = [a.lower() for a in args]
|
|
22
|
+
if words == ["off"]:
|
|
23
|
+
self.naming = None
|
|
24
|
+
self.log_msg("auto-renumber off: ids stay as they are")
|
|
25
|
+
self.refresh_sidebar()
|
|
26
|
+
return
|
|
27
|
+
auto = words[0] == "auto"
|
|
28
|
+
if auto:
|
|
29
|
+
words, args = words[1:], args[1:]
|
|
30
|
+
if not words or words[0] not in ORDERS or len(words) > 2:
|
|
31
|
+
raise NetError(usage)
|
|
32
|
+
order = words[0]
|
|
33
|
+
root = self.net.get(self._ref(args[1])[0]) if len(args) == 2 else None
|
|
34
|
+
if auto:
|
|
35
|
+
self.naming = (order, root)
|
|
36
|
+
self.log_msg(f"auto-renumber on: [b]{self.naming_text()}[/] after every change")
|
|
37
|
+
self.changed() # applies it now
|
|
38
|
+
else:
|
|
39
|
+
self._apply_renumber(order, root, announce_none=True)
|
|
40
|
+
self.changed()
|
|
41
|
+
|
|
42
|
+
def naming_text(self) -> str:
|
|
43
|
+
if self.naming is None:
|
|
44
|
+
return "off"
|
|
45
|
+
order, root = self.naming
|
|
46
|
+
alive = root is not None and root.id in self.net.devices and self.net.devices[root.id] is root
|
|
47
|
+
return f"{order.upper()} from {root.id if alive else self.net.default_root() or '—'}"
|
|
48
|
+
|
|
49
|
+
def auto_renumber(self) -> None:
|
|
50
|
+
"""Called from changed(): keep ids in traversal order while the mode is on."""
|
|
51
|
+
if self.naming is not None:
|
|
52
|
+
order, root = self.naming
|
|
53
|
+
if root is not None and self.net.devices.get(root.id) is not root:
|
|
54
|
+
root = None # the root was deleted: fall back to the default
|
|
55
|
+
self._apply_renumber(order, root, announce_none=False)
|
|
56
|
+
|
|
57
|
+
def _apply_renumber(self, order: str, root: Device | None, announce_none: bool) -> None:
|
|
58
|
+
c = self.canvas
|
|
59
|
+
mapping = self.net.renumber(order, root.id if root else None)
|
|
60
|
+
if c.selected in mapping:
|
|
61
|
+
c.selected = mapping[c.selected]
|
|
62
|
+
if mapping:
|
|
63
|
+
pairs = sorted(mapping.items(), key=lambda kv: (kv[1][0], int(kv[1][1:])))
|
|
64
|
+
shown = " ".join(f"{old}→{new}" for old, new in pairs[:12])
|
|
65
|
+
more = f" (+{len(pairs) - 12} more)" if len(pairs) > 12 else ""
|
|
66
|
+
self.log_msg(f"[cyan]renumbered ({order.upper()}):[/] {shown}{more}")
|
|
67
|
+
elif announce_none:
|
|
68
|
+
self.log_msg(f"already in {order.upper()} order: nothing to rename")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A tiny command registry: decorate a handler with the words that invoke it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
Handler = Callable[..., None]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def command(*names: str) -> Callable[[Handler], Handler]:
|
|
10
|
+
"""Mark a method as the handler for one or more command words.
|
|
11
|
+
|
|
12
|
+
Handlers are called as ``handler(self, args, opts)`` where ``args`` are the
|
|
13
|
+
remaining tokens and ``opts`` holds any ``key=value`` tokens (lower-cased).
|
|
14
|
+
"""
|
|
15
|
+
def mark(fn: Handler) -> Handler:
|
|
16
|
+
fn.__commands__ = names # type: ignore[attr-defined]
|
|
17
|
+
return fn
|
|
18
|
+
return mark
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def collect(cls: type) -> dict[str, str]:
|
|
22
|
+
"""Map every command word to the name of the method that handles it."""
|
|
23
|
+
table: dict[str, str] = {}
|
|
24
|
+
for klass in reversed(cls.__mro__):
|
|
25
|
+
for attr, fn in vars(klass).items():
|
|
26
|
+
for word in getattr(fn, "__commands__", ()):
|
|
27
|
+
table[word] = attr
|
|
28
|
+
return table
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Construction scripts: export the network as console commands, and run script files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from ...model import NetError
|
|
9
|
+
from ...synthesis import MODES, synthesize
|
|
10
|
+
from .base import CommandHost
|
|
11
|
+
from .registry import command
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScriptCommands(CommandHost):
|
|
15
|
+
@command("run", "source")
|
|
16
|
+
def cmd_run(self, args: list[str], opts: dict[str, str]) -> None:
|
|
17
|
+
if len(args) != 1:
|
|
18
|
+
raise NetError("usage: run <file> (one console command per line; # starts a comment)")
|
|
19
|
+
path = Path(args[0]).expanduser()
|
|
20
|
+
try:
|
|
21
|
+
text = path.read_text()
|
|
22
|
+
except OSError as e:
|
|
23
|
+
raise NetError(f"couldn't read {path}: {e}") from None
|
|
24
|
+
ran = self.run_script(text, origin=str(path)) # type: ignore[attr-defined]
|
|
25
|
+
self.log_msg(f"ran {ran} command{'s' if ran != 1 else ''} from {path}: "
|
|
26
|
+
f"{len(self.net.devices)} devices, {len(self.net.links)} cables, {len(self.net.groups)} groups")
|
|
27
|
+
|
|
28
|
+
def export_script(self, args: list[str]) -> None:
|
|
29
|
+
"""export script [explicit|readable|shortest] [file]"""
|
|
30
|
+
modes = [a for a in args if a.lower() in MODES]
|
|
31
|
+
files = [a for a in args if a.lower() not in MODES]
|
|
32
|
+
if len(modes) > 1 or len(files) > 1:
|
|
33
|
+
raise NetError(f"usage: export script [{'|'.join(MODES)}] [file] (no file: show it here)")
|
|
34
|
+
mode = modes[0].lower() if modes else "readable"
|
|
35
|
+
res = synthesize(self.net, mode)
|
|
36
|
+
reuse = (f", reusing structure {len(res.used)} time{'s' if len(res.used) != 1 else ''} "
|
|
37
|
+
f"({res.explicit_commands} without)" if res.used else "")
|
|
38
|
+
summary = f"{mode} script: {res.commands} commands{reuse}, verified by replaying it"
|
|
39
|
+
if files:
|
|
40
|
+
path = Path(files[0]).expanduser()
|
|
41
|
+
try:
|
|
42
|
+
path.write_text(res.script)
|
|
43
|
+
except OSError as e:
|
|
44
|
+
raise NetError(f"couldn't write {path}: {e}") from None
|
|
45
|
+
self.log_msg(f"[green]exported {summary} → {path}[/]")
|
|
46
|
+
self.log_msg(f" [dim]replay it with: clear, then run {path}[/]")
|
|
47
|
+
else:
|
|
48
|
+
for line in res.script.splitlines():
|
|
49
|
+
self.log_msg(f"[dim]{escape(line)}[/]" if line.startswith("#") else escape(line))
|
|
50
|
+
self.log_msg(f"[green]{summary}[/]")
|
|
51
|
+
for s in res.used:
|
|
52
|
+
self.log_msg(f" [cyan]{escape(s.command())}[/] [dim]{escape(s.describe())}[/]")
|
|
53
|
+
for text in res.limitations:
|
|
54
|
+
self.log_msg(f" [yellow]not expressible as a command: {escape(text)}[/]")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Switching loops: broadcasts, spanning tree, and broadcast storms."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ...model import NetError
|
|
7
|
+
from ...model.stp import DEFAULT_PRIORITY, Flood
|
|
8
|
+
from ..cables import route
|
|
9
|
+
from .base import CommandHost
|
|
10
|
+
from .registry import command
|
|
11
|
+
|
|
12
|
+
ANIMATED_STORM_HOPS = 6 # how many generations of a storm to draw before it's declared
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SwitchingCommands(CommandHost):
|
|
16
|
+
@command("broadcast", "bcast", "flood")
|
|
17
|
+
def cmd_broadcast(self, args: list[str], opts: dict[str, str]) -> None:
|
|
18
|
+
if len(args) != 1:
|
|
19
|
+
raise NetError("usage: broadcast <id[:port]> (one frame to ff:ff:ff:ff:ff:ff)")
|
|
20
|
+
nic = self._sending_port(args[0])
|
|
21
|
+
name = self._ifname(nic) # type: ignore[attr-defined]
|
|
22
|
+
net = self.net
|
|
23
|
+
mac = net.devices[nic[0]].mac(nic[1])
|
|
24
|
+
self._refuse_if_storming(nic, "broadcast")
|
|
25
|
+
fl = net.flood(nic)
|
|
26
|
+
if not fl.generations:
|
|
27
|
+
raise NetError(f"{name} can't send: its cable is down or blocked")
|
|
28
|
+
if fl.storm:
|
|
29
|
+
self.log_msg(f"[b gold1]BROADCAST[/] {name} [dim]{mac}[/] → everyone")
|
|
30
|
+
self.set_off_storm(fl, f"a broadcast from {name}")
|
|
31
|
+
return
|
|
32
|
+
net.learn_flood(fl)
|
|
33
|
+
hops, reached = len(fl.generations), sorted(fl.delivered)
|
|
34
|
+
self.log_msg(f"[b gold1]BROADCAST[/] {name} [dim]{mac}[/] → everyone: reached "
|
|
35
|
+
f"{len(reached)} interface{'s' if len(reached) != 1 else ''} in {hops} hop"
|
|
36
|
+
f"{'s' if hops != 1 else ''}, then the frames died out (no loop)")
|
|
37
|
+
|
|
38
|
+
def done() -> None:
|
|
39
|
+
for dev, _ in reached:
|
|
40
|
+
self.canvas.flash_device(dev, "gold1")
|
|
41
|
+
self.refresh_sidebar()
|
|
42
|
+
|
|
43
|
+
self.animate_flood(fl, "gold1", done)
|
|
44
|
+
|
|
45
|
+
@command("stp")
|
|
46
|
+
def cmd_stp(self, args: list[str], opts: dict[str, str]) -> None:
|
|
47
|
+
net = self.net
|
|
48
|
+
words = [a.lower() for a in args]
|
|
49
|
+
if not words:
|
|
50
|
+
self._stp_status()
|
|
51
|
+
return
|
|
52
|
+
if words[0] == "priority":
|
|
53
|
+
if len(args) != 3 or not args[2].isdigit():
|
|
54
|
+
raise NetError("usage: stp priority <switch> <0-65535> (lower wins the root election)")
|
|
55
|
+
sw = self._switch(args[1])
|
|
56
|
+
net.stp_priority[sw] = int(args[2])
|
|
57
|
+
if net.stp_priority[sw] == DEFAULT_PRIORITY:
|
|
58
|
+
del net.stp_priority[sw]
|
|
59
|
+
self.log_msg(f"{sw}: bridge priority {args[2]}")
|
|
60
|
+
self.changed()
|
|
61
|
+
return
|
|
62
|
+
if words[-1] not in ("on", "off"):
|
|
63
|
+
raise NetError("usage: stp · stp on|off · stp <switch…> on|off · stp priority <switch> <n>")
|
|
64
|
+
switches = ([self._switch(a) for a in args[:-1]] if len(args) > 1
|
|
65
|
+
else [d.id for d in net.sorted_devices() if d.kind == "s"])
|
|
66
|
+
if words[-1] == "on":
|
|
67
|
+
net.stp_off -= set(switches)
|
|
68
|
+
else:
|
|
69
|
+
net.stp_off |= set(switches)
|
|
70
|
+
self.log_msg(f"STP {words[-1]} for {' '.join(switches) if len(args) > 1 else 'every switch'}")
|
|
71
|
+
self.changed()
|
|
72
|
+
|
|
73
|
+
# ---- storms -----------------------------------------------------------
|
|
74
|
+
def set_off_storm(self, fl: Flood, cause: str) -> None:
|
|
75
|
+
"""A broadcast hit a live loop: mark the storm, explain it, and draw it."""
|
|
76
|
+
net = self.net
|
|
77
|
+
before = set(net.storming)
|
|
78
|
+
net.start_storm(fl)
|
|
79
|
+
totals = fl.totals
|
|
80
|
+
growth = (" → ".join(f"{n:,}" for n in totals[:7])
|
|
81
|
+
+ (f" → … → {totals[-1]:,}" if len(totals) > 7 else ""))
|
|
82
|
+
switches = " ".join(sorted(net.storming - before, key=net.bridge_id))
|
|
83
|
+
# A plain ring just circles; a meshier loop copies the frame faster than it dies.
|
|
84
|
+
multiplying = totals[-1] > 2 * max(totals[:4])
|
|
85
|
+
how = "multiplying as it goes" if multiplying else "copies circling in both directions"
|
|
86
|
+
self.log_msg(f"[b red]⚡ BROADCAST STORM[/] set off by {cause}: the frame goes round a switching "
|
|
87
|
+
f"loop with no STP to block it, {how}")
|
|
88
|
+
self.log_msg(f" [red]frames in flight per hop: {growth}, and it never stops "
|
|
89
|
+
f"(layer-2 frames have no TTL)[/]")
|
|
90
|
+
mac = net.devices[fl.source[0]].mac(fl.source[1])
|
|
91
|
+
for sw, ports in sorted(fl.flaps.items(), key=lambda kv: net.bridge_id(kv[0])):
|
|
92
|
+
self.log_msg(f" [orange1]MAC {mac} flapping on {sw}: "
|
|
93
|
+
f"{'⇄'.join(f'p{p}' for p in sorted(ports))}[/]")
|
|
94
|
+
self.log_msg(f" [red]{switches} saturated: nothing else gets through until the loop is broken "
|
|
95
|
+
f"([b]stp on[/b], or fail/unlink a cable in it)[/]")
|
|
96
|
+
self.animate_flood(fl, "red", None, max_hops=ANIMATED_STORM_HOPS)
|
|
97
|
+
self.canvas.animate()
|
|
98
|
+
self.changed()
|
|
99
|
+
|
|
100
|
+
def storm_hint(self, *devs: str) -> str:
|
|
101
|
+
"""' (broadcast storm on …)' if a storm is what's in the way, else ''."""
|
|
102
|
+
hit: set[str] = set()
|
|
103
|
+
for d in devs:
|
|
104
|
+
if d in self.net.devices:
|
|
105
|
+
hit |= self.net.storm_near(d)
|
|
106
|
+
if not hit:
|
|
107
|
+
return ""
|
|
108
|
+
return f" — broadcast storm on {' '.join(sorted(hit, key=self.net.bridge_id))} is saturating the links"
|
|
109
|
+
|
|
110
|
+
def _refuse_if_storming(self, nic: tuple[str, int], what: str) -> None:
|
|
111
|
+
if self.net.storm_near(nic[0]):
|
|
112
|
+
raise NetError(f"{what} from {self._ifname(nic)} can't get out" # type: ignore[attr-defined]
|
|
113
|
+
+ self.storm_hint(nic[0]))
|
|
114
|
+
|
|
115
|
+
def animate_flood(self, fl: Flood, color: str, on_done: Callable[[], None] | None,
|
|
116
|
+
max_hops: int | None = None) -> None:
|
|
117
|
+
"""Draw a flood generation by generation: every copy of hop n, then hop n+1…"""
|
|
118
|
+
net, c = self.net, self.canvas
|
|
119
|
+
gens = fl.generations[:max_hops] if max_hops else fl.generations
|
|
120
|
+
links = {li for g in gens for li, _ in g}
|
|
121
|
+
c.heat(links)
|
|
122
|
+
|
|
123
|
+
def run(i: int) -> None:
|
|
124
|
+
if i >= len(gens):
|
|
125
|
+
c.cool(links)
|
|
126
|
+
if on_done:
|
|
127
|
+
on_done()
|
|
128
|
+
return
|
|
129
|
+
moves = list(gens[i])
|
|
130
|
+
pending = [len(moves)]
|
|
131
|
+
|
|
132
|
+
def arrived() -> None:
|
|
133
|
+
pending[0] -= 1
|
|
134
|
+
if pending[0] == 0:
|
|
135
|
+
run(i + 1)
|
|
136
|
+
|
|
137
|
+
hot = "red" if color == "red" or i >= 2 and fl.storm else color
|
|
138
|
+
for li, dev in moves:
|
|
139
|
+
l = net.links[li]
|
|
140
|
+
frm = l.other(dev)[0]
|
|
141
|
+
pts = route(net, l, li)
|
|
142
|
+
if l.a != frm:
|
|
143
|
+
pts = pts[::-1]
|
|
144
|
+
df, dt = net.devices[frm], net.devices[dev]
|
|
145
|
+
cells = ([(df.port_x(l.port_of(frm)), df.y + 2)] + pts
|
|
146
|
+
+ [(dt.port_x(l.port_of(dev)), dt.y + 2)])
|
|
147
|
+
c.send_packet(cells, hot, arrived)
|
|
148
|
+
|
|
149
|
+
run(0)
|
|
150
|
+
|
|
151
|
+
# ---- helpers ------------------------------------------------------------
|
|
152
|
+
def _switch(self, tok: str) -> str:
|
|
153
|
+
did = self.net.get(self._ref(tok)[0]).id
|
|
154
|
+
if self.net.devices[did].kind != "s":
|
|
155
|
+
raise NetError(f"{did} isn't a switch: STP runs on switches")
|
|
156
|
+
return did
|
|
157
|
+
|
|
158
|
+
def _sending_port(self, tok: str) -> tuple[str, int]:
|
|
159
|
+
did, port = self._ref(tok)
|
|
160
|
+
dev = self.net.get(did)
|
|
161
|
+
if port is None:
|
|
162
|
+
port = next((p for p in range(1, dev.nports + 1) if self.net.link_index(did, p) is not None), None)
|
|
163
|
+
if port is None:
|
|
164
|
+
raise NetError(f"{did} has no cables")
|
|
165
|
+
elif self.net.link_index(did, port) is None:
|
|
166
|
+
raise NetError(f"{did}:{dev.port_name(port)} has no cable")
|
|
167
|
+
return dev.id, port
|
|
168
|
+
|
|
169
|
+
def _stp_status(self) -> None:
|
|
170
|
+
net = self.net
|
|
171
|
+
areas = [a for a in net._areas(net._trunks(honor_stp=False)) if len(a) > 1]
|
|
172
|
+
live = net.loop_links(honor_stp=True)
|
|
173
|
+
if not areas:
|
|
174
|
+
self.log_msg("[dim]no switch is cabled to another switch: nothing for STP to do[/]")
|
|
175
|
+
for area in areas:
|
|
176
|
+
order = sorted(area, key=net.bridge_id)
|
|
177
|
+
root = net.stp_roots.get(order[0])
|
|
178
|
+
off = [s for s in order if not net.stp_enabled(s)]
|
|
179
|
+
blocked = sorted((s, p) for s, p in net.stp_blocked if s in area)
|
|
180
|
+
loops = {i for i in live if net.links[i].a in area}
|
|
181
|
+
line = f"[b]{' '.join(order)}[/]: " + (f"root bridge [cyan]{root}[/]" if root else "[yellow]no STP switch[/]")
|
|
182
|
+
if off:
|
|
183
|
+
line += f" · STP off on {' '.join(off)}"
|
|
184
|
+
self.log_msg(line)
|
|
185
|
+
if blocked:
|
|
186
|
+
self.log_msg(" blocking " + " ".join(f"{s}:p{p}" for s, p in blocked))
|
|
187
|
+
if area & net.storming:
|
|
188
|
+
self.log_msg(" [b red]⚡ broadcast storm in progress[/]")
|
|
189
|
+
elif loops:
|
|
190
|
+
self.log_msg(f" [red]live loop over {len(loops)} cables: the next broadcast here will storm[/]")
|
|
191
|
+
else:
|
|
192
|
+
self.log_msg(" [green]loop-free[/]")
|