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