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
bytesim/tui/cables.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Where cables are drawn: orthogonal paths between ports, and the line glyphs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..constants import BOX_H
|
|
5
|
+
from ..model import Device, Link, Network
|
|
6
|
+
|
|
7
|
+
N, E, S, W = 1, 2, 4, 8
|
|
8
|
+
|
|
9
|
+
LINE = {
|
|
10
|
+
0: " ", N: "│", S: "│", N | S: "│", E: "─", W: "─", E | W: "─",
|
|
11
|
+
N | E: "└", N | W: "┘", S | E: "┌", S | W: "┐",
|
|
12
|
+
N | S | E: "├", N | S | W: "┤", E | W | S: "┬", E | W | N: "┴",
|
|
13
|
+
N | E | S | W: "┼",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
LINE2 = {m: c.translate(str.maketrans("│─└┘┌┐├┤┬┴┼", "║═╚╝╔╗╠╣╦╩╬")) for m, c in LINE.items()}
|
|
17
|
+
|
|
18
|
+
LINE_HEAVY = {m: c.translate(str.maketrans("│─└┘┌┐├┤┬┴┼", "┃━┗┛┏┓┣┫┳┻╋")) for m, c in LINE.items()}
|
|
19
|
+
LINE_DASH = {m: c.translate(str.maketrans("│─", "┆┄")) for m, c in LINE.items()} # STP-blocked
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dirbit(dx: int, dy: int) -> int:
|
|
23
|
+
return {(0, -1): N, (1, 0): E, (0, 1): S, (-1, 0): W}[(dx, dy)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _polyline(corners: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
27
|
+
pts = [corners[0]]
|
|
28
|
+
for x2, y2 in corners[1:]:
|
|
29
|
+
x, y = pts[-1]
|
|
30
|
+
while (x, y) != (x2, y2):
|
|
31
|
+
x += (x2 > x) - (x2 < x)
|
|
32
|
+
y += (y2 > y) - (y2 < y)
|
|
33
|
+
pts.append((x, y))
|
|
34
|
+
return pts
|
|
35
|
+
|
|
36
|
+
def _zpath(s: tuple[int, int], e: tuple[int, int], idx: int) -> list[tuple[int, int]]:
|
|
37
|
+
(sx, sy), (ex, ey) = s, e
|
|
38
|
+
gap = ey - sy
|
|
39
|
+
mid = sy + 1 + idx % (gap - 1) if gap >= 2 else sy
|
|
40
|
+
return _polyline([(sx, sy), (sx, mid), (ex, mid), (ex, ey)])
|
|
41
|
+
|
|
42
|
+
def _bus_slot(net: Network, dev: Device) -> int:
|
|
43
|
+
row = sorted(d.x for d in net.devices.values() if d.y == dev.y)
|
|
44
|
+
return row.index(dev.x)
|
|
45
|
+
|
|
46
|
+
def route(net: Network, link: Link, idx: int) -> list[tuple[int, int]]:
|
|
47
|
+
"""Cells a cable occupies, ordered from link.a to link.b."""
|
|
48
|
+
A, B = net.devices[link.a], net.devices[link.b]
|
|
49
|
+
ax, bx = A.port_x(link.pa), B.port_x(link.pb)
|
|
50
|
+
# Cables leaving the same upper device share a horizontal "bus" row;
|
|
51
|
+
# neighbouring devices use different rows so their buses don't merge.
|
|
52
|
+
if B.y >= A.y + BOX_H + 1: # B below A
|
|
53
|
+
return _zpath((ax, A.y + BOX_H), (bx, B.y - 1), _bus_slot(net, A))
|
|
54
|
+
if A.y >= B.y + BOX_H + 1: # B above A
|
|
55
|
+
return _zpath((bx, B.y + BOX_H), (ax, A.y - 1), _bus_slot(net, B))[::-1]
|
|
56
|
+
# Overlapping rows: loop underneath both boxes.
|
|
57
|
+
y = max(A.y, B.y) + BOX_H + 1 + idx % 2
|
|
58
|
+
return _polyline([(ax, A.y + BOX_H), (ax, y), (bx, y), (bx, B.y + BOX_H)])
|
bytesim/tui/canvas.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""The network canvas: draws devices, cables, frames and packets; mouse and keys."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
|
|
13
|
+
from ..constants import BOX_H
|
|
14
|
+
from ..model import Device, Network, fmt
|
|
15
|
+
from .cables import LINE, LINE2, LINE_DASH, LINE_HEAVY, N, S, dirbit, route
|
|
16
|
+
from .theme import HA_COLOR, ROLE_COLOR, group_color
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Packet:
|
|
20
|
+
cells: list[tuple[int, int]]
|
|
21
|
+
color: str
|
|
22
|
+
on_done: Callable[[], None] | None = None
|
|
23
|
+
pos: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
TRAIL = 4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NetCanvas(Widget, can_focus=True):
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
Binding("up", "move(0,-1)", "Move", show=False),
|
|
32
|
+
Binding("down", "move(0,1)", "Move", show=False),
|
|
33
|
+
Binding("left", "move(-1,0)", "Move", show=False),
|
|
34
|
+
Binding("right", "move(1,0)", "Move", show=False),
|
|
35
|
+
Binding("shift+up", "move(0,-4)", show=False),
|
|
36
|
+
Binding("shift+down", "move(0,4)", show=False),
|
|
37
|
+
Binding("shift+left", "move(-4,0)", show=False),
|
|
38
|
+
Binding("shift+right", "move(4,0)", show=False),
|
|
39
|
+
Binding("tab", "cycle(1)", "Next dev"),
|
|
40
|
+
Binding("shift+tab", "cycle(-1)", "Prev dev", show=False),
|
|
41
|
+
Binding("a", "arrange", "Auto-arrange"),
|
|
42
|
+
Binding("u", "undo", "Undo"),
|
|
43
|
+
Binding("U", "redo", "Redo", show=False),
|
|
44
|
+
Binding("v", "toggle_addr", "Addresses"),
|
|
45
|
+
Binding("x,delete", "delete", "Delete"),
|
|
46
|
+
Binding("h", "pan(-4,0)", "Pan hjkl", show=False),
|
|
47
|
+
Binding("j", "pan(0,2)", show=False),
|
|
48
|
+
Binding("k", "pan(0,-2)", show=False),
|
|
49
|
+
Binding("l", "pan(4,0)", show=False),
|
|
50
|
+
Binding("enter,colon,slash,i", "app.focus_cmd", "Command"),
|
|
51
|
+
Binding("escape", "deselect", show=False),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
def __init__(self, **kw) -> None:
|
|
55
|
+
super().__init__(**kw)
|
|
56
|
+
self.selected: str | None = None
|
|
57
|
+
self.selected_group: str | None = None
|
|
58
|
+
self.show_addr = True
|
|
59
|
+
self.pan = (0, 0)
|
|
60
|
+
self.packets: list[Packet] = []
|
|
61
|
+
self.hot_links: Counter[int] = Counter()
|
|
62
|
+
self.flash: dict[str, list] = {} # device -> [colour, ticks left]
|
|
63
|
+
self._timer = None
|
|
64
|
+
self._drag: tuple | None = None
|
|
65
|
+
self.frame = 0 # animation clock (storms crawl along their loop)
|
|
66
|
+
|
|
67
|
+
def reset(self) -> None:
|
|
68
|
+
"""Forget selection and anything in flight (the network was replaced)."""
|
|
69
|
+
self.selected = self.selected_group = None
|
|
70
|
+
self.packets.clear()
|
|
71
|
+
self.flash.clear()
|
|
72
|
+
self.hot_links.clear()
|
|
73
|
+
self.pan = (0, 0)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def busy(self) -> bool:
|
|
77
|
+
"""True while packets are animating."""
|
|
78
|
+
return bool(self.packets)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def net(self) -> Network:
|
|
82
|
+
return self.app.net # type: ignore[attr-defined]
|
|
83
|
+
|
|
84
|
+
# ---- selection / movement -----------------------------------------
|
|
85
|
+
def select(self, dev_id: str | None) -> None:
|
|
86
|
+
self.selected = dev_id
|
|
87
|
+
self.selected_group = None
|
|
88
|
+
if dev_id:
|
|
89
|
+
d = self.net.devices[dev_id]
|
|
90
|
+
self.scroll_into_view(d.x, d.y, d.x + d.width - 1, d.y + BOX_H - 1)
|
|
91
|
+
self.app.refresh_sidebar() # type: ignore[attr-defined]
|
|
92
|
+
self.refresh()
|
|
93
|
+
|
|
94
|
+
def select_group(self, name: str) -> None:
|
|
95
|
+
self.selected = None
|
|
96
|
+
self.selected_group = name
|
|
97
|
+
self.scroll_into_view(*self.net.group_bounds(name))
|
|
98
|
+
self.app.refresh_sidebar() # type: ignore[attr-defined]
|
|
99
|
+
self.refresh()
|
|
100
|
+
|
|
101
|
+
def scroll_into_view(self, x0: int, y0: int, x1: int, y1: int) -> None:
|
|
102
|
+
ox, oy = self.pan
|
|
103
|
+
w, h = self.size.width or 80, self.size.height or 24
|
|
104
|
+
if x0 < ox:
|
|
105
|
+
ox = x0 - 2
|
|
106
|
+
elif x1 >= ox + w:
|
|
107
|
+
ox = x1 - w + 3
|
|
108
|
+
if y0 < oy:
|
|
109
|
+
oy = y0 - 1
|
|
110
|
+
elif y1 >= oy + h:
|
|
111
|
+
oy = y1 - h + 3
|
|
112
|
+
self.pan = (ox, oy)
|
|
113
|
+
|
|
114
|
+
def _moving(self) -> list[Device]:
|
|
115
|
+
if self.selected_group:
|
|
116
|
+
return [self.net.devices[d] for d in self.net.devices_in(self.selected_group)]
|
|
117
|
+
return [self.net.devices[self.selected]] if self.selected else []
|
|
118
|
+
|
|
119
|
+
def _shift(self, devs: list[Device], dx: int, dy: int, what: str = "") -> bool:
|
|
120
|
+
"""Move devices together without letting any go off the top/left edge."""
|
|
121
|
+
dx = max(dx, -min(d.x for d in devs))
|
|
122
|
+
dy = max(dy, -min(d.y for d in devs))
|
|
123
|
+
if dx or dy:
|
|
124
|
+
self.app.before_move(what or " ".join(d.id for d in devs)) # type: ignore[attr-defined]
|
|
125
|
+
for d in devs:
|
|
126
|
+
d.x, d.y = d.x + dx, d.y + dy
|
|
127
|
+
if dx or dy:
|
|
128
|
+
self.app.manual_moved() # type: ignore[attr-defined]
|
|
129
|
+
return bool(dx or dy)
|
|
130
|
+
|
|
131
|
+
def action_move(self, dx: int, dy: int) -> None:
|
|
132
|
+
devs = self._moving()
|
|
133
|
+
if not devs:
|
|
134
|
+
self.app.log_msg("[dim]select a device or group first (tab, click, or type its name)[/]") # type: ignore[attr-defined]
|
|
135
|
+
return
|
|
136
|
+
self._shift(devs, dx, dy, self.selected_group or self.selected or "")
|
|
137
|
+
if self.selected_group:
|
|
138
|
+
self.scroll_into_view(*self.net.group_bounds(self.selected_group))
|
|
139
|
+
else:
|
|
140
|
+
d = devs[0]
|
|
141
|
+
self.scroll_into_view(d.x, d.y, d.x + d.width - 1, d.y + BOX_H - 1)
|
|
142
|
+
self.app.refresh_sidebar() # type: ignore[attr-defined]
|
|
143
|
+
self.refresh()
|
|
144
|
+
|
|
145
|
+
def action_cycle(self, step: int) -> None:
|
|
146
|
+
devs = [d.id for d in self.net.sorted_devices()]
|
|
147
|
+
if not devs:
|
|
148
|
+
return
|
|
149
|
+
i = devs.index(self.selected) + step if self.selected in devs else (0 if step > 0 else -1)
|
|
150
|
+
self.select(devs[i % len(devs)])
|
|
151
|
+
|
|
152
|
+
def action_deselect(self) -> None:
|
|
153
|
+
self.select(None)
|
|
154
|
+
|
|
155
|
+
def action_arrange(self) -> None:
|
|
156
|
+
self.app.run_command("auto") # type: ignore[attr-defined] (so it can be undone)
|
|
157
|
+
|
|
158
|
+
def action_delete(self) -> None:
|
|
159
|
+
if self.selected_group:
|
|
160
|
+
self.app.run_command(f"ungroup {self.selected_group}") # type: ignore[attr-defined]
|
|
161
|
+
elif self.selected:
|
|
162
|
+
self.app.run_command(f"del {self.selected}") # type: ignore[attr-defined]
|
|
163
|
+
|
|
164
|
+
def action_undo(self) -> None:
|
|
165
|
+
self.app.run_command("undo") # type: ignore[attr-defined]
|
|
166
|
+
|
|
167
|
+
def action_redo(self) -> None:
|
|
168
|
+
self.app.run_command("redo") # type: ignore[attr-defined]
|
|
169
|
+
|
|
170
|
+
def action_toggle_addr(self) -> None:
|
|
171
|
+
self.show_addr = not self.show_addr
|
|
172
|
+
self.refresh()
|
|
173
|
+
|
|
174
|
+
def action_pan(self, dx: int, dy: int) -> None:
|
|
175
|
+
self.pan = (self.pan[0] + dx, self.pan[1] + dy)
|
|
176
|
+
self.refresh()
|
|
177
|
+
|
|
178
|
+
# ---- mouse ---------------------------------------------------------
|
|
179
|
+
def _hit(self, wx: int, wy: int) -> Device | None:
|
|
180
|
+
for d in reversed(self._draw_order()):
|
|
181
|
+
if d.contains(wx, wy):
|
|
182
|
+
return d
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def _hit_group(self, wx: int, wy: int) -> str | None:
|
|
186
|
+
"""A group is hit by clicking its frame (border or label); inner frames win."""
|
|
187
|
+
for g in sorted(self.net.groups, key=lambda g: -len(self.net.chain(g))):
|
|
188
|
+
x0, y0, x1, y1 = self.net.group_bounds(g)
|
|
189
|
+
on_x = wx in (x0, x1) and y0 <= wy <= y1
|
|
190
|
+
on_y = wy in (y0, y1) and x0 <= wx <= x1
|
|
191
|
+
if on_x or on_y:
|
|
192
|
+
return g
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
196
|
+
off = event.get_content_offset(self)
|
|
197
|
+
if off is None:
|
|
198
|
+
return
|
|
199
|
+
self.focus()
|
|
200
|
+
wx, wy = off.x + self.pan[0], off.y + self.pan[1]
|
|
201
|
+
hit = self._hit(wx, wy)
|
|
202
|
+
if hit:
|
|
203
|
+
self.select(hit.id)
|
|
204
|
+
self._drag = ("dev", hit.id, wx, wy)
|
|
205
|
+
elif g := self._hit_group(wx, wy):
|
|
206
|
+
self.select_group(g)
|
|
207
|
+
self._drag = ("group", g, wx, wy)
|
|
208
|
+
else:
|
|
209
|
+
self._drag = ("pan", off.x, off.y, *self.pan)
|
|
210
|
+
self.capture_mouse()
|
|
211
|
+
|
|
212
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
213
|
+
if not self._drag:
|
|
214
|
+
return
|
|
215
|
+
off = event.get_content_offset_capture(self)
|
|
216
|
+
if self._drag[0] in ("dev", "group"):
|
|
217
|
+
kind, name, lx, ly = self._drag
|
|
218
|
+
if kind == "dev":
|
|
219
|
+
devs = [self.net.devices[name]] if name in self.net.devices else []
|
|
220
|
+
else:
|
|
221
|
+
devs = [self.net.devices[d] for d in self.net.devices_in(name)]
|
|
222
|
+
if not devs:
|
|
223
|
+
return
|
|
224
|
+
wx, wy = off.x + self.pan[0], off.y + self.pan[1]
|
|
225
|
+
if self._shift(devs, wx - lx, wy - ly, name):
|
|
226
|
+
self._drag = (kind, name, wx, wy)
|
|
227
|
+
self.refresh()
|
|
228
|
+
else:
|
|
229
|
+
_, sx, sy, ox, oy = self._drag
|
|
230
|
+
self.pan = (ox - (off.x - sx), oy - (off.y - sy))
|
|
231
|
+
self.refresh()
|
|
232
|
+
|
|
233
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
234
|
+
if self._drag:
|
|
235
|
+
self._drag = None
|
|
236
|
+
self.release_mouse()
|
|
237
|
+
self.app.refresh_sidebar() # type: ignore[attr-defined]
|
|
238
|
+
|
|
239
|
+
def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
|
240
|
+
self.action_pan(*((4, 0) if event.shift else (0, 2)))
|
|
241
|
+
|
|
242
|
+
def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
243
|
+
self.action_pan(*((-4, 0) if event.shift else (0, -2)))
|
|
244
|
+
|
|
245
|
+
# ---- packets -------------------------------------------------------
|
|
246
|
+
def send_packet(self, cells: list[tuple[int, int]], color: str,
|
|
247
|
+
on_done: Callable[[], None] | None = None) -> None:
|
|
248
|
+
self.packets.append(Packet(cells, color, on_done))
|
|
249
|
+
if self._timer is None:
|
|
250
|
+
self._timer = self.set_interval(1 / 45, self._tick)
|
|
251
|
+
|
|
252
|
+
def heat(self, links) -> None:
|
|
253
|
+
self.hot_links.update(links)
|
|
254
|
+
|
|
255
|
+
def cool(self, links) -> None:
|
|
256
|
+
self.hot_links.subtract(links)
|
|
257
|
+
self.hot_links += Counter() # drop zero counts
|
|
258
|
+
self.refresh()
|
|
259
|
+
|
|
260
|
+
def flash_device(self, dev_id: str, color: str, ticks: int = 30) -> None:
|
|
261
|
+
self.flash[dev_id] = [color, ticks]
|
|
262
|
+
self.animate()
|
|
263
|
+
|
|
264
|
+
def animate(self) -> None:
|
|
265
|
+
"""Make sure the animation clock is running (it stops itself when idle)."""
|
|
266
|
+
if self._timer is None:
|
|
267
|
+
self._timer = self.set_interval(1 / 45, self._tick)
|
|
268
|
+
|
|
269
|
+
def _tick(self) -> None:
|
|
270
|
+
for pk in list(self.packets):
|
|
271
|
+
pk.pos += 1
|
|
272
|
+
if pk.pos - TRAIL >= len(pk.cells):
|
|
273
|
+
self.packets.remove(pk)
|
|
274
|
+
if pk.on_done:
|
|
275
|
+
pk.on_done()
|
|
276
|
+
for dev_id in list(self.flash):
|
|
277
|
+
self.flash[dev_id][1] -= 1
|
|
278
|
+
if self.flash[dev_id][1] <= 0:
|
|
279
|
+
del self.flash[dev_id]
|
|
280
|
+
self.frame += 1
|
|
281
|
+
if not self.packets and not self.flash and not self.net.storming and self._timer is not None:
|
|
282
|
+
self._timer.stop()
|
|
283
|
+
self._timer = None
|
|
284
|
+
self.refresh()
|
|
285
|
+
|
|
286
|
+
# ---- rendering -----------------------------------------------------
|
|
287
|
+
def _draw_order(self) -> list[Device]:
|
|
288
|
+
return sorted(self.net.devices.values(), key=lambda d: d.id == self.selected)
|
|
289
|
+
|
|
290
|
+
def render(self) -> Text:
|
|
291
|
+
w, h = self.size.width, self.size.height
|
|
292
|
+
ox, oy = self.pan
|
|
293
|
+
chars = [[" "] * w for _ in range(h)]
|
|
294
|
+
styles = [[""] * w for _ in range(h)]
|
|
295
|
+
|
|
296
|
+
def put(x: int, y: int, ch: str, st: str) -> None:
|
|
297
|
+
sx, sy = x - ox, y - oy
|
|
298
|
+
if 0 <= sx < w and 0 <= sy < h:
|
|
299
|
+
chars[sy][sx] = ch
|
|
300
|
+
styles[sy][sx] = st
|
|
301
|
+
|
|
302
|
+
net = self.net
|
|
303
|
+
masks: dict[tuple[int, int], int] = {}
|
|
304
|
+
cell_style: dict[tuple[int, int], str] = {}
|
|
305
|
+
sides: dict[tuple[str, int], str] = {}
|
|
306
|
+
hot = set(self.hot_links)
|
|
307
|
+
if self.selected:
|
|
308
|
+
hot |= {i for i, l in enumerate(net.links) if l.touches(self.selected)}
|
|
309
|
+
if self.selected_group:
|
|
310
|
+
members = set(net.devices_in(self.selected_group))
|
|
311
|
+
hot |= {i for i, l in enumerate(net.links) if l.a in members or l.b in members}
|
|
312
|
+
|
|
313
|
+
kinds: dict[tuple[int, int], set[str]] = {}
|
|
314
|
+
broken: list[tuple[int, int]] = []
|
|
315
|
+
crawl: list[list[tuple[int, int]]] = [] # storm loops: dots run along them
|
|
316
|
+
looping = net.loop_links() if net.storming else set()
|
|
317
|
+
for i, l in enumerate(net.links):
|
|
318
|
+
pts = route(net, l, i)
|
|
319
|
+
alive = l.up and net.devices[l.a].up and net.devices[l.b].up
|
|
320
|
+
blocked = (l.a, l.pa) in net.stp_blocked or (l.b, l.pb) in net.stp_blocked
|
|
321
|
+
storm = l.a in net.storming or l.b in net.storming
|
|
322
|
+
if storm and i in looping:
|
|
323
|
+
crawl.append(pts)
|
|
324
|
+
st = ("red" if not alive or storm else "grey35" if blocked
|
|
325
|
+
else "bold yellow" if i in hot else ROLE_COLOR[l.role])
|
|
326
|
+
kind = "control" if l.control else "blocked" if blocked else l.role
|
|
327
|
+
for pt in pts:
|
|
328
|
+
kinds.setdefault(pt, set()).add(kind)
|
|
329
|
+
if not l.up:
|
|
330
|
+
broken.append(pts[len(pts) // 2])
|
|
331
|
+
for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
|
|
332
|
+
masks[(x1, y1)] = masks.get((x1, y1), 0) | dirbit(x2 - x1, y2 - y1)
|
|
333
|
+
masks[(x2, y2)] = masks.get((x2, y2), 0) | dirbit(x1 - x2, y1 - y2)
|
|
334
|
+
for dev_id, port, pt in ((l.a, l.pa, pts[0]), (l.b, l.pb, pts[-1])):
|
|
335
|
+
below = pt[1] > net.devices[dev_id].y
|
|
336
|
+
masks[pt] = masks.get(pt, 0) | (N if below else S)
|
|
337
|
+
sides[(dev_id, port)] = "bottom" if below else "top"
|
|
338
|
+
for pt in pts:
|
|
339
|
+
if i in hot or pt not in cell_style:
|
|
340
|
+
cell_style[pt] = st
|
|
341
|
+
frames = []
|
|
342
|
+
for g in sorted(net.groups, key=lambda g: len(net.chain(g))): # outer frames first
|
|
343
|
+
sel = g == self.selected_group
|
|
344
|
+
label = f" {g} "
|
|
345
|
+
st = group_color(g)
|
|
346
|
+
if g in net.clusters and not self.app.graph: # type: ignore[attr-defined]
|
|
347
|
+
ha = net.ha_state(g)
|
|
348
|
+
label = f" {g} · HA {ha['level']} " if ha["level"] != "ok" else f" {g} · HA "
|
|
349
|
+
if ha["level"] != "ok":
|
|
350
|
+
st = HA_COLOR[ha["level"]]
|
|
351
|
+
st = "bold yellow" if sel else st
|
|
352
|
+
x0, y0, x1, y1 = net.group_bounds(g)
|
|
353
|
+
frames.append((g, st, x0, y0, x1, label))
|
|
354
|
+
hz, vt = ("━", "┃") if sel else ("┄", "┆")
|
|
355
|
+
for x in range(x0 + 1, x1):
|
|
356
|
+
put(x, y0, hz, st)
|
|
357
|
+
put(x, y1, hz, st)
|
|
358
|
+
for y in range(y0 + 1, y1):
|
|
359
|
+
put(x0, y, vt, st)
|
|
360
|
+
put(x1, y, vt, st)
|
|
361
|
+
for x, y, ch in ((x0, y0, "╭"), (x1, y0, "╮"), (x0, y1, "╰"), (x1, y1, "╯")):
|
|
362
|
+
put(x, y, ch, st)
|
|
363
|
+
|
|
364
|
+
for (x, y), m in masks.items():
|
|
365
|
+
k = kinds.get((x, y), {"data"})
|
|
366
|
+
table = (LINE2 if k == {"control"} else LINE_HEAVY if k == {"fabric"}
|
|
367
|
+
else LINE_DASH if k == {"blocked"} else LINE)
|
|
368
|
+
put(x, y, table[m], cell_style[(x, y)])
|
|
369
|
+
for x, y in broken:
|
|
370
|
+
put(x, y, "✕", "bold red")
|
|
371
|
+
for pts in crawl:
|
|
372
|
+
for k, (x, y) in enumerate(pts):
|
|
373
|
+
if (k - self.frame // 2) % 4 == 0:
|
|
374
|
+
put(x, y, "•", "bold bright_red")
|
|
375
|
+
|
|
376
|
+
# Group labels sit on the top edge, in the first gap no cable passes through.
|
|
377
|
+
for g, st, x0, y0, x1, label in frames:
|
|
378
|
+
starts = range(x0 + 2, max(x0 + 3, x1 - len(label)))
|
|
379
|
+
lx = next((sx for sx in starts
|
|
380
|
+
if not any((sx + k, y0) in masks for k in range(len(label)))), x0 + 2)
|
|
381
|
+
for k, ch in enumerate(label):
|
|
382
|
+
if (lx + k, y0) not in masks:
|
|
383
|
+
put(lx + k, y0, ch, f"bold reverse {st}" if 0 < k < len(label) - 1 else st)
|
|
384
|
+
|
|
385
|
+
for d in self._draw_order():
|
|
386
|
+
self._draw_device(d, put, sides, d.id == self.selected, self.flash.get(d.id))
|
|
387
|
+
|
|
388
|
+
if self.show_addr:
|
|
389
|
+
# Labels wider than their box drop to a lower line instead of colliding.
|
|
390
|
+
ends: dict[tuple[int, int], int] = {}
|
|
391
|
+
for d in sorted(net.devices.values(), key=lambda d: d.x):
|
|
392
|
+
text, st, y = self._addr_label(d)
|
|
393
|
+
if not text:
|
|
394
|
+
continue
|
|
395
|
+
step = 1 if d.kind == "c" else -1
|
|
396
|
+
|
|
397
|
+
def blocked(lv: int, dx: int) -> bool:
|
|
398
|
+
return (ends.get((y, lv), -10**6) >= d.x + dx
|
|
399
|
+
or any((d.x + dx + k, y + lv * step) in masks for k in range(len(text))))
|
|
400
|
+
|
|
401
|
+
level, dx = next(((lv, dx) for dx in range(d.width) for lv in range(3)
|
|
402
|
+
if not blocked(lv, dx)), (0, 0))
|
|
403
|
+
ends[(y, level)] = d.x + dx + len(text)
|
|
404
|
+
y += level * step
|
|
405
|
+
for k, ch in enumerate(text):
|
|
406
|
+
if (d.x + dx + k, y) not in masks:
|
|
407
|
+
put(d.x + dx + k, y, ch, st)
|
|
408
|
+
|
|
409
|
+
for pk in self.packets:
|
|
410
|
+
for k in range(TRAIL):
|
|
411
|
+
idx = pk.pos - k
|
|
412
|
+
if 0 <= idx < len(pk.cells):
|
|
413
|
+
x, y = pk.cells[idx]
|
|
414
|
+
put(x, y, "●" if k == 0 else "•", f"bold {pk.color}" if k == 0 else pk.color)
|
|
415
|
+
|
|
416
|
+
text = Text(no_wrap=True, overflow="crop")
|
|
417
|
+
for row in range(h):
|
|
418
|
+
start = 0
|
|
419
|
+
for col in range(1, w + 1):
|
|
420
|
+
if col == w or styles[row][col] != styles[row][start]:
|
|
421
|
+
text.append("".join(chars[row][start:col]), styles[row][start] or None)
|
|
422
|
+
start = col
|
|
423
|
+
if row < h - 1:
|
|
424
|
+
text.append("\n")
|
|
425
|
+
return text
|
|
426
|
+
|
|
427
|
+
def _addr_label(self, d: Device) -> tuple[str, str, int]:
|
|
428
|
+
"""Short address caption drawn under computers and above gateways."""
|
|
429
|
+
net = self.net
|
|
430
|
+
if d.kind == "g":
|
|
431
|
+
return "☁ internet", "magenta", d.y - 1
|
|
432
|
+
if d.kind != "c" or self.app.graph: # type: ignore[attr-defined]
|
|
433
|
+
return "", "", 0
|
|
434
|
+
y = d.y + BOX_H
|
|
435
|
+
for g in net.chain(d.id): # step below any frame edges under the box
|
|
436
|
+
bottom = net.group_bounds(g)[3]
|
|
437
|
+
if y <= bottom <= d.y + BOX_H + 3:
|
|
438
|
+
y = bottom + 1
|
|
439
|
+
addrs = net.addressed(d.id)
|
|
440
|
+
if not addrs:
|
|
441
|
+
cabled = any(net.link_index(d.id, p) is not None for p in range(1, d.nports + 1))
|
|
442
|
+
return ("no addr", "red", y) if cabled else ("", "", 0)
|
|
443
|
+
_, ifc = addrs[0]
|
|
444
|
+
st = {"adhoc": "yellow", "static": "cyan"}.get(ifc.via, "grey70")
|
|
445
|
+
return fmt(ifc.addr) + (f"+{len(addrs) - 1}" if len(addrs) > 1 else ""), st, y
|
|
446
|
+
|
|
447
|
+
def _draw_device(self, d: Device, put, sides: dict, selected: bool, flash=None) -> None:
|
|
448
|
+
if selected:
|
|
449
|
+
tl, tr, bl, br, hz, vt, dn, up = "╔╗╚╝═║╤╧"
|
|
450
|
+
bst = "bold yellow"
|
|
451
|
+
else:
|
|
452
|
+
tl, tr, bl, br, hz, vt, dn, up = "┌┐└┘─│┬┴"
|
|
453
|
+
bst = d.color
|
|
454
|
+
if not d.up:
|
|
455
|
+
bst = "red"
|
|
456
|
+
elif d.id in self.net.storming: # pulse while drowning in a storm
|
|
457
|
+
bst = "bold bright_red" if (self.frame // 12) % 2 else "red"
|
|
458
|
+
if flash and flash[1] % 10 >= 3: # blink on arrival
|
|
459
|
+
tl, tr, bl, br, hz, vt, dn, up = "┏┓┗┛━┃┳┻"
|
|
460
|
+
bst = f"bold {flash[0]}"
|
|
461
|
+
x, y, w = d.x, d.y, d.width
|
|
462
|
+
rows = [tl + hz * (w - 2) + tr, None, None, bl + hz * (w - 2) + br]
|
|
463
|
+
for i, ch in enumerate(rows[0]):
|
|
464
|
+
put(x + i, y, ch, bst)
|
|
465
|
+
for i, ch in enumerate(rows[3]):
|
|
466
|
+
put(x + i, y + 3, ch, bst)
|
|
467
|
+
for r in (1, 2):
|
|
468
|
+
put(x, y + r, vt, bst)
|
|
469
|
+
put(x + w - 1, y + r, vt, bst)
|
|
470
|
+
for i in range(1, w - 1):
|
|
471
|
+
put(x + i, y + r, " ", "")
|
|
472
|
+
label = (d.id if d.up else d.id + "✕").center(w - 2)
|
|
473
|
+
lst = f"bold reverse {d.color}" if selected else f"bold {d.color}"
|
|
474
|
+
if not d.up:
|
|
475
|
+
lst = "bold red strike"
|
|
476
|
+
lpad = len(label) - len(label.lstrip())
|
|
477
|
+
for i, ch in enumerate(label):
|
|
478
|
+
put(x + 1 + i, y + 1, ch, lst if lpad - 1 <= i <= lpad + len(d.id) else "")
|
|
479
|
+
for p in range(1, d.nports + 1):
|
|
480
|
+
px = d.port_x(p)
|
|
481
|
+
side = sides.get((d.id, p))
|
|
482
|
+
if side and (d.id, p) in self.net.stp_blocked:
|
|
483
|
+
put(px, y + 2, "⊘", "bold yellow") # held blocking by STP
|
|
484
|
+
put(px, y + 3 if side == "bottom" else y, dn if side == "bottom" else up, bst)
|
|
485
|
+
elif side:
|
|
486
|
+
put(px, y + 2, "●", f"bold {d.color}")
|
|
487
|
+
put(px, y + 3 if side == "bottom" else y, dn if side == "bottom" else up, bst)
|
|
488
|
+
else:
|
|
489
|
+
put(px, y + 2, "○", "grey42")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The command line, with history on up/down."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.widgets import Input
|
|
6
|
+
|
|
7
|
+
class CommandInput(Input):
|
|
8
|
+
BINDINGS = [
|
|
9
|
+
Binding("escape", "app.focus_canvas", "Canvas", show=False),
|
|
10
|
+
Binding("up", "history(-1)", show=False),
|
|
11
|
+
Binding("down", "history(1)", show=False),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
def __init__(self, **kw) -> None:
|
|
15
|
+
super().__init__(**kw)
|
|
16
|
+
self.history: list[str] = []
|
|
17
|
+
self.hpos = 0
|
|
18
|
+
|
|
19
|
+
def remember(self, line: str) -> None:
|
|
20
|
+
if line and (not self.history or self.history[-1] != line):
|
|
21
|
+
self.history.append(line)
|
|
22
|
+
self.hpos = len(self.history)
|
|
23
|
+
|
|
24
|
+
def action_history(self, step: int) -> None:
|
|
25
|
+
if not self.history:
|
|
26
|
+
return
|
|
27
|
+
self.hpos = max(0, min(len(self.history), self.hpos + step))
|
|
28
|
+
self.value = self.history[self.hpos] if self.hpos < len(self.history) else ""
|
|
29
|
+
self.cursor_position = len(self.value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Console commands, grouped by topic. Each handler is registered with @command."""
|