router-cli 0.2.0__py3-none-any.whl → 0.3.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.
- router_cli/__init__.py +1 -1
- router_cli/client.py +47 -610
- router_cli/commands.py +227 -0
- router_cli/config.py +75 -21
- router_cli/display.py +191 -0
- router_cli/formatters.py +368 -0
- router_cli/main.py +50 -645
- router_cli/models.py +165 -0
- router_cli/parser.py +521 -0
- {router_cli-0.2.0.dist-info → router_cli-0.3.0.dist-info}/METADATA +21 -3
- router_cli-0.3.0.dist-info/RECORD +14 -0
- router_cli-0.2.0.dist-info/RECORD +0 -9
- {router_cli-0.2.0.dist-info → router_cli-0.3.0.dist-info}/WHEEL +0 -0
- {router_cli-0.2.0.dist-info → router_cli-0.3.0.dist-info}/entry_points.txt +0 -0
router_cli/formatters.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Formatting functions for router data."""
|
|
2
|
+
|
|
3
|
+
from .models import (
|
|
4
|
+
ADSLStats,
|
|
5
|
+
DHCPLease,
|
|
6
|
+
InterfaceStats,
|
|
7
|
+
LogEntry,
|
|
8
|
+
Route,
|
|
9
|
+
RouterStatus,
|
|
10
|
+
Statistics,
|
|
11
|
+
WirelessClient,
|
|
12
|
+
)
|
|
13
|
+
from .config import KnownDevices
|
|
14
|
+
from .display import colorize, format_bytes, format_expires, get_device_display
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_status(status: RouterStatus) -> str:
|
|
18
|
+
"""Format router status for display."""
|
|
19
|
+
lines = [
|
|
20
|
+
"=" * 50,
|
|
21
|
+
f"{'ROUTER STATUS (DSL-2750U)':^50}",
|
|
22
|
+
"=" * 50,
|
|
23
|
+
"",
|
|
24
|
+
"SYSTEM INFO",
|
|
25
|
+
f" Model Name: {status.model_name}",
|
|
26
|
+
f" Time and Date: {status.time_date}",
|
|
27
|
+
f" Firmware: {status.firmware}",
|
|
28
|
+
"",
|
|
29
|
+
"INTERNET INFO",
|
|
30
|
+
f" Default Gateway: {status.default_gateway}",
|
|
31
|
+
f" Preferred DNS: {status.preferred_dns}",
|
|
32
|
+
f" Alternate DNS: {status.alternate_dns}",
|
|
33
|
+
"",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if status.wan_connections:
|
|
37
|
+
lines.append(" WAN Connections:")
|
|
38
|
+
lines.append(
|
|
39
|
+
f" {'Interface':<12} {'Description':<16} {'Status':<12} {'IPv4 Address':<16}"
|
|
40
|
+
)
|
|
41
|
+
lines.append(f" {'-' * 10:<12} {'-' * 14:<16} {'-' * 10:<12} {'-' * 14:<16}")
|
|
42
|
+
for conn in status.wan_connections:
|
|
43
|
+
lines.append(
|
|
44
|
+
f" {conn.interface:<12} {conn.description:<16} "
|
|
45
|
+
f"{conn.status:<12} {conn.ipv4:<16}"
|
|
46
|
+
)
|
|
47
|
+
lines.append("")
|
|
48
|
+
|
|
49
|
+
lines.extend(
|
|
50
|
+
[
|
|
51
|
+
"WIRELESS INFO",
|
|
52
|
+
f" SSID: {status.ssid}",
|
|
53
|
+
f" MAC Address: {status.wireless_mac}",
|
|
54
|
+
f" Status: {status.wireless_status}",
|
|
55
|
+
f" Security Mode: {status.security_mode}",
|
|
56
|
+
"",
|
|
57
|
+
"LOCAL NETWORK",
|
|
58
|
+
f" MAC Address: {status.local_mac}",
|
|
59
|
+
f" IP Address: {status.local_ip}",
|
|
60
|
+
f" Subnet Mask: {status.subnet_mask}",
|
|
61
|
+
f" DHCP Server: {status.dhcp_server}",
|
|
62
|
+
"=" * 50,
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_clients(
|
|
70
|
+
clients: list[WirelessClient], known_devices: KnownDevices | None = None
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Format wireless clients for display."""
|
|
73
|
+
if not clients:
|
|
74
|
+
return "No wireless clients connected."
|
|
75
|
+
|
|
76
|
+
known_devices = known_devices or KnownDevices()
|
|
77
|
+
|
|
78
|
+
# Build display data and calculate column widths
|
|
79
|
+
rows = []
|
|
80
|
+
for c in clients:
|
|
81
|
+
assoc = "Yes" if c.associated else "No"
|
|
82
|
+
auth = "Yes" if c.authorized else "No"
|
|
83
|
+
# WirelessClient doesn't have hostname, so we can only check by MAC
|
|
84
|
+
alias = known_devices.get_alias(c.mac, "")
|
|
85
|
+
is_known = alias is not None
|
|
86
|
+
mac_display = f"{c.mac} ({alias})" if alias else c.mac
|
|
87
|
+
rows.append((mac_display, assoc, auth, c.ssid, c.interface, is_known))
|
|
88
|
+
|
|
89
|
+
# Calculate column widths
|
|
90
|
+
headers = ["MAC Address", "Associated", "Authorized", "SSID", "Interface"]
|
|
91
|
+
widths = [len(h) for h in headers]
|
|
92
|
+
for row in rows:
|
|
93
|
+
for i, val in enumerate(row[:5]):
|
|
94
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
95
|
+
|
|
96
|
+
# Add padding
|
|
97
|
+
widths = [w + 2 for w in widths]
|
|
98
|
+
|
|
99
|
+
# Build output
|
|
100
|
+
header_line = "".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
101
|
+
sep_line = "".join(("-" * (w - 2)).ljust(w) for w in widths)
|
|
102
|
+
lines = [header_line, sep_line]
|
|
103
|
+
|
|
104
|
+
for mac_display, assoc, auth, ssid, interface, is_known in rows:
|
|
105
|
+
color = "green" if is_known else "red"
|
|
106
|
+
line = (
|
|
107
|
+
f"{mac_display.ljust(widths[0])}"
|
|
108
|
+
f"{assoc.ljust(widths[1])}"
|
|
109
|
+
f"{auth.ljust(widths[2])}"
|
|
110
|
+
f"{ssid.ljust(widths[3])}"
|
|
111
|
+
f"{interface.ljust(widths[4])}"
|
|
112
|
+
)
|
|
113
|
+
lines.append(colorize(line, color))
|
|
114
|
+
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_dhcp(
|
|
119
|
+
leases: list[DHCPLease], known_devices: KnownDevices | None = None
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Format DHCP leases for display."""
|
|
122
|
+
if not leases:
|
|
123
|
+
return "No DHCP leases."
|
|
124
|
+
|
|
125
|
+
known_devices = known_devices or KnownDevices()
|
|
126
|
+
|
|
127
|
+
# Build display data and calculate column widths
|
|
128
|
+
rows = []
|
|
129
|
+
for lease in leases:
|
|
130
|
+
display_name, is_known = get_device_display(
|
|
131
|
+
lease.mac, lease.hostname, known_devices
|
|
132
|
+
)
|
|
133
|
+
expires = f"⏱ {format_expires(lease.expires_in)}"
|
|
134
|
+
rows.append((display_name, lease.mac, lease.ip, expires, is_known))
|
|
135
|
+
|
|
136
|
+
# Calculate column widths
|
|
137
|
+
headers = ["Device", "MAC Address", "IP Address", "Expires"]
|
|
138
|
+
widths = [len(h) for h in headers]
|
|
139
|
+
for row in rows:
|
|
140
|
+
for i, val in enumerate(row[:4]):
|
|
141
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
142
|
+
|
|
143
|
+
# Add padding
|
|
144
|
+
widths = [w + 2 for w in widths]
|
|
145
|
+
|
|
146
|
+
# Build output
|
|
147
|
+
header_line = "".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
148
|
+
sep_line = "".join(("-" * (w - 2)).ljust(w) for w in widths)
|
|
149
|
+
lines = [header_line, sep_line]
|
|
150
|
+
|
|
151
|
+
for display_name, mac, ip, expires, is_known in rows:
|
|
152
|
+
color = "green" if is_known else "red"
|
|
153
|
+
line = (
|
|
154
|
+
f"{display_name.ljust(widths[0])}"
|
|
155
|
+
f"{mac.ljust(widths[1])}"
|
|
156
|
+
f"{ip.ljust(widths[2])}"
|
|
157
|
+
f"{expires.ljust(widths[3])}"
|
|
158
|
+
)
|
|
159
|
+
lines.append(colorize(line, color))
|
|
160
|
+
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_routes(routes: list[Route]) -> str:
|
|
165
|
+
"""Format routing table for display."""
|
|
166
|
+
if not routes:
|
|
167
|
+
return "No routes found."
|
|
168
|
+
|
|
169
|
+
lines = [
|
|
170
|
+
f"{'Destination':<16} {'Gateway':<16} {'Subnet Mask':<16} {'Flag':<6} {'Metric':<8} {'Service':<12}",
|
|
171
|
+
f"{'-' * 14:<16} {'-' * 14:<16} {'-' * 14:<16} {'-' * 4:<6} {'-' * 6:<8} {'-' * 10:<12}",
|
|
172
|
+
]
|
|
173
|
+
for r in routes:
|
|
174
|
+
lines.append(
|
|
175
|
+
f"{r.destination:<16} {r.gateway:<16} {r.subnet_mask:<16} "
|
|
176
|
+
f"{r.flag:<6} {r.metric:<8} {r.service:<12}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def format_interface_stats(interfaces: list[InterfaceStats], title: str) -> list[str]:
|
|
183
|
+
"""Format interface stats section."""
|
|
184
|
+
if not interfaces:
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
lines = [
|
|
188
|
+
title,
|
|
189
|
+
f" {'Interface':<12} {'RX Bytes':<12} {'RX Pkts':<10} {'RX Err':<8} {'RX Drop':<8} "
|
|
190
|
+
f"{'TX Bytes':<12} {'TX Pkts':<10} {'TX Err':<8} {'TX Drop':<8}",
|
|
191
|
+
f" {'-' * 10:<12} {'-' * 10:<12} {'-' * 8:<10} {'-' * 6:<8} {'-' * 6:<8} "
|
|
192
|
+
f"{'-' * 10:<12} {'-' * 8:<10} {'-' * 6:<8} {'-' * 6:<8}",
|
|
193
|
+
]
|
|
194
|
+
for intf in interfaces:
|
|
195
|
+
lines.append(
|
|
196
|
+
f" {intf.interface:<12} {format_bytes(intf.rx_bytes):<12} {intf.rx_packets:<10} "
|
|
197
|
+
f"{intf.rx_errors:<8} {intf.rx_drops:<8} {format_bytes(intf.tx_bytes):<12} "
|
|
198
|
+
f"{intf.tx_packets:<10} {intf.tx_errors:<8} {intf.tx_drops:<8}"
|
|
199
|
+
)
|
|
200
|
+
return lines
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def format_adsl(adsl: ADSLStats) -> list[str]:
|
|
204
|
+
"""Format ADSL statistics."""
|
|
205
|
+
if adsl.status == "N/A":
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
"ADSL STATUS",
|
|
210
|
+
f" Mode: {adsl.mode}",
|
|
211
|
+
f" Status: {adsl.status}",
|
|
212
|
+
f" Link Power State: {adsl.link_power_state}",
|
|
213
|
+
"",
|
|
214
|
+
f" {'Metric':<22} {'Downstream':<14} {'Upstream':<14}",
|
|
215
|
+
f" {'-' * 20:<22} {'-' * 12:<14} {'-' * 12:<14}",
|
|
216
|
+
f" {'Rate (Kbps)':<22} {adsl.downstream_rate:<14} {adsl.upstream_rate:<14}",
|
|
217
|
+
f" {'Attainable Rate':<22} {adsl.downstream_attainable_rate:<14} {adsl.upstream_attainable_rate:<14}",
|
|
218
|
+
f" {'SNR Margin (dB)':<22} {adsl.downstream_snr_margin:<14.1f} {adsl.upstream_snr_margin:<14.1f}",
|
|
219
|
+
f" {'Attenuation (dB)':<22} {adsl.downstream_attenuation:<14.1f} {adsl.upstream_attenuation:<14.1f}",
|
|
220
|
+
f" {'Output Power (dBm)':<22} {adsl.downstream_output_power:<14.1f} {adsl.upstream_output_power:<14.1f}",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def format_stats(stats: Statistics) -> str:
|
|
225
|
+
"""Format network statistics for display."""
|
|
226
|
+
lines = []
|
|
227
|
+
|
|
228
|
+
lines.extend(format_interface_stats(stats.lan_interfaces, "LAN INTERFACES"))
|
|
229
|
+
if stats.lan_interfaces:
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
lines.extend(format_interface_stats(stats.wan_interfaces, "WAN INTERFACES"))
|
|
233
|
+
if stats.wan_interfaces:
|
|
234
|
+
lines.append("")
|
|
235
|
+
|
|
236
|
+
lines.extend(format_adsl(stats.adsl))
|
|
237
|
+
|
|
238
|
+
return "\n".join(lines) if lines else "No statistics available."
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def format_logs(logs: list[LogEntry]) -> str:
|
|
242
|
+
"""Format system logs for display."""
|
|
243
|
+
if not logs:
|
|
244
|
+
return "No log entries."
|
|
245
|
+
|
|
246
|
+
lines = [
|
|
247
|
+
f"{'Date/Time':<22} {'Facility':<10} {'Severity':<10} {'Message'}",
|
|
248
|
+
f"{'-' * 20:<22} {'-' * 8:<10} {'-' * 8:<10} {'-' * 40}",
|
|
249
|
+
]
|
|
250
|
+
for log in logs:
|
|
251
|
+
lines.append(
|
|
252
|
+
f"{log.datetime:<22} {log.facility:<10} {log.severity:<10} {log.message}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def format_overview(
|
|
259
|
+
status: RouterStatus,
|
|
260
|
+
clients: list[WirelessClient],
|
|
261
|
+
leases: list[DHCPLease],
|
|
262
|
+
stats: Statistics,
|
|
263
|
+
known_devices: KnownDevices | None = None,
|
|
264
|
+
) -> str:
|
|
265
|
+
"""Format overview dashboard with highlights from multiple sources."""
|
|
266
|
+
known_devices = known_devices or KnownDevices()
|
|
267
|
+
lines = [
|
|
268
|
+
"=" * 60,
|
|
269
|
+
f"{'ROUTER OVERVIEW':^60}",
|
|
270
|
+
"=" * 60,
|
|
271
|
+
"",
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
# Connection status
|
|
275
|
+
lines.append("CONNECTION")
|
|
276
|
+
if stats.adsl.status and stats.adsl.status != "N/A":
|
|
277
|
+
lines.append(f" ADSL Status: {stats.adsl.status}")
|
|
278
|
+
lines.append(
|
|
279
|
+
f" Sync Rate: {stats.adsl.downstream_rate} / {stats.adsl.upstream_rate} Kbps (down/up)"
|
|
280
|
+
)
|
|
281
|
+
lines.append(
|
|
282
|
+
f" SNR Margin: {stats.adsl.downstream_snr_margin:.1f} / {stats.adsl.upstream_snr_margin:.1f} dB"
|
|
283
|
+
)
|
|
284
|
+
lines.append(f" Default Gateway: {status.default_gateway}")
|
|
285
|
+
if status.wan_connections:
|
|
286
|
+
wan = status.wan_connections[0]
|
|
287
|
+
lines.append(
|
|
288
|
+
f" WAN IP: {wan.ipv4 or 'N/A'} ({wan.status or 'N/A'})"
|
|
289
|
+
)
|
|
290
|
+
lines.append("")
|
|
291
|
+
|
|
292
|
+
# Network
|
|
293
|
+
lines.append("NETWORK")
|
|
294
|
+
lines.append(f" Router IP: {status.local_ip}")
|
|
295
|
+
lines.append(f" SSID: {status.ssid}")
|
|
296
|
+
lines.append(f" Wireless Clients: {len(clients)}")
|
|
297
|
+
lines.append(f" DHCP Leases: {len(leases)}")
|
|
298
|
+
lines.append("")
|
|
299
|
+
|
|
300
|
+
# DHCP Leases list
|
|
301
|
+
if leases:
|
|
302
|
+
lines.append("DEVICES")
|
|
303
|
+
|
|
304
|
+
# Build display data and calculate column widths
|
|
305
|
+
rows = []
|
|
306
|
+
for lease in leases:
|
|
307
|
+
display_name, is_known = get_device_display(
|
|
308
|
+
lease.mac, lease.hostname, known_devices
|
|
309
|
+
)
|
|
310
|
+
expires = f"⏱ {format_expires(lease.expires_in)}"
|
|
311
|
+
rows.append((display_name, lease.mac, lease.ip, expires, is_known))
|
|
312
|
+
|
|
313
|
+
# Calculate column widths
|
|
314
|
+
widths = [
|
|
315
|
+
max(len(row[0]) for row in rows),
|
|
316
|
+
max(len(row[1]) for row in rows),
|
|
317
|
+
max(len(row[2]) for row in rows),
|
|
318
|
+
max(len(row[3]) for row in rows),
|
|
319
|
+
]
|
|
320
|
+
widths = [w + 2 for w in widths]
|
|
321
|
+
|
|
322
|
+
for display_name, mac, ip, expires, is_known in rows:
|
|
323
|
+
color = "green" if is_known else "red"
|
|
324
|
+
device_line = (
|
|
325
|
+
f" {display_name.ljust(widths[0])}"
|
|
326
|
+
f"{mac.ljust(widths[1])}"
|
|
327
|
+
f"{ip.ljust(widths[2])}"
|
|
328
|
+
f"{expires}"
|
|
329
|
+
)
|
|
330
|
+
lines.append(colorize(device_line, color))
|
|
331
|
+
lines.append("")
|
|
332
|
+
|
|
333
|
+
# Traffic summary (if available)
|
|
334
|
+
total_rx = sum(i.rx_bytes for i in stats.wan_interfaces)
|
|
335
|
+
total_tx = sum(i.tx_bytes for i in stats.wan_interfaces)
|
|
336
|
+
if total_rx or total_tx:
|
|
337
|
+
lines.append("TRAFFIC (WAN)")
|
|
338
|
+
lines.append(f" Downloaded: {format_bytes(total_rx)}")
|
|
339
|
+
lines.append(f" Uploaded: {format_bytes(total_tx)}")
|
|
340
|
+
lines.append("")
|
|
341
|
+
|
|
342
|
+
# Warnings
|
|
343
|
+
warnings = []
|
|
344
|
+
total_errors = sum(
|
|
345
|
+
i.rx_errors + i.tx_errors for i in stats.wan_interfaces + stats.lan_interfaces
|
|
346
|
+
)
|
|
347
|
+
if total_errors > 0:
|
|
348
|
+
warnings.append(f" Interface errors detected: {total_errors}")
|
|
349
|
+
if stats.adsl.downstream_snr_margin and stats.adsl.downstream_snr_margin < 6:
|
|
350
|
+
warnings.append(
|
|
351
|
+
f" Low SNR margin: {stats.adsl.downstream_snr_margin:.1f} dB (may cause disconnects)"
|
|
352
|
+
)
|
|
353
|
+
# Warn about unknown devices
|
|
354
|
+
unknown_count = sum(
|
|
355
|
+
1 for lease in leases if not known_devices.is_known(lease.mac, lease.hostname)
|
|
356
|
+
)
|
|
357
|
+
if unknown_count > 0:
|
|
358
|
+
warnings.append(
|
|
359
|
+
colorize(f" Unknown devices on network: {unknown_count}", "red")
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if warnings:
|
|
363
|
+
lines.append("WARNINGS")
|
|
364
|
+
lines.extend(warnings)
|
|
365
|
+
lines.append("")
|
|
366
|
+
|
|
367
|
+
lines.append("=" * 60)
|
|
368
|
+
return "\n".join(lines)
|