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,255 @@
1
+ """Building the network: devices, cables, roles, failures, files, modes."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from ... import demos
7
+ from ...constants import ALIASES, KINDS, REF, ROLES
8
+ from ...model import Link, NetError, Network
9
+ from ...topology import dump_topology, load_topology, looks_like_topology
10
+ from ..help import HELP
11
+ from ..theme import ROLE_COLOR
12
+ from .base import CommandHost
13
+ from .registry import command
14
+
15
+ DEFAULT_FILE = "bytesim.json"
16
+
17
+
18
+ class TopologyCommands(CommandHost):
19
+ @command("help", "?", "h")
20
+ def cmd_help(self, args: list[str], opts: dict[str, str]) -> None:
21
+ self.log_msg(HELP)
22
+
23
+ @command("add", "new", "+")
24
+ def cmd_add(self, args: list[str], opts: dict[str, str]) -> None:
25
+ net = self.net
26
+ if not args or args[0].lower() not in ALIASES:
27
+ raise NetError("usage: add <g|r|s|c> [ports] [count]")
28
+ kind = ALIASES[args[0].lower()]
29
+ try:
30
+ nports = int(args[1]) if len(args) > 1 else None
31
+ count = int(args[2]) if len(args) > 2 else 1
32
+ except ValueError:
33
+ raise NetError("ports and count must be numbers") from None
34
+ if not 1 <= count <= 100:
35
+ raise NetError("count must be 1..100")
36
+ made = []
37
+ for _ in range(count):
38
+ d = net.add(kind, nports)
39
+ if not self.auto:
40
+ net.place_new(d)
41
+ made.append(d)
42
+ kindword = "NIC" if kind == "c" else "port"
43
+ n = made[0].nports
44
+ self.log_msg(f"[green]+[/] {KINDS[kind][0]} {' '.join(d.id for d in made)} "
45
+ f"({n} {kindword}{'s' if n != 1 else ''})")
46
+ self.changed()
47
+ self.canvas.select(made[-1].id)
48
+
49
+ @command("link", "connect", "ln", "cable")
50
+ def cmd_link(self, args: list[str], opts: dict[str, str]) -> None:
51
+ net = self.net
52
+ if len(args) != 2:
53
+ raise NetError("usage: link <id>[:port] <id>[:port] [role=...]")
54
+ role = opts.get("role", "data")
55
+ if role not in ROLES:
56
+ raise NetError(f"role must be one of: {', '.join(ROLES)}")
57
+ (a, pa), (b, pb) = self._ref(args[0]), self._ref(args[1])
58
+ l = net.connect(a, pa, b, pb, role)
59
+ da, db = net.devices[l.a], net.devices[l.b]
60
+ self.log_msg(f"[green]⇄[/] [{da.color}]{l.a}[/]:{da.port_name(l.pa)} ── "
61
+ f"[{db.color}]{l.b}[/]:{db.port_name(l.pb)}"
62
+ + (f" [{ROLE_COLOR[role]}]{role}[/]" if role != "data" else ""))
63
+ # Plugging in a computer kicks off a (visible) DHCP exchange.
64
+ ends = [(d, p) for d, p in ((da, l.pa), (db, l.pb))
65
+ if d.kind == "c" and net.iface(d.id, p).mode == "dhcp" and not self.graph
66
+ and not self.replaying] # scripts re-address once, at the end
67
+ self.changed(skip=frozenset((d.id, p) for d, p in ends))
68
+ for d, p in ends: # by Device: auto-renumber may have renamed it
69
+ self._dhcp_exchange((d.id, p)) # type: ignore[attr-defined]
70
+
71
+ @command("unlink", "disconnect", "cut")
72
+ def cmd_unlink(self, args: list[str], opts: dict[str, str]) -> None:
73
+ if not 1 <= len(args) <= 2:
74
+ raise NetError("usage: unlink <id>[:port] [id]")
75
+ a, pa = self._ref(args[0])
76
+ b = self._ref(args[1])[0] if len(args) == 2 else None
77
+ n = self.net.disconnect(a, pa, b)
78
+ self.log_msg(f"[yellow]✂[/] removed {n} cable{'s' if n != 1 else ''}" if n
79
+ else "[dim]no matching cables[/]")
80
+ self.changed()
81
+
82
+ @command("del", "rm", "delete", "remove")
83
+ def cmd_del(self, args: list[str], opts: dict[str, str]) -> None:
84
+ if not args:
85
+ raise NetError("usage: del <id> [id ...]")
86
+ for tok in args:
87
+ did, _ = self._ref(tok)
88
+ n = self.net.remove(did)
89
+ self.log_msg(f"[red]−[/] {did} deleted ({n} cable{'s' if n != 1 else ''} removed)")
90
+ self.changed()
91
+
92
+ @command("ports", "nics", "resize")
93
+ def cmd_ports(self, args: list[str], opts: dict[str, str]) -> None:
94
+ if args and args[0].lower() == "copy": # ports copy src(old, …) dst(new, …): cabling only
95
+ self.copy_cabling(args[1:]) # type: ignore[attr-defined]
96
+ return
97
+ if len(args) != 2 or not args[1].isdigit():
98
+ raise NetError("usage: ports <id> <count>")
99
+ did, _ = self._ref(args[0])
100
+ self.net.resize(did, int(args[1]))
101
+ self.log_msg(f"{did} now has {args[1]} ports")
102
+ self.changed()
103
+
104
+ @command("role")
105
+ def cmd_role(self, args: list[str], opts: dict[str, str]) -> None:
106
+ role = args[-1].lower() if args else ""
107
+ if len(args) < 2 or role not in ROLES:
108
+ raise NetError(f"usage: role <id[:port]> [id] <{'|'.join(ROLES)}>")
109
+ for l in self._links_matching(args[:-1]):
110
+ l.role = role
111
+ self.log_msg(f"{self._link_text(l)} is now [{ROLE_COLOR[role]}]{role}[/]")
112
+ self.changed()
113
+
114
+ @command("fail", "down")
115
+ def cmd_fail(self, args: list[str], opts: dict[str, str]) -> None:
116
+ self._fail(args, up=False)
117
+
118
+ @command("restore", "up", "repair")
119
+ def cmd_restore(self, args: list[str], opts: dict[str, str]) -> None:
120
+ self._fail(args, up=True)
121
+
122
+ @command("mode")
123
+ def cmd_mode(self, args: list[str], opts: dict[str, str]) -> None:
124
+ if not args or args[0].lower() not in ("graph", "addr", "ip", "address"):
125
+ self.log_msg(f"mode: [b]{'graph' if self.graph else 'addr'}[/] (mode graph | mode addr)")
126
+ return
127
+ self.graph = args[0].lower() == "graph"
128
+ self.log_msg("[b]graph mode[/]: traffic follows cables only, addresses ignored" if self.graph
129
+ else "[b]address mode[/]: networks, gateways and DHCP apply")
130
+ self.changed()
131
+
132
+ @command("auto", "arrange", "layout")
133
+ def cmd_auto(self, args: list[str], opts: dict[str, str]) -> None:
134
+ self.arrange(announce=True) # type: ignore[attr-defined]
135
+
136
+ @command("list", "ls")
137
+ def cmd_list(self, args: list[str], opts: dict[str, str]) -> None:
138
+ net = self.net
139
+ for d in net.sorted_devices():
140
+ used = sum(1 for l in net.links if l.touches(d.id))
141
+ self.log_msg(f"[{d.color}]{d.id:<4}[/] {d.name:<8} {used}/{d.nports} "
142
+ f"{'NICs' if d.kind == 'c' else 'ports'}")
143
+ for l in net.links:
144
+ self.log_msg(" " + self._link_text(l))
145
+ if net.groups:
146
+ self.dispatch(["groups"])
147
+ if not net.devices:
148
+ self.log_msg("[dim]empty network[/]")
149
+
150
+ @command("clear")
151
+ def cmd_clear(self, args: list[str], opts: dict[str, str]) -> None:
152
+ self.replace_network(Network(), auto=True) # type: ignore[attr-defined]
153
+ self.log_msg("[yellow]network cleared[/]")
154
+
155
+ @command("demo")
156
+ def cmd_demo(self, args: list[str], opts: dict[str, str]) -> None:
157
+ name = args[0].lower() if args else "basic"
158
+ if name not in demos.DEMOS:
159
+ raise NetError(f"demos: {', '.join(demos.DEMOS)}")
160
+ net, hint = demos.DEMOS[name]()
161
+ self.replace_network(net, auto=True) # type: ignore[attr-defined]
162
+ self.log_msg(hint)
163
+
164
+ @command("save")
165
+ def cmd_save(self, args: list[str], opts: dict[str, str]) -> None:
166
+ path = Path(args[0] if args else DEFAULT_FILE).expanduser()
167
+ try:
168
+ path.write_text(self.net.to_json())
169
+ except OSError as e:
170
+ raise NetError(f"couldn't save {path}: {e}") from None
171
+ self.log_msg(f"saved to {path}")
172
+
173
+ @command("load", "open")
174
+ def cmd_load(self, args: list[str], opts: dict[str, str]) -> None:
175
+ path = Path(args[0] if args else DEFAULT_FILE).expanduser()
176
+ try:
177
+ text = path.read_text()
178
+ topology = looks_like_topology(text)
179
+ net = load_topology(text) if topology else Network.from_json(text)
180
+ except NetError as e:
181
+ raise NetError(f"{path}: {e}") from None
182
+ except (OSError, ValueError, KeyError, TypeError) as e:
183
+ raise NetError(f"couldn't load {path}: {e}") from None
184
+ # A topology has no positions, so it's laid out; a save file keeps its own.
185
+ self.replace_network(net, auto=topology) # type: ignore[attr-defined]
186
+ self.log_msg(f"loaded {path} ({len(net.devices)} devices, {len(net.links)} cables"
187
+ + (", topology: auto-arranged)" if topology else ")"))
188
+
189
+ @command("export")
190
+ def cmd_export(self, args: list[str], opts: dict[str, str]) -> None:
191
+ if args and args[0].lower() == "script": # export script [mode] [file]
192
+ self.export_script(args[1:]) # type: ignore[attr-defined]
193
+ return
194
+ if not 1 <= len(args) <= 2 or (len(args) == 2 and args[1].lower() != "ports"):
195
+ raise NetError("usage: export <file.yaml> [ports] (ports = pin every cable to its ports)")
196
+ path = Path(args[0]).expanduser()
197
+ pin = len(args) == 2
198
+ try:
199
+ path.write_text(dump_topology(self.net, pin_ports=pin))
200
+ except OSError as e:
201
+ raise NetError(f"couldn't write {path}: {e}") from None
202
+ self.log_msg(f"exported topology to {path}"
203
+ + (" (every port pinned)" if pin else " (compact: meshes folded, ports allocated on load)"))
204
+
205
+ @command("quit", "exit", "q")
206
+ def cmd_quit(self, args: list[str], opts: dict[str, str]) -> None:
207
+ self.exit() # type: ignore[attr-defined]
208
+
209
+ # ---- helpers -------------------------------------------------------
210
+ def _link_text(self, l: Link, state: bool = True) -> str:
211
+ net = self.net
212
+ txt = f"{l.a}:{net.devices[l.a].port_name(l.pa)} ── {l.b}:{net.devices[l.b].port_name(l.pb)}"
213
+ if l.role != "data":
214
+ txt += f" [{ROLE_COLOR[l.role]}]{l.role}[/]"
215
+ return txt + (" [red]✕ down[/]" if state and not l.up else "")
216
+
217
+ def _links_matching(self, args: list[str]) -> list[Link]:
218
+ """Cables picked by `id:port` or `id[:port] id[:port]`."""
219
+ if not 1 <= len(args) <= 2:
220
+ raise NetError("pick a cable with id:port, or two device ids")
221
+ (a, pa) = self._ref(args[0])
222
+ self.net.get(a)
223
+ b, pb = self._ref(args[1]) if len(args) == 2 else (None, None)
224
+ if b is None and pa is None:
225
+ raise NetError(f"which cable of {a}? use {a}:<port> or name the other end")
226
+ hit = [l for l in self.net.links if l.touches(a)
227
+ and (pa is None or l.port_of(a) == pa)
228
+ and (b is None or (l.other(a)[0] == b and (pb is None or l.other(a)[1] == pb)))]
229
+ if not hit:
230
+ raise NetError("no matching cable")
231
+ return hit
232
+
233
+ def _fail(self, args: list[str], up: bool) -> None:
234
+ net = self.net
235
+ verb = "restored" if up else "failed"
236
+ if not args:
237
+ raise NetError(f"usage: {'restore' if up else 'fail'} <id> | <id:port> | <id> <id>"
238
+ + (" | all" if up else ""))
239
+ if up and args[0].lower() == "all":
240
+ for d in net.devices.values():
241
+ d.up = True
242
+ for l in net.links:
243
+ l.up = True
244
+ self.log_msg("[green]✓ everything restored[/]")
245
+ elif len(args) == 1 and REF.match(args[0]) and self._ref(args[0])[1] is None:
246
+ d = net.get(self._ref(args[0])[0])
247
+ d.up = up
248
+ self.log_msg(f"[{'green' if up else 'red'}]{'✓' if up else '✕'} {d.id} {verb}[/]")
249
+ else:
250
+ for l in self._links_matching(args):
251
+ l.up = up
252
+ self.log_msg(f"[{'green' if up else 'red'}]{'✓' if up else '✕'}[/] cable "
253
+ f"{self._link_text(l, state=False)} {verb}")
254
+ net.mac_tables = {}
255
+ self.changed()
@@ -0,0 +1,312 @@
1
+ """Sending things: ping, unicast and multicast, in address or graph mode."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+
6
+ from rich.markup import escape
7
+
8
+ from ...constants import ADDR, HOST_MAX, REF
9
+ from ...model import Leg, NetError, fmt
10
+ from ..cables import route
11
+ from .base import CommandHost
12
+ from .registry import command
13
+
14
+
15
+ class TrafficCommands(CommandHost):
16
+ INTERNET = ("internet", "inet", "wan")
17
+
18
+ @staticmethod
19
+ def _flatten(legs: list[Leg]) -> list[tuple[str, int]]:
20
+ path: list[tuple[str, int]] = []
21
+ for _, _, p in legs:
22
+ path.extend(p if not path else p[1:])
23
+ return path
24
+
25
+ @staticmethod
26
+ def _reverse(path: list[tuple[str, int]]) -> list[tuple[str, int]]:
27
+ return [(path[-1][0], -1)] + [(path[i][0], path[i + 1][1]) for i in range(len(path) - 2, -1, -1)]
28
+
29
+ def _ifname(self, ifc: tuple[str, int]) -> str:
30
+ return f"{ifc[0]}:{self.net.devices[ifc[0]].port_name(ifc[1])}"
31
+
32
+ def _parse_addr(self, tok: str) -> tuple[int, int] | None:
33
+ m = ADDR.match(tok)
34
+ if not m:
35
+ return None
36
+ net, host = int(m[1]), int(m[2])
37
+ if net < 1 or not 1 <= host < HOST_MAX:
38
+ raise NetError(f"bad address {tok}: network ≥ 1, host 1..{HOST_MAX - 1}")
39
+ return net, host
40
+
41
+ def _resolve_dst(self, tok: str) -> tuple[str, list[tuple[str, int]] | None]:
42
+ """A destination token -> (label, candidate interfaces); None means the internet."""
43
+ net = self.net
44
+ if tok.lower() in self.INTERNET:
45
+ return "internet", None
46
+ if (addr := self._parse_addr(tok)) is not None:
47
+ hit = net.resolve(addr)
48
+ if hit is None:
49
+ raise NetError(f"nobody has {fmt(addr)} (ARP got no reply)")
50
+ return f"{fmt(addr)} {hit[0]}", [hit]
51
+ did, port = self._ref(tok)
52
+ dev = net.get(did)
53
+ if dev.kind == "s":
54
+ raise NetError(f"{did} is a switch (layer 2): it has no address to reach")
55
+ if port:
56
+ if not net.iface(did, port).addr:
57
+ raise NetError(f"{did}:{dev.port_name(port)} has no address")
58
+ return f"{did}:{dev.port_name(port)}", [(did, port)]
59
+ cands = [(did, p) for p, _ in net.addressed(did)]
60
+ if not cands:
61
+ raise NetError(f"{did} has no address (try: dhcp {did})")
62
+ return did, cands
63
+
64
+ def _best_route(self, src: str, cands: list[tuple[str, int]] | None) -> list[Leg]:
65
+ if cands is None:
66
+ return self.net.route(src, None)
67
+ best, err = None, None
68
+ for c in cands:
69
+ try:
70
+ legs = self.net.route(src, c)
71
+ except NetError as e:
72
+ err = e
73
+ continue
74
+ if best is None or len(self._flatten(legs)) < len(self._flatten(best)):
75
+ best, self._last_dst = legs, c
76
+ if best is None:
77
+ raise err # type: ignore[misc]
78
+ return best
79
+
80
+ def _src(self, tok: str) -> str:
81
+ did = self.net.get(self._ref(tok)[0]).id
82
+ if self.net.devices[did].kind == "s" and not self.graph:
83
+ raise NetError(f"{did} is a switch (layer 2): it can't originate addressed traffic")
84
+ return did
85
+
86
+ def _plan(self, src: str, tok: str) -> tuple[str, list[tuple[str, int]], list[Leg] | None, bool]:
87
+ """Work out how a packet gets from src to tok: (label, device path, legs, to-internet).
88
+
89
+ In graph mode it's the shortest cable path; in address mode it follows
90
+ networks and gateways, and legs records each layer-2 hop."""
91
+ net = self.net
92
+ internet = tok.lower() in self.INTERNET
93
+ if self.graph:
94
+ if ADDR.match(tok):
95
+ raise NetError("graph mode ignores addresses: use device ids (or `mode addr`)")
96
+ tree = net.tree(src)
97
+ if internet:
98
+ dst = next((d for d in tree if net.devices[d].kind == "g"), None)
99
+ if dst is None:
100
+ raise NetError(f"no gateway reachable from {src}")
101
+ label = "internet"
102
+ else:
103
+ dst = net.get(self._ref(tok)[0]).id
104
+ label = dst
105
+ path = net.path_in(tree, dst)
106
+ if path is None:
107
+ raise NetError(f"{dst} is not reachable from {src}"
108
+ + self.storm_hint(src, dst)) # type: ignore[attr-defined]
109
+ return label, path, None, internet
110
+ net.notes.clear()
111
+ label, cands = self._resolve_dst(tok)
112
+ try:
113
+ legs = self._best_route(src, cands)
114
+ except NetError as e:
115
+ hint = self.storm_hint(src, *(c[0] for c in cands or [])) # type: ignore[attr-defined]
116
+ raise NetError(f"{e}{hint}") from None
117
+ # Each hop starts with an ARP request: a broadcast. Into a live loop, that's a storm.
118
+ for a, b, _ in legs:
119
+ fl = net.flood(a)
120
+ if fl.storm:
121
+ self.set_off_storm(fl, f"the ARP for {fmt(net.addr_of(b))} from {self._ifname(a)}") # type: ignore[attr-defined]
122
+ raise NetError(f"{self._ifname(a)} never got an ARP reply: its broadcast set off a storm")
123
+ return label, self._flatten(legs), legs, internet
124
+
125
+ def _learn(self, legs: list[Leg] | None, both_ways: bool = False) -> None:
126
+ for a, b, p in legs or []:
127
+ self.net.learn(a, p)
128
+ if both_ways:
129
+ self.net.learn(b, self._reverse(p))
130
+
131
+ def _log_route(self, kind: str, color: str, src: str, label: str,
132
+ path: list[tuple[str, int]], legs: list[Leg] | None, internet: bool) -> None:
133
+ net = self.net
134
+ chain = " → ".join(f"[{net.devices[d].color}]{d}[/]" for d, _ in path)
135
+ if internet:
136
+ chain += " → [magenta]☁[/]"
137
+ if legs is None:
138
+ self.log_msg(f"[b {color}]{kind}[/] {src} → {label}: {chain}")
139
+ return
140
+ frm = fmt(net.addr_of(legs[0][0]))
141
+ to = "internet" if internet else label.split()[0] if label.startswith("(") else fmt(net.addr_of(self._last_dst))
142
+ self.log_msg(f"[b {color}]{kind}[/] {frm} → {to} [dim]({src} → {label})[/]: {chain}")
143
+ for a, b, _ in legs:
144
+ self.log_msg(f" [dim]ARP {fmt(net.addr_of(b))} is-at {net.devices[b[0]].mac(b[1])} "
145
+ f"(asked by {self._ifname(a)})[/]")
146
+ for note in dict.fromkeys(net.notes):
147
+ self.log_msg(f" [{'red' if note.startswith('⚠') else 'orchid'}]{escape(note)}[/]")
148
+
149
+ def _routers_in(self, path: list[tuple[str, int]], legs: list[Leg] | None) -> int:
150
+ if legs is not None:
151
+ return len(legs) - 1
152
+ return sum(1 for d, _ in path[1:-1] if self.net.is_router(d))
153
+
154
+ @command("ping", "trace", "tracert", "traceroute")
155
+ def cmd_ping(self, args: list[str], opts: dict[str, str]) -> None:
156
+ if len(args) != 2:
157
+ raise NetError("usage: ping <src> <id|(net,host)|internet>")
158
+ src = self._src(args[0])
159
+ if args[1].lower() not in self.INTERNET and self._ref_or_none(args[1]) == src:
160
+ self.log_msg(f"{src}: reply from self (loopback)")
161
+ return
162
+ try:
163
+ label, path, legs, internet = self._plan(src, args[1])
164
+ except NetError as e:
165
+ raise NetError(f"ping {src} → {args[1]}: {e}") from None
166
+ if len(path) < 2:
167
+ self.log_msg(f"[green]{src} is a gateway: internet reachable directly[/]")
168
+ return
169
+ self._log_route("PING", "red", src, label, path, legs, internet)
170
+ self._learn(legs, both_ways=True)
171
+ back = self._reverse(path)
172
+ c = self.canvas
173
+ links = [li for _, li in path[1:]]
174
+ c.heat(links)
175
+ ttl = 64 - self._routers_in(path, legs)
176
+ rtt = sum(random.uniform(0.15, 0.6) for _ in path) * 2 + (12 if internet else 0)
177
+
178
+ def arrived() -> None:
179
+ c.flash_device(path[-1][0], "red")
180
+ c.send_packet(self._cells(back), "green", replied)
181
+
182
+ def replied() -> None:
183
+ c.cool(links)
184
+ c.flash_device(src, "green")
185
+ self.log_msg(f" [green]reply from {label}[/]: hops={len(path) - 1} "
186
+ f"ttl={ttl} time={rtt:.2f} ms")
187
+ self.refresh_sidebar()
188
+
189
+ c.send_packet(self._cells(path), "red", arrived)
190
+
191
+ def _ref_or_none(self, tok: str) -> str | None:
192
+ m = REF.match(tok)
193
+ return f"{m[1].lower()}{int(m[2])}" if m else None
194
+
195
+ def _cells(self, path: list[tuple[str, int]]) -> list[tuple[int, int]]:
196
+ """Screen cells a packet crosses along a path, port to port."""
197
+ net = self.net
198
+ cells: list[tuple[int, int]] = []
199
+ for (u, _), (v, li) in zip(path, path[1:]):
200
+ l = net.links[li]
201
+ pts = route(net, l, li)
202
+ if l.a != u:
203
+ pts = pts[::-1]
204
+ du, dv = net.devices[u], net.devices[v]
205
+ cells.append((du.port_x(l.port_of(u)), du.y + 2))
206
+ cells.extend(pts)
207
+ cells.append((dv.port_x(l.port_of(v)), dv.y + 2))
208
+ return cells
209
+
210
+ @command("unicast", "ucast", "send")
211
+ def cmd_unicast(self, args: list[str], opts: dict[str, str]) -> None:
212
+ """One packet, one destination, no reply."""
213
+ if len(args) != 2:
214
+ raise NetError("usage: unicast <src> <id|(net,host)|internet>")
215
+ src = self._src(args[0])
216
+ if self._ref_or_none(args[1]) == src:
217
+ raise NetError("source and destination are the same device")
218
+ try:
219
+ label, path, legs, internet = self._plan(src, args[1])
220
+ except NetError as e:
221
+ raise NetError(f"unicast {src} → {args[1]}: {e}") from None
222
+ if len(path) < 2:
223
+ raise NetError(f"{src} is a gateway: nothing to send through")
224
+ self._log_route("UNICAST", "orange1", src, label, path, legs, internet)
225
+ self._learn(legs)
226
+ c = self.canvas
227
+ links = [li for _, li in path[1:]]
228
+ c.heat(links)
229
+ routers = self._routers_in(path, legs)
230
+
231
+ def delivered() -> None:
232
+ c.cool(links)
233
+ c.flash_device(path[-1][0], "orange1")
234
+ self.log_msg(f" [orange1]delivered to {label}[/]: {len(path) - 1} hops, "
235
+ f"{routers} router{'s' if routers != 1 else ''}")
236
+ self.refresh_sidebar()
237
+
238
+ c.send_packet(self._cells(path), "orange1", delivered)
239
+
240
+ def _targets(self, toks: list[str]) -> list[str]:
241
+ """Expand device ids, addresses and group names into a list of device ids."""
242
+ out: list[str] = []
243
+ for t in toks:
244
+ if t.lower() in ("all", "*"):
245
+ ids = [d.id for d in self.net.sorted_devices() if d.kind == "c"]
246
+ elif t.lower() in self.net.groups:
247
+ ids = [d for d in self.net.devices_in(t.lower())
248
+ if self.graph or self.net.devices[d].kind != "s"] # switches can't receive
249
+ elif ADDR.match(t) and not self.graph:
250
+ ids = [self._resolve_dst(t)[1][0][0]] # type: ignore[index]
251
+ else:
252
+ ids = [self.net.get(self._ref(t)[0]).id]
253
+ out.extend(i for i in ids if i not in out)
254
+ return out
255
+
256
+ @command("multicast", "mcast")
257
+ def cmd_multicast(self, args: list[str], opts: dict[str, str]) -> None:
258
+ """One packet, copied only where the paths to the receivers split."""
259
+ if len(args) < 2:
260
+ raise NetError("usage: multicast <src> <id|(net,host)|group|all> [...]")
261
+ net = self.net
262
+ src = self._src(args[0])
263
+ receivers = [d for d in self._targets(args[1:]) if d != src]
264
+ if not receivers:
265
+ raise NetError("no receivers (the source can't send to itself)")
266
+ paths: dict[str, list[tuple[str, int]]] = {}
267
+ lost: list[str] = []
268
+ for d in receivers:
269
+ try:
270
+ _, path, legs, _ = self._plan(src, d)
271
+ except NetError as e:
272
+ lost.append(f"{d} ({e})")
273
+ continue
274
+ self._learn(legs)
275
+ paths[d] = path
276
+ recv_txt = " ".join(f"[{net.devices[d].color}]{d}[/]" for d in receivers)
277
+ frm = src if self.graph else f"{fmt(next((i.addr for _, i in net.addressed(src)), None))} [dim]({src})[/]"
278
+ self.log_msg(f"[b bright_magenta]MULTICAST[/] {frm} → {recv_txt}")
279
+ for msg in lost:
280
+ self.log_msg(f" [red]✗ unreachable: {escape(msg)}[/]")
281
+ if not paths:
282
+ return
283
+
284
+ # Links in the distribution tree, and where copies get made.
285
+ tree_links = {li for p in paths.values() for _, li in p[1:]}
286
+ children: dict[str, set[str]] = {}
287
+ for p in paths.values():
288
+ for (u, _), (v, _) in zip(p, p[1:]):
289
+ children.setdefault(u, set()).add(v)
290
+ forks = [f"{u}×{len(vs)}" for u, vs in children.items() if len(vs) > 1]
291
+ unicast_cost = sum(len(p) - 1 for p in paths.values())
292
+ self.log_msg(f" copies made at: {' '.join(forks) or 'none (single path)'}")
293
+ self.log_msg(f" {len(tree_links)} link transmissions "
294
+ f"(separate unicasts would need {unicast_cost})")
295
+
296
+ # All copies leave together and share cells until their paths split,
297
+ # so on screen one packet visibly forks at each branching device.
298
+ c = self.canvas
299
+ c.heat(tree_links)
300
+ pending = [len(paths)]
301
+
302
+ def arrived(dst: str) -> None:
303
+ c.flash_device(dst, "bright_magenta")
304
+ pending[0] -= 1
305
+ if pending[0] == 0:
306
+ c.cool(tree_links)
307
+ self.log_msg(f" [bright_magenta]delivered to {len(paths)} receiver{'s' if len(paths) != 1 else ''}[/]: "
308
+ + " ".join(paths))
309
+ self.refresh_sidebar()
310
+
311
+ for dst, p in paths.items():
312
+ c.send_packet(self._cells(p), "bright_magenta", lambda d=dst: arrived(d))
bytesim/tui/help.py ADDED
@@ -0,0 +1,50 @@
1
+ """The text shown by the `help` command (Rich markup)."""
2
+
3
+ HELP = """\
4
+ [b]Commands[/] (device ids: g1 r2 s3 c4 · ports: r1:3 or c2:nic2)
5
+ [cyan]add[/] <g|r|s|c> \\[ports] \\[count] add devices e.g. [i]add s 24[/], [i]add c 2 3[/]
6
+ [cyan]<id> <id>[/] cable two devices (first free ports)
7
+ [cyan]<id>:<port> <id>:<port>[/] cable specific ports, e.g. [i]r1:2 s1:8[/]
8
+ [cyan]<id>[/] select device, then move it with arrow keys
9
+ [cyan]unlink[/] <id>\\[:port] \\[id] remove cable(s)
10
+ [cyan]del[/] <id> delete a device
11
+ [cyan]ports[/] <id> <n> change port / NIC count
12
+ [cyan]ping[/] <id> <id|addr|internet> echo request + reply (follows networks + gateways)
13
+ [cyan]unicast[/] <src> <id|addr> one-way packet to a single device
14
+ [cyan]multicast[/] <src> <ids|groups|all> one packet, copied where paths split, e.g. [i]mcast c1 pcs c8[/]
15
+ [cyan]group[/] <name> <id|group ...> logical layer; groups nest, e.g. [i]group dc1 fab1 rfab1[/]
16
+ [cyan]group[/] <name> or <name> select a group (arrows / drag its frame move everything in it)
17
+ [cyan]ungroup[/] <name> \\[member ...] take members out, or dissolve (members move up a level)
18
+ [cyan]clone[/] <group> \\[new-name] copy a group: devices, nested groups, internal cables
19
+ [cyan]ports copy[/] <group>(old, …) <group>(new, …) cable an existing group the way another is cabled, e.g.
20
+ [i]ports copy link1(rfab1, rfab2) link2(rfab2, rfab3)[/]
21
+ [cyan]copy[/] <group>(old, …) <new>(new, …) the same, creating the new group as a clone first
22
+ a name like [i]fab(1)[/] means the next free fabN (group, clone)
23
+ [cyan]renumber[/] bfs|dfs \\[root] renumber each kind 1..n in BFS/DFS order from root (default g1…)
24
+ [cyan]renumber auto[/] bfs|dfs \\[root] · [cyan]renumber off[/] keep renumbering after every change
25
+ [cyan]export[/] <file.yaml> \\[ports] declarative topology (load reads it back); ports = exact
26
+ [cyan]export script[/] \\[readable|shortest|explicit] \\[file] the commands that rebuild this network
27
+ [cyan]run[/] <file> run a script of commands (after [cyan]clear[/] for an export)
28
+ [cyan]groups[/] · [cyan]auto[/] (re-arrange) · [cyan]list[/] · [cyan]demo[/] · [cyan]clear[/] · [cyan]save[/]/[cyan]load[/] \\[file] · [cyan]quit[/]
29
+ [b]Addressing[/] an address is (network, host): network id + 8-bit host, e.g. [i](2,14)[/]
30
+ MACs name the physical port: R1A = r1 port 1. A network spans every switch it touches.
31
+ Routers/gateways take host 1, 2… on each network and serve DHCP; computers lease hosts 10+,
32
+ or take a free host on their own when no server answers (ad-hoc)
33
+ [cyan]ip[/] \\[id\\[:port]] table of MACs, networks and hosts
34
+ [cyan]ip[/] <id:port> (net,host) \\[gw] static, e.g. [i]ip c5 (2,50) 1[/] · [cyan]ip[/] <id:port> dhcp|adhoc|none
35
+ [cyan]dhcp[/] <id\\[:nic]|all> run DHCP (animated DISCOVER broadcast + OFFER)
36
+ [cyan]release[/] <id\\[:nic]|all> give up a lease · [cyan]adhoc[/] <id\\[:nic]|all> self-assign
37
+ [cyan]dhcpd[/] <router> on|off turn a router's DHCP server on or off
38
+ [cyan]mode[/] graph|addr graph mode: pure connectivity, no addressing rules
39
+ [b]Loops, STP, storms[/] switches run spanning tree unless it's switched off for them
40
+ [cyan]broadcast[/] <id\\[:port]> flood one frame; into a live loop it becomes a storm
41
+ [cyan]stp[/] · [cyan]stp[/] on|off · [cyan]stp[/] <switch…> on|off · [cyan]stp priority[/] <switch> <n> · [cyan]demo loop[/]
42
+ [b]Roles, failures, HA[/]
43
+ [cyan]<id> <id> role=[/]data|control|control-backup|fabric cable with a role (control = heartbeat only, ═)
44
+ [cyan]role[/] <id:port>|<id> <id> <role> change a cable's role (fabric = router↔router data, ━)
45
+ [cyan]fail[/] <id> | <id:port> | <id> <id> kill a device or cable(s) · [cyan]restore[/] … | all
46
+ [cyan]cluster[/] <name> <routers…> \\[policy=none|quorum|witness] HA pair/group sharing gateway host 254
47
+ [cyan]cluster[/] <name> · [cyan]clusters[/] · [cyan]uncluster[/] <name> · [cyan]demo ha[/]
48
+ [cyan]undo[/] \\[n] · [cyan]redo[/] \\[n] · [cyan]history[/] step back through changes (u / U on the canvas)
49
+ [b]Canvas keys[/] arrows move (shift = x4) · tab cycle · a arrange · x delete · u undo · U redo
50
+ hjkl / drag background / wheel pan · drag a box to move · v show/hide addresses · enter or : → command line"""