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/__init__.py
CHANGED
router_cli/client.py
CHANGED
|
@@ -5,151 +5,49 @@ import time
|
|
|
5
5
|
import urllib.error
|
|
6
6
|
import urllib.parse
|
|
7
7
|
import urllib.request
|
|
8
|
-
from dataclasses import dataclass, field
|
|
9
8
|
from http.cookiejar import CookieJar
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# WAN Connections
|
|
53
|
-
wan_connections: list[dict] = field(default_factory=list)
|
|
54
|
-
|
|
55
|
-
# Wireless Info
|
|
56
|
-
ssid: str = ""
|
|
57
|
-
wireless_mac: str = ""
|
|
58
|
-
wireless_status: str = ""
|
|
59
|
-
security_mode: str = ""
|
|
60
|
-
|
|
61
|
-
# Local Network
|
|
62
|
-
local_mac: str = ""
|
|
63
|
-
local_ip: str = ""
|
|
64
|
-
subnet_mask: str = ""
|
|
65
|
-
dhcp_server: str = ""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@dataclass
|
|
69
|
-
class WirelessClient:
|
|
70
|
-
"""A connected wireless client."""
|
|
71
|
-
|
|
72
|
-
mac: str
|
|
73
|
-
associated: bool
|
|
74
|
-
authorized: bool
|
|
75
|
-
ssid: str
|
|
76
|
-
interface: str
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@dataclass
|
|
80
|
-
class DHCPLease:
|
|
81
|
-
"""A DHCP lease entry."""
|
|
82
|
-
|
|
83
|
-
hostname: str
|
|
84
|
-
mac: str
|
|
85
|
-
ip: str
|
|
86
|
-
expires_in: str
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@dataclass
|
|
90
|
-
class Route:
|
|
91
|
-
"""A routing table entry."""
|
|
92
|
-
|
|
93
|
-
destination: str
|
|
94
|
-
gateway: str
|
|
95
|
-
subnet_mask: str
|
|
96
|
-
flag: str
|
|
97
|
-
metric: int
|
|
98
|
-
service: str
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
@dataclass
|
|
102
|
-
class InterfaceStats:
|
|
103
|
-
"""Statistics for a network interface."""
|
|
104
|
-
|
|
105
|
-
interface: str
|
|
106
|
-
rx_bytes: int
|
|
107
|
-
rx_packets: int
|
|
108
|
-
rx_errors: int
|
|
109
|
-
rx_drops: int
|
|
110
|
-
tx_bytes: int
|
|
111
|
-
tx_packets: int
|
|
112
|
-
tx_errors: int
|
|
113
|
-
tx_drops: int
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
@dataclass
|
|
117
|
-
class ADSLStats:
|
|
118
|
-
"""ADSL line statistics."""
|
|
119
|
-
|
|
120
|
-
mode: str = ""
|
|
121
|
-
traffic_type: str = ""
|
|
122
|
-
status: str = ""
|
|
123
|
-
link_power_state: str = ""
|
|
124
|
-
downstream_rate: int = 0
|
|
125
|
-
upstream_rate: int = 0
|
|
126
|
-
downstream_snr_margin: float = 0.0
|
|
127
|
-
upstream_snr_margin: float = 0.0
|
|
128
|
-
downstream_attenuation: float = 0.0
|
|
129
|
-
upstream_attenuation: float = 0.0
|
|
130
|
-
downstream_output_power: float = 0.0
|
|
131
|
-
upstream_output_power: float = 0.0
|
|
132
|
-
downstream_attainable_rate: int = 0
|
|
133
|
-
upstream_attainable_rate: int = 0
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@dataclass
|
|
137
|
-
class Statistics:
|
|
138
|
-
"""Network and ADSL statistics."""
|
|
139
|
-
|
|
140
|
-
lan_interfaces: list[InterfaceStats] = field(default_factory=list)
|
|
141
|
-
wan_interfaces: list[InterfaceStats] = field(default_factory=list)
|
|
142
|
-
adsl: ADSLStats = field(default_factory=ADSLStats)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
@dataclass
|
|
146
|
-
class LogEntry:
|
|
147
|
-
"""A system log entry."""
|
|
148
|
-
|
|
149
|
-
datetime: str
|
|
150
|
-
facility: str
|
|
151
|
-
severity: str
|
|
152
|
-
message: str
|
|
10
|
+
from .models import (
|
|
11
|
+
ADSLStats,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ConnectionError,
|
|
14
|
+
DHCPLease,
|
|
15
|
+
HTTPError,
|
|
16
|
+
InterfaceStats,
|
|
17
|
+
LogEntry,
|
|
18
|
+
Route,
|
|
19
|
+
RouterError,
|
|
20
|
+
RouterStatus,
|
|
21
|
+
Statistics,
|
|
22
|
+
WANConnection,
|
|
23
|
+
WirelessClient,
|
|
24
|
+
)
|
|
25
|
+
from .parser import (
|
|
26
|
+
parse_dhcp_leases,
|
|
27
|
+
parse_logs,
|
|
28
|
+
parse_routes,
|
|
29
|
+
parse_statistics,
|
|
30
|
+
parse_status,
|
|
31
|
+
parse_wireless_clients,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Re-export models for backward compatibility
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ADSLStats",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"ConnectionError",
|
|
39
|
+
"DHCPLease",
|
|
40
|
+
"HTTPError",
|
|
41
|
+
"InterfaceStats",
|
|
42
|
+
"LogEntry",
|
|
43
|
+
"Route",
|
|
44
|
+
"RouterClient",
|
|
45
|
+
"RouterError",
|
|
46
|
+
"RouterStatus",
|
|
47
|
+
"Statistics",
|
|
48
|
+
"WANConnection",
|
|
49
|
+
"WirelessClient",
|
|
50
|
+
]
|
|
153
51
|
|
|
154
52
|
|
|
155
53
|
class RouterClient:
|
|
@@ -373,149 +271,7 @@ class RouterClient:
|
|
|
373
271
|
def get_status(self) -> RouterStatus:
|
|
374
272
|
"""Fetch and parse router status."""
|
|
375
273
|
html = self.fetch_page("/info.html")
|
|
376
|
-
return
|
|
377
|
-
|
|
378
|
-
def _parse_status(self, html: str) -> RouterStatus:
|
|
379
|
-
"""Parse status information from HTML."""
|
|
380
|
-
status = RouterStatus()
|
|
381
|
-
|
|
382
|
-
# System Info - from JavaScript variables or table cells
|
|
383
|
-
status.model_name = self._extract_value(
|
|
384
|
-
html,
|
|
385
|
-
r"var\s+modeName\s*=\s*[\"']([^\"']+)[\"']",
|
|
386
|
-
r"Model Name:.*?<td[^>]*>([^<]+)</td>",
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
status.time_date = self._extract_value(
|
|
390
|
-
html,
|
|
391
|
-
# From document.writeln() in JS
|
|
392
|
-
r"Time and Date:.*?<td>([^<]+)</td>",
|
|
393
|
-
# Static HTML fallback
|
|
394
|
-
r"Time and Date:.*?<td[^>]*>([^<]+)</td>",
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
status.firmware = self._extract_value(
|
|
398
|
-
html,
|
|
399
|
-
r"Firmware Version:\s*([A-Z0-9_.]+)",
|
|
400
|
-
r"<td[^>]*>Firmware Version:</td>\s*<td[^>]*>([^<]+)</td>",
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
# Internet Info - parse from JS variables first, then static HTML
|
|
404
|
-
status.default_gateway = self._extract_value(
|
|
405
|
-
html,
|
|
406
|
-
r"var\s+dfltGw\s*=\s*[\"']([^\"']+)[\"']",
|
|
407
|
-
r"Default Gateway:.*?<td[^>]*>([^<]+)</td>",
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
status.preferred_dns = self._extract_value(
|
|
411
|
-
html, r"Preferred DNS Server:.*?<td[^>]*>([^<]+)</td>"
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
status.alternate_dns = self._extract_value(
|
|
415
|
-
html, r"Alternate DNS Server:.*?<td[^>]*>([^<]+)</td>"
|
|
416
|
-
)
|
|
417
|
-
|
|
418
|
-
# WAN Connections - parse table rows with class="hd"
|
|
419
|
-
status.wan_connections = self._parse_wan_connections(html)
|
|
420
|
-
|
|
421
|
-
# Wireless Info - find section and extract all values
|
|
422
|
-
wireless_section = re.search(
|
|
423
|
-
r"Wireless Info:.*?Local Network Info", html, re.DOTALL
|
|
424
|
-
)
|
|
425
|
-
if wireless_section:
|
|
426
|
-
ws = wireless_section.group(0)
|
|
427
|
-
status.ssid = self._extract_value(
|
|
428
|
-
ws,
|
|
429
|
-
r"<option[^>]*selected[^>]*>\s*([^<\n]+?)\s*</option>",
|
|
430
|
-
)
|
|
431
|
-
status.wireless_mac = self._extract_value(
|
|
432
|
-
ws, r"MAC Address:.*?<td[^>]*>([^<]+)</td>"
|
|
433
|
-
)
|
|
434
|
-
status.wireless_status = self._extract_value(
|
|
435
|
-
ws, r"Status:.*?<td[^>]*>([^<]+)</td>"
|
|
436
|
-
)
|
|
437
|
-
status.security_mode = self._extract_value(
|
|
438
|
-
ws, r"Security Mode:.*?<td[^>]*>([^<]+)</td>"
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
# Local Network Info - find section after "Local Network Info"
|
|
442
|
-
local_section = re.search(
|
|
443
|
-
r"Local Network Info.*?(?:Storage Device|$)", html, re.DOTALL
|
|
444
|
-
)
|
|
445
|
-
if local_section:
|
|
446
|
-
ls = local_section.group(0)
|
|
447
|
-
# MAC address may have malformed HTML (</SPAN> without opening tag)
|
|
448
|
-
status.local_mac = self._extract_value(
|
|
449
|
-
ls,
|
|
450
|
-
r"MAC Address:</TD>\s*<TD>([^<]+)",
|
|
451
|
-
r"MAC Address:.*?<td[^>]*>([^<]+)</td>",
|
|
452
|
-
)
|
|
453
|
-
status.local_ip = self._extract_value(
|
|
454
|
-
ls, r"IP Address:.*?<td[^>]*>([^<]+)</td>"
|
|
455
|
-
)
|
|
456
|
-
status.subnet_mask = self._extract_value(
|
|
457
|
-
ls, r"Subnet Mask:.*?<td[^>]*>([^<]+)</td>"
|
|
458
|
-
)
|
|
459
|
-
# DHCP may be in document.writeln() or static HTML
|
|
460
|
-
status.dhcp_server = self._extract_value(
|
|
461
|
-
ls,
|
|
462
|
-
r"DHCP Server:.*?<td>([^<]+)</td>",
|
|
463
|
-
r"DHCP Server:.*?<td[^>]*>([^<]+)</td>",
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
return status
|
|
467
|
-
|
|
468
|
-
def _extract_value(self, html: str, *patterns: str) -> str:
|
|
469
|
-
"""Try multiple regex patterns and return first match."""
|
|
470
|
-
for pattern in patterns:
|
|
471
|
-
match = re.search(pattern, html, re.DOTALL | re.IGNORECASE)
|
|
472
|
-
if match:
|
|
473
|
-
value = match.group(1).strip()
|
|
474
|
-
# Clean up HTML entities
|
|
475
|
-
value = value.replace(" ", "").strip()
|
|
476
|
-
if value:
|
|
477
|
-
return value
|
|
478
|
-
return "N/A"
|
|
479
|
-
|
|
480
|
-
def _parse_wan_connections(self, html: str) -> list[dict]:
|
|
481
|
-
"""Parse WAN connection table."""
|
|
482
|
-
connections = []
|
|
483
|
-
|
|
484
|
-
# Find the WAN connections table section
|
|
485
|
-
wan_section = re.search(
|
|
486
|
-
r"Enabled WAN Connections:.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
487
|
-
)
|
|
488
|
-
if not wan_section:
|
|
489
|
-
return connections
|
|
490
|
-
|
|
491
|
-
section = wan_section.group(0)
|
|
492
|
-
|
|
493
|
-
# Find data rows - handle both single and double quotes
|
|
494
|
-
# Pattern matches: <tr align='center'> or <tr align="center">
|
|
495
|
-
rows = re.findall(
|
|
496
|
-
r"<tr[^>]*align=[\"']center[\"'][^>]*>(.*?)</tr>",
|
|
497
|
-
section,
|
|
498
|
-
re.DOTALL | re.IGNORECASE,
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
for row in rows:
|
|
502
|
-
# Match cells with class='hd' or class="hd"
|
|
503
|
-
cells = re.findall(
|
|
504
|
-
r"<td[^>]*class=[\"']hd[\"'][^>]*>([^<]*)</td>",
|
|
505
|
-
row,
|
|
506
|
-
re.IGNORECASE,
|
|
507
|
-
)
|
|
508
|
-
if len(cells) >= 4:
|
|
509
|
-
connections.append(
|
|
510
|
-
{
|
|
511
|
-
"interface": cells[0].strip(),
|
|
512
|
-
"description": cells[1].strip(),
|
|
513
|
-
"status": cells[2].strip(),
|
|
514
|
-
"ipv4": cells[3].strip(),
|
|
515
|
-
}
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
return connections
|
|
274
|
+
return parse_status(html)
|
|
519
275
|
|
|
520
276
|
def reboot(self) -> bool:
|
|
521
277
|
"""Reboot the router."""
|
|
@@ -541,343 +297,24 @@ class RouterClient:
|
|
|
541
297
|
def get_wireless_clients(self) -> list[WirelessClient]:
|
|
542
298
|
"""Fetch and parse wireless clients."""
|
|
543
299
|
html = self.fetch_page("/wlstationlist.cmd")
|
|
544
|
-
return
|
|
545
|
-
|
|
546
|
-
def _parse_wireless_clients(self, html: str) -> list[WirelessClient]:
|
|
547
|
-
"""Parse wireless clients from HTML."""
|
|
548
|
-
clients = []
|
|
549
|
-
|
|
550
|
-
# Find all data rows in the table
|
|
551
|
-
rows = re.findall(
|
|
552
|
-
r"<tr>\s*<td><p align=center>\s*([A-Fa-f0-9:]+)\s*"
|
|
553
|
-
r".*?<p align=center>\s*(Yes|No)\s*</p>.*?"
|
|
554
|
-
r"<p align=center>\s*(Yes|No)\s*</p>.*?"
|
|
555
|
-
r"<p align=center>\s*([^<&]+?)(?: )?\s*</td>.*?"
|
|
556
|
-
r"<p align=center>\s*([^<&]+?)(?: )?\s*</td>",
|
|
557
|
-
html,
|
|
558
|
-
re.DOTALL | re.IGNORECASE,
|
|
559
|
-
)
|
|
560
|
-
|
|
561
|
-
for row in rows:
|
|
562
|
-
mac, associated, authorized, ssid, interface = row
|
|
563
|
-
clients.append(
|
|
564
|
-
WirelessClient(
|
|
565
|
-
mac=mac.strip(),
|
|
566
|
-
associated=associated.lower() == "yes",
|
|
567
|
-
authorized=authorized.lower() == "yes",
|
|
568
|
-
ssid=ssid.strip(),
|
|
569
|
-
interface=interface.strip(),
|
|
570
|
-
)
|
|
571
|
-
)
|
|
572
|
-
|
|
573
|
-
return clients
|
|
300
|
+
return parse_wireless_clients(html)
|
|
574
301
|
|
|
575
302
|
def get_dhcp_leases(self) -> list[DHCPLease]:
|
|
576
303
|
"""Fetch and parse DHCP leases."""
|
|
577
304
|
html = self.fetch_page("/dhcpinfo.html")
|
|
578
|
-
return
|
|
579
|
-
|
|
580
|
-
def _parse_dhcp_leases(self, html: str) -> list[DHCPLease]:
|
|
581
|
-
"""Parse DHCP leases from HTML."""
|
|
582
|
-
leases = []
|
|
583
|
-
|
|
584
|
-
# Find the DHCP table section
|
|
585
|
-
table_match = re.search(
|
|
586
|
-
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
587
|
-
)
|
|
588
|
-
if not table_match:
|
|
589
|
-
return leases
|
|
590
|
-
|
|
591
|
-
table = table_match.group(0)
|
|
592
|
-
|
|
593
|
-
# Find data rows (skip header row)
|
|
594
|
-
rows = re.findall(
|
|
595
|
-
r"<tr><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td></tr>",
|
|
596
|
-
table,
|
|
597
|
-
re.IGNORECASE,
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
for row in rows:
|
|
601
|
-
hostname, mac, ip, expires = row
|
|
602
|
-
leases.append(
|
|
603
|
-
DHCPLease(
|
|
604
|
-
hostname=hostname.strip(),
|
|
605
|
-
mac=mac.strip(),
|
|
606
|
-
ip=ip.strip(),
|
|
607
|
-
expires_in=expires.strip(),
|
|
608
|
-
)
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
return leases
|
|
305
|
+
return parse_dhcp_leases(html)
|
|
612
306
|
|
|
613
307
|
def get_routes(self) -> list[Route]:
|
|
614
308
|
"""Fetch and parse routing table."""
|
|
615
309
|
html = self.fetch_page("/rtroutecfg.cmd?action=dlinkau")
|
|
616
|
-
return
|
|
617
|
-
|
|
618
|
-
def _parse_routes(self, html: str) -> list[Route]:
|
|
619
|
-
"""Parse routing table from HTML."""
|
|
620
|
-
routes = []
|
|
621
|
-
|
|
622
|
-
# Find the routing table
|
|
623
|
-
table_match = re.search(
|
|
624
|
-
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
625
|
-
)
|
|
626
|
-
if not table_match:
|
|
627
|
-
return routes
|
|
628
|
-
|
|
629
|
-
table = table_match.group(0)
|
|
630
|
-
|
|
631
|
-
# Find data rows - 6 cells per row
|
|
632
|
-
rows = re.findall(
|
|
633
|
-
r"<tr>\s*"
|
|
634
|
-
r"<td>([^<]*)</td>\s*"
|
|
635
|
-
r"<td>([^<]*)</td>\s*"
|
|
636
|
-
r"<td>([^<]*)</td>\s*"
|
|
637
|
-
r"<td>([^<]*)</td>\s*"
|
|
638
|
-
r"<td>([^<]*)</td>\s*"
|
|
639
|
-
r"<td>([^<]*)</td>\s*"
|
|
640
|
-
r"</tr>",
|
|
641
|
-
table,
|
|
642
|
-
re.IGNORECASE,
|
|
643
|
-
)
|
|
644
|
-
|
|
645
|
-
for row in rows:
|
|
646
|
-
dest, gw, mask, flag, metric, service = row
|
|
647
|
-
# Skip header row
|
|
648
|
-
if "Destination" in dest:
|
|
649
|
-
continue
|
|
650
|
-
routes.append(
|
|
651
|
-
Route(
|
|
652
|
-
destination=dest.strip(),
|
|
653
|
-
gateway=gw.strip(),
|
|
654
|
-
subnet_mask=mask.strip(),
|
|
655
|
-
flag=flag.strip(),
|
|
656
|
-
metric=int(metric.strip()) if metric.strip().isdigit() else 0,
|
|
657
|
-
service=service.replace(" ", "").strip(),
|
|
658
|
-
)
|
|
659
|
-
)
|
|
660
|
-
|
|
661
|
-
return routes
|
|
310
|
+
return parse_routes(html)
|
|
662
311
|
|
|
663
312
|
def get_statistics(self) -> Statistics:
|
|
664
313
|
"""Fetch and parse network statistics."""
|
|
665
314
|
html = self.fetch_page("/statsifcwanber.html")
|
|
666
|
-
return
|
|
667
|
-
|
|
668
|
-
def _parse_statistics(self, html: str) -> Statistics:
|
|
669
|
-
"""Parse statistics from HTML."""
|
|
670
|
-
stats = Statistics()
|
|
671
|
-
|
|
672
|
-
# Parse LAN interface stats - look for rows with 9 cells
|
|
673
|
-
lan_section = re.search(
|
|
674
|
-
r"Local Network.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
675
|
-
)
|
|
676
|
-
if lan_section:
|
|
677
|
-
stats.lan_interfaces = self._parse_interface_stats(lan_section.group(0))
|
|
678
|
-
|
|
679
|
-
# Parse WAN interface stats
|
|
680
|
-
wan_section = re.search(
|
|
681
|
-
r"<td class=topheader>\s*Internet\s*</td>.*?</table>",
|
|
682
|
-
html,
|
|
683
|
-
re.DOTALL | re.IGNORECASE,
|
|
684
|
-
)
|
|
685
|
-
if wan_section:
|
|
686
|
-
stats.wan_interfaces = self._parse_wan_interface_stats(wan_section.group(0))
|
|
687
|
-
|
|
688
|
-
# Parse ADSL stats
|
|
689
|
-
stats.adsl = self._parse_adsl_stats(html)
|
|
690
|
-
|
|
691
|
-
return stats
|
|
692
|
-
|
|
693
|
-
def _parse_interface_stats(self, html: str) -> list[InterfaceStats]:
|
|
694
|
-
"""Parse interface statistics table."""
|
|
695
|
-
interfaces = []
|
|
696
|
-
|
|
697
|
-
# Find rows with 9 numeric values
|
|
698
|
-
rows = re.findall(
|
|
699
|
-
r"<tr>\s*<td class='hd'>.*?</script>\s*</td>\s*"
|
|
700
|
-
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
|
|
701
|
-
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
|
|
702
|
-
html,
|
|
703
|
-
re.DOTALL | re.IGNORECASE,
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
# Extract interface names from script blocks
|
|
707
|
-
intf_names = re.findall(
|
|
708
|
-
r"brdIntf\s*=\s*['\"]([^'\"]+)['\"]", html, re.IGNORECASE
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
for i, row in enumerate(rows):
|
|
712
|
-
intf_name = (
|
|
713
|
-
intf_names[i].split("|")[-1] if i < len(intf_names) else f"eth{i}"
|
|
714
|
-
)
|
|
715
|
-
(
|
|
716
|
-
rx_bytes,
|
|
717
|
-
rx_pkts,
|
|
718
|
-
rx_errs,
|
|
719
|
-
rx_drops,
|
|
720
|
-
tx_bytes,
|
|
721
|
-
tx_pkts,
|
|
722
|
-
tx_errs,
|
|
723
|
-
tx_drops,
|
|
724
|
-
) = row
|
|
725
|
-
interfaces.append(
|
|
726
|
-
InterfaceStats(
|
|
727
|
-
interface=intf_name,
|
|
728
|
-
rx_bytes=int(rx_bytes),
|
|
729
|
-
rx_packets=int(rx_pkts),
|
|
730
|
-
rx_errors=int(rx_errs),
|
|
731
|
-
rx_drops=int(rx_drops),
|
|
732
|
-
tx_bytes=int(tx_bytes),
|
|
733
|
-
tx_packets=int(tx_pkts),
|
|
734
|
-
tx_errors=int(tx_errs),
|
|
735
|
-
tx_drops=int(tx_drops),
|
|
736
|
-
)
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
return interfaces
|
|
740
|
-
|
|
741
|
-
def _parse_wan_interface_stats(self, html: str) -> list[InterfaceStats]:
|
|
742
|
-
"""Parse WAN interface statistics table."""
|
|
743
|
-
interfaces = []
|
|
744
|
-
|
|
745
|
-
# Find rows with interface name, description, and 8 numeric values
|
|
746
|
-
rows = re.findall(
|
|
747
|
-
r"<tr>\s*<td class='hd'>([^<]+)</td>\s*"
|
|
748
|
-
r"<td>([^<]+)</td>\s*"
|
|
749
|
-
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
|
|
750
|
-
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
|
|
751
|
-
html,
|
|
752
|
-
re.DOTALL | re.IGNORECASE,
|
|
753
|
-
)
|
|
754
|
-
|
|
755
|
-
for row in rows:
|
|
756
|
-
(
|
|
757
|
-
intf_name,
|
|
758
|
-
_desc,
|
|
759
|
-
rx_bytes,
|
|
760
|
-
rx_pkts,
|
|
761
|
-
rx_errs,
|
|
762
|
-
rx_drops,
|
|
763
|
-
tx_bytes,
|
|
764
|
-
tx_pkts,
|
|
765
|
-
tx_errs,
|
|
766
|
-
tx_drops,
|
|
767
|
-
) = row
|
|
768
|
-
interfaces.append(
|
|
769
|
-
InterfaceStats(
|
|
770
|
-
interface=intf_name.strip(),
|
|
771
|
-
rx_bytes=int(rx_bytes),
|
|
772
|
-
rx_packets=int(rx_pkts),
|
|
773
|
-
rx_errors=int(rx_errs),
|
|
774
|
-
rx_drops=int(rx_drops),
|
|
775
|
-
tx_bytes=int(tx_bytes),
|
|
776
|
-
tx_packets=int(tx_pkts),
|
|
777
|
-
tx_errors=int(tx_errs),
|
|
778
|
-
tx_drops=int(tx_drops),
|
|
779
|
-
)
|
|
780
|
-
)
|
|
781
|
-
|
|
782
|
-
return interfaces
|
|
783
|
-
|
|
784
|
-
def _parse_adsl_stats(self, html: str) -> ADSLStats:
|
|
785
|
-
"""Parse ADSL statistics from HTML."""
|
|
786
|
-
adsl = ADSLStats()
|
|
787
|
-
|
|
788
|
-
adsl.mode = self._extract_value(html, r"Mode:</td><td>([^<]+)</td>")
|
|
789
|
-
adsl.traffic_type = self._extract_value(
|
|
790
|
-
html, r"Traffic Type:</td><td>([^<]+)</td>"
|
|
791
|
-
)
|
|
792
|
-
adsl.status = self._extract_value(html, r"Status:</td><td>([^<]+)</td>")
|
|
793
|
-
adsl.link_power_state = self._extract_value(
|
|
794
|
-
html, r"Link Power State:</td><td>([^<]+)</td>"
|
|
795
|
-
)
|
|
796
|
-
|
|
797
|
-
# Parse rate info - downstream and upstream
|
|
798
|
-
rate_match = re.search(
|
|
799
|
-
r"Rate \(Kbps\):</td><td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
800
|
-
)
|
|
801
|
-
if rate_match:
|
|
802
|
-
adsl.downstream_rate = int(rate_match.group(1))
|
|
803
|
-
adsl.upstream_rate = int(rate_match.group(2))
|
|
804
|
-
|
|
805
|
-
# Parse SNR margin
|
|
806
|
-
snr_match = re.search(
|
|
807
|
-
r"SNR Margin.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
808
|
-
)
|
|
809
|
-
if snr_match:
|
|
810
|
-
adsl.downstream_snr_margin = float(snr_match.group(1)) / 10
|
|
811
|
-
adsl.upstream_snr_margin = float(snr_match.group(2)) / 10
|
|
812
|
-
|
|
813
|
-
# Parse attenuation
|
|
814
|
-
atten_match = re.search(
|
|
815
|
-
r"Attenuation.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
816
|
-
)
|
|
817
|
-
if atten_match:
|
|
818
|
-
adsl.downstream_attenuation = float(atten_match.group(1)) / 10
|
|
819
|
-
adsl.upstream_attenuation = float(atten_match.group(2)) / 10
|
|
820
|
-
|
|
821
|
-
# Parse output power
|
|
822
|
-
power_match = re.search(
|
|
823
|
-
r"Output Power.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
824
|
-
)
|
|
825
|
-
if power_match:
|
|
826
|
-
adsl.downstream_output_power = float(power_match.group(1)) / 10
|
|
827
|
-
adsl.upstream_output_power = float(power_match.group(2)) / 10
|
|
828
|
-
|
|
829
|
-
# Parse attainable rate
|
|
830
|
-
attain_match = re.search(
|
|
831
|
-
r"Attainable Rate.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
832
|
-
)
|
|
833
|
-
if attain_match:
|
|
834
|
-
adsl.downstream_attainable_rate = int(attain_match.group(1))
|
|
835
|
-
adsl.upstream_attainable_rate = int(attain_match.group(2))
|
|
836
|
-
|
|
837
|
-
return adsl
|
|
315
|
+
return parse_statistics(html)
|
|
838
316
|
|
|
839
317
|
def get_logs(self) -> list[LogEntry]:
|
|
840
318
|
"""Fetch and parse system logs."""
|
|
841
319
|
html = self.fetch_page("/logview.cmd")
|
|
842
|
-
return
|
|
843
|
-
|
|
844
|
-
def _parse_logs(self, html: str) -> list[LogEntry]:
|
|
845
|
-
"""Parse system logs from HTML."""
|
|
846
|
-
logs = []
|
|
847
|
-
|
|
848
|
-
# Find the log table
|
|
849
|
-
table_match = re.search(
|
|
850
|
-
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
851
|
-
)
|
|
852
|
-
if not table_match:
|
|
853
|
-
return logs
|
|
854
|
-
|
|
855
|
-
table = table_match.group(0)
|
|
856
|
-
|
|
857
|
-
# Find data rows - 4 cells per row
|
|
858
|
-
rows = re.findall(
|
|
859
|
-
r"<tr>\s*"
|
|
860
|
-
r"<td[^>]*>([^<]*)</td>\s*"
|
|
861
|
-
r"<td[^>]*>([^<]*)</td>\s*"
|
|
862
|
-
r"<td[^>]*>([^<]*)</td>\s*"
|
|
863
|
-
r"<td[^>]*>([^<]*)</td>\s*"
|
|
864
|
-
r"</tr>",
|
|
865
|
-
table,
|
|
866
|
-
re.IGNORECASE,
|
|
867
|
-
)
|
|
868
|
-
|
|
869
|
-
for row in rows:
|
|
870
|
-
datetime_str, facility, severity, message = row
|
|
871
|
-
# Skip header row
|
|
872
|
-
if "Date/Time" in datetime_str:
|
|
873
|
-
continue
|
|
874
|
-
logs.append(
|
|
875
|
-
LogEntry(
|
|
876
|
-
datetime=datetime_str.strip(),
|
|
877
|
-
facility=facility.strip(),
|
|
878
|
-
severity=severity.strip(),
|
|
879
|
-
message=message.strip(),
|
|
880
|
-
)
|
|
881
|
-
)
|
|
882
|
-
|
|
883
|
-
return logs
|
|
320
|
+
return parse_logs(html)
|