router-cli 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.
- router_cli/__init__.py +3 -0
- router_cli/client.py +883 -0
- router_cli/config.py +66 -0
- router_cli/main.py +704 -0
- router_cli/py.typed +0 -0
- router_cli-0.1.0.dist-info/METADATA +228 -0
- router_cli-0.1.0.dist-info/RECORD +9 -0
- router_cli-0.1.0.dist-info/WHEEL +4 -0
- router_cli-0.1.0.dist-info/entry_points.txt +3 -0
router_cli/main.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Router CLI - Main entry point."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
|
|
10
|
+
from .client import (
|
|
11
|
+
ADSLStats,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ConnectionError,
|
|
14
|
+
DHCPLease,
|
|
15
|
+
HTTPError,
|
|
16
|
+
InterfaceStats,
|
|
17
|
+
LogEntry,
|
|
18
|
+
Route,
|
|
19
|
+
RouterClient,
|
|
20
|
+
RouterError,
|
|
21
|
+
RouterStatus,
|
|
22
|
+
Statistics,
|
|
23
|
+
WirelessClient,
|
|
24
|
+
)
|
|
25
|
+
from .config import load_config, load_known_devices
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ANSI color codes
|
|
29
|
+
_COLORS = {
|
|
30
|
+
"green": "\033[32m",
|
|
31
|
+
"red": "\033[31m",
|
|
32
|
+
"yellow": "\033[33m",
|
|
33
|
+
"reset": "\033[0m",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _handle_error(e: Exception) -> int:
|
|
38
|
+
"""Handle exceptions and print user-friendly error messages.
|
|
39
|
+
|
|
40
|
+
Returns the exit code to use.
|
|
41
|
+
"""
|
|
42
|
+
if isinstance(e, AuthenticationError):
|
|
43
|
+
print(f"Authentication error: {e}", file=sys.stderr)
|
|
44
|
+
print(
|
|
45
|
+
" Hint: Check your username/password in the config file.", file=sys.stderr
|
|
46
|
+
)
|
|
47
|
+
return 2
|
|
48
|
+
elif isinstance(e, ConnectionError):
|
|
49
|
+
print(f"Connection error: {e}", file=sys.stderr)
|
|
50
|
+
print(
|
|
51
|
+
" Hint: Ensure the router is reachable and the IP is correct.",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
return 3
|
|
55
|
+
elif isinstance(e, HTTPError):
|
|
56
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
57
|
+
if e.status_code == 503:
|
|
58
|
+
print(
|
|
59
|
+
" Hint: The router may be busy. Wait a moment and try again.",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
return 4
|
|
63
|
+
elif isinstance(e, RouterError):
|
|
64
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
else:
|
|
67
|
+
# Catch-all for unexpected errors - include type for debugging
|
|
68
|
+
print(f"Unexpected error ({type(e).__name__}): {e}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def colorize(text: str, color: str) -> str:
|
|
73
|
+
"""Apply ANSI color to text if stdout is a TTY."""
|
|
74
|
+
if not sys.stdout.isatty():
|
|
75
|
+
return text
|
|
76
|
+
return f"{_COLORS.get(color, '')}{text}{_COLORS['reset']}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Spinner frames - braille pattern for smooth animation
|
|
80
|
+
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
81
|
+
_SPINNER_INTERVAL = 0.08 # seconds between frames
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def spinner(message: str = "Loading..."):
|
|
86
|
+
"""Display an animated spinner while waiting for an operation.
|
|
87
|
+
|
|
88
|
+
Usage:
|
|
89
|
+
with spinner("Fetching status..."):
|
|
90
|
+
result = client.get_status()
|
|
91
|
+
|
|
92
|
+
Only displays spinner if stdout is a TTY.
|
|
93
|
+
"""
|
|
94
|
+
if not sys.stdout.isatty():
|
|
95
|
+
# Not a TTY, just run without spinner
|
|
96
|
+
yield
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
stop_event = threading.Event()
|
|
100
|
+
spinner_thread = None
|
|
101
|
+
|
|
102
|
+
def animate():
|
|
103
|
+
frame_idx = 0
|
|
104
|
+
# Hide cursor
|
|
105
|
+
sys.stdout.write("\033[?25l")
|
|
106
|
+
sys.stdout.flush()
|
|
107
|
+
|
|
108
|
+
while not stop_event.is_set():
|
|
109
|
+
frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
|
|
110
|
+
# Write spinner frame and message, then return cursor to start
|
|
111
|
+
sys.stdout.write(f"\r{frame} {message}")
|
|
112
|
+
sys.stdout.flush()
|
|
113
|
+
frame_idx += 1
|
|
114
|
+
stop_event.wait(_SPINNER_INTERVAL)
|
|
115
|
+
|
|
116
|
+
# Clear the spinner line
|
|
117
|
+
sys.stdout.write("\r" + " " * (len(message) + 3) + "\r")
|
|
118
|
+
# Show cursor
|
|
119
|
+
sys.stdout.write("\033[?25h")
|
|
120
|
+
sys.stdout.flush()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
spinner_thread = threading.Thread(target=animate, daemon=True)
|
|
124
|
+
spinner_thread.start()
|
|
125
|
+
yield
|
|
126
|
+
finally:
|
|
127
|
+
stop_event.set()
|
|
128
|
+
if spinner_thread:
|
|
129
|
+
spinner_thread.join(timeout=0.5)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_device_display(
|
|
133
|
+
mac: str, hostname: str, known_devices: dict[str, str]
|
|
134
|
+
) -> tuple[str, bool]:
|
|
135
|
+
"""Get display name for a device and whether it's known.
|
|
136
|
+
|
|
137
|
+
Returns (display_name, is_known) tuple.
|
|
138
|
+
If known, display_name is 'Alias (hostname)'.
|
|
139
|
+
"""
|
|
140
|
+
alias = known_devices.get(mac.upper())
|
|
141
|
+
if alias:
|
|
142
|
+
if hostname and hostname != alias:
|
|
143
|
+
return f"{alias} ({hostname})", True
|
|
144
|
+
return alias, True
|
|
145
|
+
return hostname or mac, False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def format_expires(expires_in: str) -> str:
|
|
149
|
+
"""Convert verbose expires string to compact HH:MM:SS format.
|
|
150
|
+
|
|
151
|
+
Input: "22 hours, 27 minutes, 15 seconds"
|
|
152
|
+
Output: "22:27:15"
|
|
153
|
+
"""
|
|
154
|
+
hours = minutes = seconds = 0
|
|
155
|
+
|
|
156
|
+
h_match = re.search(r"(\d+)\s*hour", expires_in)
|
|
157
|
+
m_match = re.search(r"(\d+)\s*minute", expires_in)
|
|
158
|
+
s_match = re.search(r"(\d+)\s*second", expires_in)
|
|
159
|
+
|
|
160
|
+
if h_match:
|
|
161
|
+
hours = int(h_match.group(1))
|
|
162
|
+
if m_match:
|
|
163
|
+
minutes = int(m_match.group(1))
|
|
164
|
+
if s_match:
|
|
165
|
+
seconds = int(s_match.group(1))
|
|
166
|
+
|
|
167
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def format_status(status: RouterStatus) -> str:
|
|
171
|
+
"""Format router status for display."""
|
|
172
|
+
lines = [
|
|
173
|
+
"=" * 50,
|
|
174
|
+
f"{'ROUTER STATUS (DSL-2750U)':^50}",
|
|
175
|
+
"=" * 50,
|
|
176
|
+
"",
|
|
177
|
+
"SYSTEM INFO",
|
|
178
|
+
f" Model Name: {status.model_name}",
|
|
179
|
+
f" Time and Date: {status.time_date}",
|
|
180
|
+
f" Firmware: {status.firmware}",
|
|
181
|
+
"",
|
|
182
|
+
"INTERNET INFO",
|
|
183
|
+
f" Default Gateway: {status.default_gateway}",
|
|
184
|
+
f" Preferred DNS: {status.preferred_dns}",
|
|
185
|
+
f" Alternate DNS: {status.alternate_dns}",
|
|
186
|
+
"",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
if status.wan_connections:
|
|
190
|
+
lines.append(" WAN Connections:")
|
|
191
|
+
lines.append(
|
|
192
|
+
f" {'Interface':<12} {'Description':<16} {'Status':<12} {'IPv4 Address':<16}"
|
|
193
|
+
)
|
|
194
|
+
lines.append(f" {'-' * 10:<12} {'-' * 14:<16} {'-' * 10:<12} {'-' * 14:<16}")
|
|
195
|
+
for conn in status.wan_connections:
|
|
196
|
+
lines.append(
|
|
197
|
+
f" {conn['interface']:<12} {conn['description']:<16} "
|
|
198
|
+
f"{conn['status']:<12} {conn['ipv4']:<16}"
|
|
199
|
+
)
|
|
200
|
+
lines.append("")
|
|
201
|
+
|
|
202
|
+
lines.extend(
|
|
203
|
+
[
|
|
204
|
+
"WIRELESS INFO",
|
|
205
|
+
f" SSID: {status.ssid}",
|
|
206
|
+
f" MAC Address: {status.wireless_mac}",
|
|
207
|
+
f" Status: {status.wireless_status}",
|
|
208
|
+
f" Security Mode: {status.security_mode}",
|
|
209
|
+
"",
|
|
210
|
+
"LOCAL NETWORK",
|
|
211
|
+
f" MAC Address: {status.local_mac}",
|
|
212
|
+
f" IP Address: {status.local_ip}",
|
|
213
|
+
f" Subnet Mask: {status.subnet_mask}",
|
|
214
|
+
f" DHCP Server: {status.dhcp_server}",
|
|
215
|
+
"=" * 50,
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return "\n".join(lines)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_clients(
|
|
223
|
+
clients: list[WirelessClient], known_devices: dict[str, str] | None = None
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Format wireless clients for display."""
|
|
226
|
+
if not clients:
|
|
227
|
+
return "No wireless clients connected."
|
|
228
|
+
|
|
229
|
+
known_devices = known_devices or {}
|
|
230
|
+
|
|
231
|
+
# Build display data and calculate column widths
|
|
232
|
+
rows = []
|
|
233
|
+
for c in clients:
|
|
234
|
+
assoc = "Yes" if c.associated else "No"
|
|
235
|
+
auth = "Yes" if c.authorized else "No"
|
|
236
|
+
is_known = c.mac.upper() in known_devices
|
|
237
|
+
alias = known_devices.get(c.mac.upper(), "")
|
|
238
|
+
mac_display = f"{c.mac} ({alias})" if alias else c.mac
|
|
239
|
+
rows.append((mac_display, assoc, auth, c.ssid, c.interface, is_known))
|
|
240
|
+
|
|
241
|
+
# Calculate column widths
|
|
242
|
+
headers = ["MAC Address", "Associated", "Authorized", "SSID", "Interface"]
|
|
243
|
+
widths = [len(h) for h in headers]
|
|
244
|
+
for row in rows:
|
|
245
|
+
for i, val in enumerate(row[:5]):
|
|
246
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
247
|
+
|
|
248
|
+
# Add padding
|
|
249
|
+
widths = [w + 2 for w in widths]
|
|
250
|
+
|
|
251
|
+
# Build output
|
|
252
|
+
header_line = "".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
253
|
+
sep_line = "".join(("-" * (w - 2)).ljust(w) for w in widths)
|
|
254
|
+
lines = [header_line, sep_line]
|
|
255
|
+
|
|
256
|
+
for mac_display, assoc, auth, ssid, interface, is_known in rows:
|
|
257
|
+
color = "green" if is_known else "red"
|
|
258
|
+
line = (
|
|
259
|
+
f"{mac_display.ljust(widths[0])}"
|
|
260
|
+
f"{assoc.ljust(widths[1])}"
|
|
261
|
+
f"{auth.ljust(widths[2])}"
|
|
262
|
+
f"{ssid.ljust(widths[3])}"
|
|
263
|
+
f"{interface.ljust(widths[4])}"
|
|
264
|
+
)
|
|
265
|
+
lines.append(colorize(line, color))
|
|
266
|
+
|
|
267
|
+
return "\n".join(lines)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def format_dhcp(
|
|
271
|
+
leases: list[DHCPLease], known_devices: dict[str, str] | None = None
|
|
272
|
+
) -> str:
|
|
273
|
+
"""Format DHCP leases for display."""
|
|
274
|
+
if not leases:
|
|
275
|
+
return "No DHCP leases."
|
|
276
|
+
|
|
277
|
+
known_devices = known_devices or {}
|
|
278
|
+
|
|
279
|
+
# Build display data and calculate column widths
|
|
280
|
+
rows = []
|
|
281
|
+
for lease in leases:
|
|
282
|
+
display_name, is_known = get_device_display(
|
|
283
|
+
lease.mac, lease.hostname, known_devices
|
|
284
|
+
)
|
|
285
|
+
expires = f"⏱ {format_expires(lease.expires_in)}"
|
|
286
|
+
rows.append((display_name, lease.mac, lease.ip, expires, is_known))
|
|
287
|
+
|
|
288
|
+
# Calculate column widths
|
|
289
|
+
headers = ["Device", "MAC Address", "IP Address", "Expires"]
|
|
290
|
+
widths = [len(h) for h in headers]
|
|
291
|
+
for row in rows:
|
|
292
|
+
for i, val in enumerate(row[:4]):
|
|
293
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
294
|
+
|
|
295
|
+
# Add padding
|
|
296
|
+
widths = [w + 2 for w in widths]
|
|
297
|
+
|
|
298
|
+
# Build output
|
|
299
|
+
header_line = "".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
300
|
+
sep_line = "".join(("-" * (w - 2)).ljust(w) for w in widths)
|
|
301
|
+
lines = [header_line, sep_line]
|
|
302
|
+
|
|
303
|
+
for display_name, mac, ip, expires, is_known in rows:
|
|
304
|
+
color = "green" if is_known else "red"
|
|
305
|
+
line = (
|
|
306
|
+
f"{display_name.ljust(widths[0])}"
|
|
307
|
+
f"{mac.ljust(widths[1])}"
|
|
308
|
+
f"{ip.ljust(widths[2])}"
|
|
309
|
+
f"{expires.ljust(widths[3])}"
|
|
310
|
+
)
|
|
311
|
+
lines.append(colorize(line, color))
|
|
312
|
+
|
|
313
|
+
return "\n".join(lines)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def format_routes(routes: list[Route]) -> str:
|
|
317
|
+
"""Format routing table for display."""
|
|
318
|
+
if not routes:
|
|
319
|
+
return "No routes found."
|
|
320
|
+
|
|
321
|
+
lines = [
|
|
322
|
+
f"{'Destination':<16} {'Gateway':<16} {'Subnet Mask':<16} {'Flag':<6} {'Metric':<8} {'Service':<12}",
|
|
323
|
+
f"{'-' * 14:<16} {'-' * 14:<16} {'-' * 14:<16} {'-' * 4:<6} {'-' * 6:<8} {'-' * 10:<12}",
|
|
324
|
+
]
|
|
325
|
+
for r in routes:
|
|
326
|
+
lines.append(
|
|
327
|
+
f"{r.destination:<16} {r.gateway:<16} {r.subnet_mask:<16} "
|
|
328
|
+
f"{r.flag:<6} {r.metric:<8} {r.service:<12}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return "\n".join(lines)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def format_bytes(num_bytes: int) -> str:
|
|
335
|
+
"""Format bytes as human-readable string."""
|
|
336
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
337
|
+
if abs(num_bytes) < 1024:
|
|
338
|
+
return f"{num_bytes:.1f} {unit}"
|
|
339
|
+
num_bytes /= 1024
|
|
340
|
+
return f"{num_bytes:.1f} PB"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def format_interface_stats(interfaces: list[InterfaceStats], title: str) -> list[str]:
|
|
344
|
+
"""Format interface stats section."""
|
|
345
|
+
if not interfaces:
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
lines = [
|
|
349
|
+
title,
|
|
350
|
+
f" {'Interface':<12} {'RX Bytes':<12} {'RX Pkts':<10} {'RX Err':<8} {'RX Drop':<8} "
|
|
351
|
+
f"{'TX Bytes':<12} {'TX Pkts':<10} {'TX Err':<8} {'TX Drop':<8}",
|
|
352
|
+
f" {'-' * 10:<12} {'-' * 10:<12} {'-' * 8:<10} {'-' * 6:<8} {'-' * 6:<8} "
|
|
353
|
+
f"{'-' * 10:<12} {'-' * 8:<10} {'-' * 6:<8} {'-' * 6:<8}",
|
|
354
|
+
]
|
|
355
|
+
for intf in interfaces:
|
|
356
|
+
lines.append(
|
|
357
|
+
f" {intf.interface:<12} {format_bytes(intf.rx_bytes):<12} {intf.rx_packets:<10} "
|
|
358
|
+
f"{intf.rx_errors:<8} {intf.rx_drops:<8} {format_bytes(intf.tx_bytes):<12} "
|
|
359
|
+
f"{intf.tx_packets:<10} {intf.tx_errors:<8} {intf.tx_drops:<8}"
|
|
360
|
+
)
|
|
361
|
+
return lines
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def format_adsl(adsl: ADSLStats) -> list[str]:
|
|
365
|
+
"""Format ADSL statistics."""
|
|
366
|
+
if adsl.status == "N/A":
|
|
367
|
+
return []
|
|
368
|
+
|
|
369
|
+
return [
|
|
370
|
+
"ADSL STATUS",
|
|
371
|
+
f" Mode: {adsl.mode}",
|
|
372
|
+
f" Status: {adsl.status}",
|
|
373
|
+
f" Link Power State: {adsl.link_power_state}",
|
|
374
|
+
"",
|
|
375
|
+
f" {'Metric':<22} {'Downstream':<14} {'Upstream':<14}",
|
|
376
|
+
f" {'-' * 20:<22} {'-' * 12:<14} {'-' * 12:<14}",
|
|
377
|
+
f" {'Rate (Kbps)':<22} {adsl.downstream_rate:<14} {adsl.upstream_rate:<14}",
|
|
378
|
+
f" {'Attainable Rate':<22} {adsl.downstream_attainable_rate:<14} {adsl.upstream_attainable_rate:<14}",
|
|
379
|
+
f" {'SNR Margin (dB)':<22} {adsl.downstream_snr_margin:<14.1f} {adsl.upstream_snr_margin:<14.1f}",
|
|
380
|
+
f" {'Attenuation (dB)':<22} {adsl.downstream_attenuation:<14.1f} {adsl.upstream_attenuation:<14.1f}",
|
|
381
|
+
f" {'Output Power (dBm)':<22} {adsl.downstream_output_power:<14.1f} {adsl.upstream_output_power:<14.1f}",
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def format_stats(stats: Statistics) -> str:
|
|
386
|
+
"""Format network statistics for display."""
|
|
387
|
+
lines = []
|
|
388
|
+
|
|
389
|
+
lines.extend(format_interface_stats(stats.lan_interfaces, "LAN INTERFACES"))
|
|
390
|
+
if stats.lan_interfaces:
|
|
391
|
+
lines.append("")
|
|
392
|
+
|
|
393
|
+
lines.extend(format_interface_stats(stats.wan_interfaces, "WAN INTERFACES"))
|
|
394
|
+
if stats.wan_interfaces:
|
|
395
|
+
lines.append("")
|
|
396
|
+
|
|
397
|
+
lines.extend(format_adsl(stats.adsl))
|
|
398
|
+
|
|
399
|
+
return "\n".join(lines) if lines else "No statistics available."
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def format_logs(logs: list[LogEntry]) -> str:
|
|
403
|
+
"""Format system logs for display."""
|
|
404
|
+
if not logs:
|
|
405
|
+
return "No log entries."
|
|
406
|
+
|
|
407
|
+
lines = [
|
|
408
|
+
f"{'Date/Time':<22} {'Facility':<10} {'Severity':<10} {'Message'}",
|
|
409
|
+
f"{'-' * 20:<22} {'-' * 8:<10} {'-' * 8:<10} {'-' * 40}",
|
|
410
|
+
]
|
|
411
|
+
for log in logs:
|
|
412
|
+
lines.append(
|
|
413
|
+
f"{log.datetime:<22} {log.facility:<10} {log.severity:<10} {log.message}"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return "\n".join(lines)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def format_overview(
|
|
420
|
+
status: RouterStatus,
|
|
421
|
+
clients: list[WirelessClient],
|
|
422
|
+
leases: list[DHCPLease],
|
|
423
|
+
stats: Statistics,
|
|
424
|
+
known_devices: dict[str, str] | None = None,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Format overview dashboard with highlights from multiple sources."""
|
|
427
|
+
known_devices = known_devices or {}
|
|
428
|
+
lines = [
|
|
429
|
+
"=" * 60,
|
|
430
|
+
f"{'ROUTER OVERVIEW':^60}",
|
|
431
|
+
"=" * 60,
|
|
432
|
+
"",
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
# Connection status
|
|
436
|
+
lines.append("CONNECTION")
|
|
437
|
+
if stats.adsl.status and stats.adsl.status != "N/A":
|
|
438
|
+
lines.append(f" ADSL Status: {stats.adsl.status}")
|
|
439
|
+
lines.append(
|
|
440
|
+
f" Sync Rate: {stats.adsl.downstream_rate} / {stats.adsl.upstream_rate} Kbps (down/up)"
|
|
441
|
+
)
|
|
442
|
+
lines.append(
|
|
443
|
+
f" SNR Margin: {stats.adsl.downstream_snr_margin:.1f} / {stats.adsl.upstream_snr_margin:.1f} dB"
|
|
444
|
+
)
|
|
445
|
+
lines.append(f" Default Gateway: {status.default_gateway}")
|
|
446
|
+
if status.wan_connections:
|
|
447
|
+
wan = status.wan_connections[0]
|
|
448
|
+
lines.append(
|
|
449
|
+
f" WAN IP: {wan.get('ipv4', 'N/A')} ({wan.get('status', 'N/A')})"
|
|
450
|
+
)
|
|
451
|
+
lines.append("")
|
|
452
|
+
|
|
453
|
+
# Network
|
|
454
|
+
lines.append("NETWORK")
|
|
455
|
+
lines.append(f" Router IP: {status.local_ip}")
|
|
456
|
+
lines.append(f" SSID: {status.ssid}")
|
|
457
|
+
lines.append(f" Wireless Clients: {len(clients)}")
|
|
458
|
+
lines.append(f" DHCP Leases: {len(leases)}")
|
|
459
|
+
lines.append("")
|
|
460
|
+
|
|
461
|
+
# DHCP Leases list
|
|
462
|
+
if leases:
|
|
463
|
+
lines.append("DEVICES")
|
|
464
|
+
|
|
465
|
+
# Build display data and calculate column widths
|
|
466
|
+
rows = []
|
|
467
|
+
for lease in leases:
|
|
468
|
+
display_name, is_known = get_device_display(
|
|
469
|
+
lease.mac, lease.hostname, known_devices
|
|
470
|
+
)
|
|
471
|
+
expires = f"⏱ {format_expires(lease.expires_in)}"
|
|
472
|
+
rows.append((display_name, lease.mac, lease.ip, expires, is_known))
|
|
473
|
+
|
|
474
|
+
# Calculate column widths
|
|
475
|
+
widths = [
|
|
476
|
+
max(len(row[0]) for row in rows),
|
|
477
|
+
max(len(row[1]) for row in rows),
|
|
478
|
+
max(len(row[2]) for row in rows),
|
|
479
|
+
max(len(row[3]) for row in rows),
|
|
480
|
+
]
|
|
481
|
+
widths = [w + 2 for w in widths]
|
|
482
|
+
|
|
483
|
+
for display_name, mac, ip, expires, is_known in rows:
|
|
484
|
+
color = "green" if is_known else "red"
|
|
485
|
+
device_line = (
|
|
486
|
+
f" {display_name.ljust(widths[0])}"
|
|
487
|
+
f"{mac.ljust(widths[1])}"
|
|
488
|
+
f"{ip.ljust(widths[2])}"
|
|
489
|
+
f"{expires}"
|
|
490
|
+
)
|
|
491
|
+
lines.append(colorize(device_line, color))
|
|
492
|
+
lines.append("")
|
|
493
|
+
|
|
494
|
+
# Traffic summary (if available)
|
|
495
|
+
total_rx = sum(i.rx_bytes for i in stats.wan_interfaces)
|
|
496
|
+
total_tx = sum(i.tx_bytes for i in stats.wan_interfaces)
|
|
497
|
+
if total_rx or total_tx:
|
|
498
|
+
lines.append("TRAFFIC (WAN)")
|
|
499
|
+
lines.append(f" Downloaded: {format_bytes(total_rx)}")
|
|
500
|
+
lines.append(f" Uploaded: {format_bytes(total_tx)}")
|
|
501
|
+
lines.append("")
|
|
502
|
+
|
|
503
|
+
# Warnings
|
|
504
|
+
warnings = []
|
|
505
|
+
total_errors = sum(
|
|
506
|
+
i.rx_errors + i.tx_errors for i in stats.wan_interfaces + stats.lan_interfaces
|
|
507
|
+
)
|
|
508
|
+
if total_errors > 0:
|
|
509
|
+
warnings.append(f" Interface errors detected: {total_errors}")
|
|
510
|
+
if stats.adsl.downstream_snr_margin and stats.adsl.downstream_snr_margin < 6:
|
|
511
|
+
warnings.append(
|
|
512
|
+
f" Low SNR margin: {stats.adsl.downstream_snr_margin:.1f} dB (may cause disconnects)"
|
|
513
|
+
)
|
|
514
|
+
# Warn about unknown devices
|
|
515
|
+
unknown_count = sum(1 for lease in leases if lease.mac.upper() not in known_devices)
|
|
516
|
+
if unknown_count > 0:
|
|
517
|
+
warnings.append(
|
|
518
|
+
colorize(f" Unknown devices on network: {unknown_count}", "red")
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if warnings:
|
|
522
|
+
lines.append("WARNINGS")
|
|
523
|
+
lines.extend(warnings)
|
|
524
|
+
lines.append("")
|
|
525
|
+
|
|
526
|
+
lines.append("=" * 60)
|
|
527
|
+
return "\n".join(lines)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def cmd_status(client: RouterClient) -> int:
|
|
531
|
+
"""Execute status command."""
|
|
532
|
+
try:
|
|
533
|
+
with spinner("Fetching router status..."):
|
|
534
|
+
status = client.get_status()
|
|
535
|
+
print(format_status(status))
|
|
536
|
+
return 0
|
|
537
|
+
except Exception as e:
|
|
538
|
+
return _handle_error(e)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def cmd_reboot(client: RouterClient) -> int:
|
|
542
|
+
"""Execute reboot command."""
|
|
543
|
+
try:
|
|
544
|
+
with spinner("Sending reboot command..."):
|
|
545
|
+
client.reboot()
|
|
546
|
+
print("Reboot command sent successfully.")
|
|
547
|
+
print("The router will restart in a few seconds.")
|
|
548
|
+
return 0
|
|
549
|
+
except Exception as e:
|
|
550
|
+
return _handle_error(e)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def cmd_clients(client: RouterClient, known_devices: dict[str, str]) -> int:
|
|
554
|
+
"""Execute clients command."""
|
|
555
|
+
try:
|
|
556
|
+
with spinner("Fetching wireless clients..."):
|
|
557
|
+
clients = client.get_wireless_clients()
|
|
558
|
+
print(format_clients(clients, known_devices))
|
|
559
|
+
return 0
|
|
560
|
+
except Exception as e:
|
|
561
|
+
return _handle_error(e)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def cmd_dhcp(client: RouterClient, known_devices: dict[str, str]) -> int:
|
|
565
|
+
"""Execute dhcp command."""
|
|
566
|
+
try:
|
|
567
|
+
with spinner("Fetching DHCP leases..."):
|
|
568
|
+
leases = client.get_dhcp_leases()
|
|
569
|
+
print(format_dhcp(leases, known_devices))
|
|
570
|
+
return 0
|
|
571
|
+
except Exception as e:
|
|
572
|
+
return _handle_error(e)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def cmd_routes(client: RouterClient) -> int:
|
|
576
|
+
"""Execute routes command."""
|
|
577
|
+
try:
|
|
578
|
+
with spinner("Fetching routing table..."):
|
|
579
|
+
routes = client.get_routes()
|
|
580
|
+
print(format_routes(routes))
|
|
581
|
+
return 0
|
|
582
|
+
except Exception as e:
|
|
583
|
+
return _handle_error(e)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def cmd_stats(client: RouterClient) -> int:
|
|
587
|
+
"""Execute stats command."""
|
|
588
|
+
try:
|
|
589
|
+
with spinner("Fetching network statistics..."):
|
|
590
|
+
stats = client.get_statistics()
|
|
591
|
+
print(format_stats(stats))
|
|
592
|
+
return 0
|
|
593
|
+
except Exception as e:
|
|
594
|
+
return _handle_error(e)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def cmd_logs(
|
|
598
|
+
client: RouterClient, tail: int | None = None, level: str | None = None
|
|
599
|
+
) -> int:
|
|
600
|
+
"""Execute logs command."""
|
|
601
|
+
try:
|
|
602
|
+
with spinner("Fetching system logs..."):
|
|
603
|
+
logs = client.get_logs()
|
|
604
|
+
|
|
605
|
+
# Filter by severity level if specified
|
|
606
|
+
if level:
|
|
607
|
+
level_upper = level.upper()
|
|
608
|
+
logs = [log for log in logs if log.severity.upper() == level_upper]
|
|
609
|
+
|
|
610
|
+
# Limit to last N entries if tail specified
|
|
611
|
+
if tail and tail > 0:
|
|
612
|
+
logs = logs[-tail:]
|
|
613
|
+
|
|
614
|
+
print(format_logs(logs))
|
|
615
|
+
return 0
|
|
616
|
+
except Exception as e:
|
|
617
|
+
return _handle_error(e)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def cmd_overview(client: RouterClient, known_devices: dict[str, str]) -> int:
|
|
621
|
+
"""Execute overview command."""
|
|
622
|
+
try:
|
|
623
|
+
with spinner("Fetching router overview..."):
|
|
624
|
+
status = client.get_status()
|
|
625
|
+
clients = client.get_wireless_clients()
|
|
626
|
+
leases = client.get_dhcp_leases()
|
|
627
|
+
stats = client.get_statistics()
|
|
628
|
+
print(format_overview(status, clients, leases, stats, known_devices))
|
|
629
|
+
return 0
|
|
630
|
+
except Exception as e:
|
|
631
|
+
return _handle_error(e)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def main() -> int:
|
|
635
|
+
"""Main entry point."""
|
|
636
|
+
parser = argparse.ArgumentParser(
|
|
637
|
+
prog="router", description="Manage D-Link DSL-2750U router"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
641
|
+
|
|
642
|
+
subparsers.add_parser("status", help="Display router status")
|
|
643
|
+
subparsers.add_parser("clients", help="List connected wireless clients")
|
|
644
|
+
subparsers.add_parser("dhcp", help="Show DHCP leases")
|
|
645
|
+
subparsers.add_parser("routes", help="Show routing table")
|
|
646
|
+
subparsers.add_parser("stats", help="Show network and ADSL statistics")
|
|
647
|
+
|
|
648
|
+
logs_parser = subparsers.add_parser("logs", help="Show system logs")
|
|
649
|
+
logs_parser.add_argument(
|
|
650
|
+
"--tail", "-n", type=int, metavar="N", help="Show only the last N log entries"
|
|
651
|
+
)
|
|
652
|
+
logs_parser.add_argument(
|
|
653
|
+
"--level", "-l", type=str, metavar="LEVEL", help="Filter by severity level"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
subparsers.add_parser("overview", help="Show quick dashboard with highlights")
|
|
657
|
+
subparsers.add_parser("reboot", help="Reboot the router")
|
|
658
|
+
|
|
659
|
+
args = parser.parse_args()
|
|
660
|
+
|
|
661
|
+
if not args.command:
|
|
662
|
+
parser.print_help()
|
|
663
|
+
return 0
|
|
664
|
+
|
|
665
|
+
# Load configuration
|
|
666
|
+
try:
|
|
667
|
+
config = load_config()
|
|
668
|
+
except FileNotFoundError as e:
|
|
669
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
670
|
+
return 1
|
|
671
|
+
|
|
672
|
+
# Create client
|
|
673
|
+
client = RouterClient(
|
|
674
|
+
ip=config.get("ip", "192.168.1.1"),
|
|
675
|
+
username=config.get("username", "admin"),
|
|
676
|
+
password=config.get("password", ""),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# Load known devices for colorization
|
|
680
|
+
known_devices = load_known_devices()
|
|
681
|
+
|
|
682
|
+
# Execute command
|
|
683
|
+
if args.command == "status":
|
|
684
|
+
return cmd_status(client)
|
|
685
|
+
elif args.command == "clients":
|
|
686
|
+
return cmd_clients(client, known_devices)
|
|
687
|
+
elif args.command == "dhcp":
|
|
688
|
+
return cmd_dhcp(client, known_devices)
|
|
689
|
+
elif args.command == "routes":
|
|
690
|
+
return cmd_routes(client)
|
|
691
|
+
elif args.command == "stats":
|
|
692
|
+
return cmd_stats(client)
|
|
693
|
+
elif args.command == "logs":
|
|
694
|
+
return cmd_logs(client, tail=args.tail, level=args.level)
|
|
695
|
+
elif args.command == "overview":
|
|
696
|
+
return cmd_overview(client, known_devices)
|
|
697
|
+
elif args.command == "reboot":
|
|
698
|
+
return cmd_reboot(client)
|
|
699
|
+
|
|
700
|
+
return 0
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
if __name__ == "__main__":
|
|
704
|
+
sys.exit(main())
|
router_cli/py.typed
ADDED
|
File without changes
|