router-cli 0.1.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 +158 -28
- router_cli/display.py +191 -0
- router_cli/formatters.py +368 -0
- router_cli/main.py +49 -635
- router_cli/models.py +165 -0
- router_cli/parser.py +521 -0
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/METADATA +48 -6
- router_cli-0.3.0.dist-info/RECORD +14 -0
- router_cli-0.1.0.dist-info/RECORD +0 -9
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/WHEEL +0 -0
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/entry_points.txt +0 -0
router_cli/models.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Data models for router CLI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RouterError(Exception):
|
|
7
|
+
"""Base exception for router errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(RouterError):
|
|
13
|
+
"""Raised when authentication fails or session expires."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectionError(RouterError):
|
|
19
|
+
"""Raised when unable to connect to the router."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPError(RouterError):
|
|
25
|
+
"""Raised for HTTP error responses."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class WANConnection:
|
|
34
|
+
"""A WAN connection entry."""
|
|
35
|
+
|
|
36
|
+
interface: str = ""
|
|
37
|
+
description: str = ""
|
|
38
|
+
status: str = ""
|
|
39
|
+
ipv4: str = ""
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
"""Convert to dictionary for backward compatibility."""
|
|
43
|
+
return {
|
|
44
|
+
"interface": self.interface,
|
|
45
|
+
"description": self.description,
|
|
46
|
+
"status": self.status,
|
|
47
|
+
"ipv4": self.ipv4,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RouterStatus:
|
|
53
|
+
"""Parsed router status information."""
|
|
54
|
+
|
|
55
|
+
# System Info
|
|
56
|
+
model_name: str = ""
|
|
57
|
+
time_date: str = ""
|
|
58
|
+
firmware: str = ""
|
|
59
|
+
|
|
60
|
+
# Internet Info
|
|
61
|
+
default_gateway: str = ""
|
|
62
|
+
preferred_dns: str = ""
|
|
63
|
+
alternate_dns: str = ""
|
|
64
|
+
|
|
65
|
+
# WAN Connections
|
|
66
|
+
wan_connections: list[WANConnection] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
# Wireless Info
|
|
69
|
+
ssid: str = ""
|
|
70
|
+
wireless_mac: str = ""
|
|
71
|
+
wireless_status: str = ""
|
|
72
|
+
security_mode: str = ""
|
|
73
|
+
|
|
74
|
+
# Local Network
|
|
75
|
+
local_mac: str = ""
|
|
76
|
+
local_ip: str = ""
|
|
77
|
+
subnet_mask: str = ""
|
|
78
|
+
dhcp_server: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class WirelessClient:
|
|
83
|
+
"""A connected wireless client."""
|
|
84
|
+
|
|
85
|
+
mac: str
|
|
86
|
+
associated: bool
|
|
87
|
+
authorized: bool
|
|
88
|
+
ssid: str
|
|
89
|
+
interface: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class DHCPLease:
|
|
94
|
+
"""A DHCP lease entry."""
|
|
95
|
+
|
|
96
|
+
hostname: str
|
|
97
|
+
mac: str
|
|
98
|
+
ip: str
|
|
99
|
+
expires_in: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Route:
|
|
104
|
+
"""A routing table entry."""
|
|
105
|
+
|
|
106
|
+
destination: str
|
|
107
|
+
gateway: str
|
|
108
|
+
subnet_mask: str
|
|
109
|
+
flag: str
|
|
110
|
+
metric: int
|
|
111
|
+
service: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class InterfaceStats:
|
|
116
|
+
"""Statistics for a network interface."""
|
|
117
|
+
|
|
118
|
+
interface: str
|
|
119
|
+
rx_bytes: int
|
|
120
|
+
rx_packets: int
|
|
121
|
+
rx_errors: int
|
|
122
|
+
rx_drops: int
|
|
123
|
+
tx_bytes: int
|
|
124
|
+
tx_packets: int
|
|
125
|
+
tx_errors: int
|
|
126
|
+
tx_drops: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ADSLStats:
|
|
131
|
+
"""ADSL line statistics."""
|
|
132
|
+
|
|
133
|
+
mode: str = ""
|
|
134
|
+
traffic_type: str = ""
|
|
135
|
+
status: str = ""
|
|
136
|
+
link_power_state: str = ""
|
|
137
|
+
downstream_rate: int = 0
|
|
138
|
+
upstream_rate: int = 0
|
|
139
|
+
downstream_snr_margin: float = 0.0
|
|
140
|
+
upstream_snr_margin: float = 0.0
|
|
141
|
+
downstream_attenuation: float = 0.0
|
|
142
|
+
upstream_attenuation: float = 0.0
|
|
143
|
+
downstream_output_power: float = 0.0
|
|
144
|
+
upstream_output_power: float = 0.0
|
|
145
|
+
downstream_attainable_rate: int = 0
|
|
146
|
+
upstream_attainable_rate: int = 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class Statistics:
|
|
151
|
+
"""Network and ADSL statistics."""
|
|
152
|
+
|
|
153
|
+
lan_interfaces: list[InterfaceStats] = field(default_factory=list)
|
|
154
|
+
wan_interfaces: list[InterfaceStats] = field(default_factory=list)
|
|
155
|
+
adsl: ADSLStats = field(default_factory=ADSLStats)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class LogEntry:
|
|
160
|
+
"""A system log entry."""
|
|
161
|
+
|
|
162
|
+
datetime: str
|
|
163
|
+
facility: str
|
|
164
|
+
severity: str
|
|
165
|
+
message: str
|
router_cli/parser.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""HTML parsing utilities for router responses.
|
|
2
|
+
|
|
3
|
+
The router's HTML is often malformed (unclosed tags, mixed case, etc.).
|
|
4
|
+
These parsers use flexible regex patterns with re.IGNORECASE and re.DOTALL
|
|
5
|
+
to handle the inconsistencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
ADSLStats,
|
|
12
|
+
DHCPLease,
|
|
13
|
+
InterfaceStats,
|
|
14
|
+
LogEntry,
|
|
15
|
+
Route,
|
|
16
|
+
RouterStatus,
|
|
17
|
+
Statistics,
|
|
18
|
+
WANConnection,
|
|
19
|
+
WirelessClient,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_value(html: str, *patterns: str) -> str:
|
|
24
|
+
"""Try multiple regex patterns and return first match.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
html: The HTML content to search
|
|
28
|
+
*patterns: One or more regex patterns to try in order
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The first captured group from the first matching pattern,
|
|
32
|
+
or "N/A" if no pattern matches.
|
|
33
|
+
"""
|
|
34
|
+
for pattern in patterns:
|
|
35
|
+
match = re.search(pattern, html, re.DOTALL | re.IGNORECASE)
|
|
36
|
+
if match:
|
|
37
|
+
value = match.group(1).strip()
|
|
38
|
+
# Clean up HTML entities
|
|
39
|
+
value = value.replace(" ", "").strip()
|
|
40
|
+
if value:
|
|
41
|
+
return value
|
|
42
|
+
return "N/A"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_status(html: str) -> RouterStatus:
|
|
46
|
+
"""Parse status information from HTML.
|
|
47
|
+
|
|
48
|
+
Parses the /info.html page to extract router status including
|
|
49
|
+
system info, internet info, WAN connections, wireless info,
|
|
50
|
+
and local network settings.
|
|
51
|
+
"""
|
|
52
|
+
status = RouterStatus()
|
|
53
|
+
|
|
54
|
+
# System Info - from JavaScript variables or table cells
|
|
55
|
+
status.model_name = extract_value(
|
|
56
|
+
html,
|
|
57
|
+
r"var\s+modeName\s*=\s*[\"']([^\"']+)[\"']",
|
|
58
|
+
r"Model Name:.*?<td[^>]*>([^<]+)</td>",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
status.time_date = extract_value(
|
|
62
|
+
html,
|
|
63
|
+
# From document.writeln() in JS
|
|
64
|
+
r"Time and Date:.*?<td>([^<]+)</td>",
|
|
65
|
+
# Static HTML fallback
|
|
66
|
+
r"Time and Date:.*?<td[^>]*>([^<]+)</td>",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
status.firmware = extract_value(
|
|
70
|
+
html,
|
|
71
|
+
r"Firmware Version:\s*([A-Z0-9_.]+)",
|
|
72
|
+
r"<td[^>]*>Firmware Version:</td>\s*<td[^>]*>([^<]+)</td>",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Internet Info - parse from JS variables first, then static HTML
|
|
76
|
+
status.default_gateway = extract_value(
|
|
77
|
+
html,
|
|
78
|
+
r"var\s+dfltGw\s*=\s*[\"']([^\"']+)[\"']",
|
|
79
|
+
r"Default Gateway:.*?<td[^>]*>([^<]+)</td>",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
status.preferred_dns = extract_value(
|
|
83
|
+
html, r"Preferred DNS Server:.*?<td[^>]*>([^<]+)</td>"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
status.alternate_dns = extract_value(
|
|
87
|
+
html, r"Alternate DNS Server:.*?<td[^>]*>([^<]+)</td>"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# WAN Connections - parse table rows with class="hd"
|
|
91
|
+
status.wan_connections = parse_wan_connections(html)
|
|
92
|
+
|
|
93
|
+
# Wireless Info - find section and extract all values
|
|
94
|
+
wireless_section = re.search(
|
|
95
|
+
r"Wireless Info:.*?Local Network Info", html, re.DOTALL
|
|
96
|
+
)
|
|
97
|
+
if wireless_section:
|
|
98
|
+
ws = wireless_section.group(0)
|
|
99
|
+
status.ssid = extract_value(
|
|
100
|
+
ws,
|
|
101
|
+
r"<option[^>]*selected[^>]*>\s*([^<\n]+?)\s*</option>",
|
|
102
|
+
)
|
|
103
|
+
status.wireless_mac = extract_value(ws, r"MAC Address:.*?<td[^>]*>([^<]+)</td>")
|
|
104
|
+
status.wireless_status = extract_value(ws, r"Status:.*?<td[^>]*>([^<]+)</td>")
|
|
105
|
+
status.security_mode = extract_value(
|
|
106
|
+
ws, r"Security Mode:.*?<td[^>]*>([^<]+)</td>"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Local Network Info - find section after "Local Network Info"
|
|
110
|
+
# Note: MAC address may have malformed HTML (</SPAN> without opening tag)
|
|
111
|
+
local_section = re.search(
|
|
112
|
+
r"Local Network Info.*?(?:Storage Device|$)", html, re.DOTALL
|
|
113
|
+
)
|
|
114
|
+
if local_section:
|
|
115
|
+
ls = local_section.group(0)
|
|
116
|
+
status.local_mac = extract_value(
|
|
117
|
+
ls,
|
|
118
|
+
r"MAC Address:</TD>\s*<TD>([^<]+)",
|
|
119
|
+
r"MAC Address:.*?<td[^>]*>([^<]+)</td>",
|
|
120
|
+
)
|
|
121
|
+
status.local_ip = extract_value(ls, r"IP Address:.*?<td[^>]*>([^<]+)</td>")
|
|
122
|
+
status.subnet_mask = extract_value(ls, r"Subnet Mask:.*?<td[^>]*>([^<]+)</td>")
|
|
123
|
+
# DHCP may be in document.writeln() or static HTML
|
|
124
|
+
status.dhcp_server = extract_value(
|
|
125
|
+
ls,
|
|
126
|
+
r"DHCP Server:.*?<td>([^<]+)</td>",
|
|
127
|
+
r"DHCP Server:.*?<td[^>]*>([^<]+)</td>",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return status
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_wan_connections(html: str) -> list[WANConnection]:
|
|
134
|
+
"""Parse WAN connection table from HTML.
|
|
135
|
+
|
|
136
|
+
Looks for the "Enabled WAN Connections:" table and extracts
|
|
137
|
+
interface, description, status, and IPv4 address from each row.
|
|
138
|
+
"""
|
|
139
|
+
connections = []
|
|
140
|
+
|
|
141
|
+
# Find the WAN connections table section
|
|
142
|
+
wan_section = re.search(
|
|
143
|
+
r"Enabled WAN Connections:.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
144
|
+
)
|
|
145
|
+
if not wan_section:
|
|
146
|
+
return connections
|
|
147
|
+
|
|
148
|
+
section = wan_section.group(0)
|
|
149
|
+
|
|
150
|
+
# Find data rows - handle both single and double quotes
|
|
151
|
+
# Pattern matches: <tr align='center'> or <tr align="center">
|
|
152
|
+
rows = re.findall(
|
|
153
|
+
r"<tr[^>]*align=[\"']center[\"'][^>]*>(.*?)</tr>",
|
|
154
|
+
section,
|
|
155
|
+
re.DOTALL | re.IGNORECASE,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for row in rows:
|
|
159
|
+
# Match cells with class='hd' or class="hd"
|
|
160
|
+
cells = re.findall(
|
|
161
|
+
r"<td[^>]*class=[\"']hd[\"'][^>]*>([^<]*)</td>",
|
|
162
|
+
row,
|
|
163
|
+
re.IGNORECASE,
|
|
164
|
+
)
|
|
165
|
+
if len(cells) >= 4:
|
|
166
|
+
connections.append(
|
|
167
|
+
WANConnection(
|
|
168
|
+
interface=cells[0].strip(),
|
|
169
|
+
description=cells[1].strip(),
|
|
170
|
+
status=cells[2].strip(),
|
|
171
|
+
ipv4=cells[3].strip(),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return connections
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_wireless_clients(html: str) -> list[WirelessClient]:
|
|
179
|
+
"""Parse wireless clients from HTML.
|
|
180
|
+
|
|
181
|
+
Parses the /wlstationlist.cmd page to extract connected
|
|
182
|
+
wireless clients with their MAC, association/authorization status,
|
|
183
|
+
SSID, and interface.
|
|
184
|
+
"""
|
|
185
|
+
clients = []
|
|
186
|
+
|
|
187
|
+
# Find all data rows in the table
|
|
188
|
+
rows = re.findall(
|
|
189
|
+
r"<tr>\s*<td><p align=center>\s*([A-Fa-f0-9:]+)\s*"
|
|
190
|
+
r".*?<p align=center>\s*(Yes|No)\s*</p>.*?"
|
|
191
|
+
r"<p align=center>\s*(Yes|No)\s*</p>.*?"
|
|
192
|
+
r"<p align=center>\s*([^<&]+?)(?: )?\s*</td>.*?"
|
|
193
|
+
r"<p align=center>\s*([^<&]+?)(?: )?\s*</td>",
|
|
194
|
+
html,
|
|
195
|
+
re.DOTALL | re.IGNORECASE,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
for row in rows:
|
|
199
|
+
mac, associated, authorized, ssid, interface = row
|
|
200
|
+
clients.append(
|
|
201
|
+
WirelessClient(
|
|
202
|
+
mac=mac.strip(),
|
|
203
|
+
associated=associated.lower() == "yes",
|
|
204
|
+
authorized=authorized.lower() == "yes",
|
|
205
|
+
ssid=ssid.strip(),
|
|
206
|
+
interface=interface.strip(),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return clients
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_dhcp_leases(html: str) -> list[DHCPLease]:
|
|
214
|
+
"""Parse DHCP leases from HTML.
|
|
215
|
+
|
|
216
|
+
Parses the /dhcpinfo.html page to extract active DHCP leases
|
|
217
|
+
with hostname, MAC address, IP address, and expiry time.
|
|
218
|
+
"""
|
|
219
|
+
leases = []
|
|
220
|
+
|
|
221
|
+
# Find the DHCP table section
|
|
222
|
+
table_match = re.search(
|
|
223
|
+
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
224
|
+
)
|
|
225
|
+
if not table_match:
|
|
226
|
+
return leases
|
|
227
|
+
|
|
228
|
+
table = table_match.group(0)
|
|
229
|
+
|
|
230
|
+
# Find data rows (skip header row)
|
|
231
|
+
rows = re.findall(
|
|
232
|
+
r"<tr><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td><td>([^<]*)</td></tr>",
|
|
233
|
+
table,
|
|
234
|
+
re.IGNORECASE,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
for row in rows:
|
|
238
|
+
hostname, mac, ip, expires = row
|
|
239
|
+
leases.append(
|
|
240
|
+
DHCPLease(
|
|
241
|
+
hostname=hostname.strip(),
|
|
242
|
+
mac=mac.strip(),
|
|
243
|
+
ip=ip.strip(),
|
|
244
|
+
expires_in=expires.strip(),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return leases
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_routes(html: str) -> list[Route]:
|
|
252
|
+
"""Parse routing table from HTML.
|
|
253
|
+
|
|
254
|
+
Parses the /rtroutecfg.cmd page to extract routing table entries
|
|
255
|
+
with destination, gateway, subnet mask, flags, metric, and service.
|
|
256
|
+
"""
|
|
257
|
+
routes = []
|
|
258
|
+
|
|
259
|
+
# Find the routing table
|
|
260
|
+
table_match = re.search(
|
|
261
|
+
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
262
|
+
)
|
|
263
|
+
if not table_match:
|
|
264
|
+
return routes
|
|
265
|
+
|
|
266
|
+
table = table_match.group(0)
|
|
267
|
+
|
|
268
|
+
# Find data rows - 6 cells per row
|
|
269
|
+
rows = re.findall(
|
|
270
|
+
r"<tr>\s*"
|
|
271
|
+
r"<td>([^<]*)</td>\s*"
|
|
272
|
+
r"<td>([^<]*)</td>\s*"
|
|
273
|
+
r"<td>([^<]*)</td>\s*"
|
|
274
|
+
r"<td>([^<]*)</td>\s*"
|
|
275
|
+
r"<td>([^<]*)</td>\s*"
|
|
276
|
+
r"<td>([^<]*)</td>\s*"
|
|
277
|
+
r"</tr>",
|
|
278
|
+
table,
|
|
279
|
+
re.IGNORECASE,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
for row in rows:
|
|
283
|
+
dest, gw, mask, flag, metric, service = row
|
|
284
|
+
# Skip header row
|
|
285
|
+
if "Destination" in dest:
|
|
286
|
+
continue
|
|
287
|
+
routes.append(
|
|
288
|
+
Route(
|
|
289
|
+
destination=dest.strip(),
|
|
290
|
+
gateway=gw.strip(),
|
|
291
|
+
subnet_mask=mask.strip(),
|
|
292
|
+
flag=flag.strip(),
|
|
293
|
+
metric=int(metric.strip()) if metric.strip().isdigit() else 0,
|
|
294
|
+
service=service.replace(" ", "").strip(),
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return routes
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def parse_statistics(html: str) -> Statistics:
|
|
302
|
+
"""Parse network statistics from HTML.
|
|
303
|
+
|
|
304
|
+
Parses the /statsifcwanber.html page to extract LAN interface stats,
|
|
305
|
+
WAN interface stats, and ADSL line statistics.
|
|
306
|
+
"""
|
|
307
|
+
stats = Statistics()
|
|
308
|
+
|
|
309
|
+
# Parse LAN interface stats - look for rows with 9 cells
|
|
310
|
+
lan_section = re.search(
|
|
311
|
+
r"Local Network.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
312
|
+
)
|
|
313
|
+
if lan_section:
|
|
314
|
+
stats.lan_interfaces = parse_interface_stats(lan_section.group(0))
|
|
315
|
+
|
|
316
|
+
# Parse WAN interface stats
|
|
317
|
+
wan_section = re.search(
|
|
318
|
+
r"<td class=topheader>\s*Internet\s*</td>.*?</table>",
|
|
319
|
+
html,
|
|
320
|
+
re.DOTALL | re.IGNORECASE,
|
|
321
|
+
)
|
|
322
|
+
if wan_section:
|
|
323
|
+
stats.wan_interfaces = parse_wan_interface_stats(wan_section.group(0))
|
|
324
|
+
|
|
325
|
+
# Parse ADSL stats
|
|
326
|
+
stats.adsl = parse_adsl_stats(html)
|
|
327
|
+
|
|
328
|
+
return stats
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def parse_interface_stats(html: str) -> list[InterfaceStats]:
|
|
332
|
+
"""Parse LAN interface statistics table."""
|
|
333
|
+
interfaces = []
|
|
334
|
+
|
|
335
|
+
# Find rows with 9 numeric values
|
|
336
|
+
rows = re.findall(
|
|
337
|
+
r"<tr>\s*<td class='hd'>.*?</script>\s*</td>\s*"
|
|
338
|
+
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
|
|
339
|
+
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
|
|
340
|
+
html,
|
|
341
|
+
re.DOTALL | re.IGNORECASE,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Extract interface names from script blocks
|
|
345
|
+
intf_names = re.findall(r"brdIntf\s*=\s*['\"]([^'\"]+)['\"]", html, re.IGNORECASE)
|
|
346
|
+
|
|
347
|
+
for i, row in enumerate(rows):
|
|
348
|
+
intf_name = intf_names[i].split("|")[-1] if i < len(intf_names) else f"eth{i}"
|
|
349
|
+
(
|
|
350
|
+
rx_bytes,
|
|
351
|
+
rx_pkts,
|
|
352
|
+
rx_errs,
|
|
353
|
+
rx_drops,
|
|
354
|
+
tx_bytes,
|
|
355
|
+
tx_pkts,
|
|
356
|
+
tx_errs,
|
|
357
|
+
tx_drops,
|
|
358
|
+
) = row
|
|
359
|
+
interfaces.append(
|
|
360
|
+
InterfaceStats(
|
|
361
|
+
interface=intf_name,
|
|
362
|
+
rx_bytes=int(rx_bytes),
|
|
363
|
+
rx_packets=int(rx_pkts),
|
|
364
|
+
rx_errors=int(rx_errs),
|
|
365
|
+
rx_drops=int(rx_drops),
|
|
366
|
+
tx_bytes=int(tx_bytes),
|
|
367
|
+
tx_packets=int(tx_pkts),
|
|
368
|
+
tx_errors=int(tx_errs),
|
|
369
|
+
tx_drops=int(tx_drops),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return interfaces
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def parse_wan_interface_stats(html: str) -> list[InterfaceStats]:
|
|
377
|
+
"""Parse WAN interface statistics table."""
|
|
378
|
+
interfaces = []
|
|
379
|
+
|
|
380
|
+
# Find rows with interface name, description, and 8 numeric values
|
|
381
|
+
rows = re.findall(
|
|
382
|
+
r"<tr>\s*<td class='hd'>([^<]+)</td>\s*"
|
|
383
|
+
r"<td>([^<]+)</td>\s*"
|
|
384
|
+
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*"
|
|
385
|
+
r"<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*<td>(\d+)</td>\s*</tr>",
|
|
386
|
+
html,
|
|
387
|
+
re.DOTALL | re.IGNORECASE,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
for row in rows:
|
|
391
|
+
(
|
|
392
|
+
intf_name,
|
|
393
|
+
_desc,
|
|
394
|
+
rx_bytes,
|
|
395
|
+
rx_pkts,
|
|
396
|
+
rx_errs,
|
|
397
|
+
rx_drops,
|
|
398
|
+
tx_bytes,
|
|
399
|
+
tx_pkts,
|
|
400
|
+
tx_errs,
|
|
401
|
+
tx_drops,
|
|
402
|
+
) = row
|
|
403
|
+
interfaces.append(
|
|
404
|
+
InterfaceStats(
|
|
405
|
+
interface=intf_name.strip(),
|
|
406
|
+
rx_bytes=int(rx_bytes),
|
|
407
|
+
rx_packets=int(rx_pkts),
|
|
408
|
+
rx_errors=int(rx_errs),
|
|
409
|
+
rx_drops=int(rx_drops),
|
|
410
|
+
tx_bytes=int(tx_bytes),
|
|
411
|
+
tx_packets=int(tx_pkts),
|
|
412
|
+
tx_errors=int(tx_errs),
|
|
413
|
+
tx_drops=int(tx_drops),
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return interfaces
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def parse_adsl_stats(html: str) -> ADSLStats:
|
|
421
|
+
"""Parse ADSL statistics from HTML.
|
|
422
|
+
|
|
423
|
+
Extracts mode, status, link power state, rates, SNR margins,
|
|
424
|
+
attenuation, and output power levels.
|
|
425
|
+
"""
|
|
426
|
+
adsl = ADSLStats()
|
|
427
|
+
|
|
428
|
+
adsl.mode = extract_value(html, r"Mode:</td><td>([^<]+)</td>")
|
|
429
|
+
adsl.traffic_type = extract_value(html, r"Traffic Type:</td><td>([^<]+)</td>")
|
|
430
|
+
adsl.status = extract_value(html, r"Status:</td><td>([^<]+)</td>")
|
|
431
|
+
adsl.link_power_state = extract_value(
|
|
432
|
+
html, r"Link Power State:</td><td>([^<]+)</td>"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Parse rate info - downstream and upstream
|
|
436
|
+
rate_match = re.search(
|
|
437
|
+
r"Rate \(Kbps\):</td><td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
438
|
+
)
|
|
439
|
+
if rate_match:
|
|
440
|
+
adsl.downstream_rate = int(rate_match.group(1))
|
|
441
|
+
adsl.upstream_rate = int(rate_match.group(2))
|
|
442
|
+
|
|
443
|
+
# Parse SNR margin
|
|
444
|
+
snr_match = re.search(
|
|
445
|
+
r"SNR Margin.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
446
|
+
)
|
|
447
|
+
if snr_match:
|
|
448
|
+
adsl.downstream_snr_margin = float(snr_match.group(1)) / 10
|
|
449
|
+
adsl.upstream_snr_margin = float(snr_match.group(2)) / 10
|
|
450
|
+
|
|
451
|
+
# Parse attenuation
|
|
452
|
+
atten_match = re.search(
|
|
453
|
+
r"Attenuation.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
454
|
+
)
|
|
455
|
+
if atten_match:
|
|
456
|
+
adsl.downstream_attenuation = float(atten_match.group(1)) / 10
|
|
457
|
+
adsl.upstream_attenuation = float(atten_match.group(2)) / 10
|
|
458
|
+
|
|
459
|
+
# Parse output power
|
|
460
|
+
power_match = re.search(
|
|
461
|
+
r"Output Power.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
462
|
+
)
|
|
463
|
+
if power_match:
|
|
464
|
+
adsl.downstream_output_power = float(power_match.group(1)) / 10
|
|
465
|
+
adsl.upstream_output_power = float(power_match.group(2)) / 10
|
|
466
|
+
|
|
467
|
+
# Parse attainable rate
|
|
468
|
+
attain_match = re.search(
|
|
469
|
+
r"Attainable Rate.*?<td>(\d+)</td><td>(\d+)</td>", html, re.IGNORECASE
|
|
470
|
+
)
|
|
471
|
+
if attain_match:
|
|
472
|
+
adsl.downstream_attainable_rate = int(attain_match.group(1))
|
|
473
|
+
adsl.upstream_attainable_rate = int(attain_match.group(2))
|
|
474
|
+
|
|
475
|
+
return adsl
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def parse_logs(html: str) -> list[LogEntry]:
|
|
479
|
+
"""Parse system logs from HTML.
|
|
480
|
+
|
|
481
|
+
Parses the /logview.cmd page to extract log entries with
|
|
482
|
+
timestamp, facility, severity, and message.
|
|
483
|
+
"""
|
|
484
|
+
logs = []
|
|
485
|
+
|
|
486
|
+
# Find the log table
|
|
487
|
+
table_match = re.search(
|
|
488
|
+
r"<table class=formlisting>.*?</table>", html, re.DOTALL | re.IGNORECASE
|
|
489
|
+
)
|
|
490
|
+
if not table_match:
|
|
491
|
+
return logs
|
|
492
|
+
|
|
493
|
+
table = table_match.group(0)
|
|
494
|
+
|
|
495
|
+
# Find data rows - 4 cells per row
|
|
496
|
+
rows = re.findall(
|
|
497
|
+
r"<tr>\s*"
|
|
498
|
+
r"<td[^>]*>([^<]*)</td>\s*"
|
|
499
|
+
r"<td[^>]*>([^<]*)</td>\s*"
|
|
500
|
+
r"<td[^>]*>([^<]*)</td>\s*"
|
|
501
|
+
r"<td[^>]*>([^<]*)</td>\s*"
|
|
502
|
+
r"</tr>",
|
|
503
|
+
table,
|
|
504
|
+
re.IGNORECASE,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
for row in rows:
|
|
508
|
+
datetime_str, facility, severity, message = row
|
|
509
|
+
# Skip header row
|
|
510
|
+
if "Date/Time" in datetime_str:
|
|
511
|
+
continue
|
|
512
|
+
logs.append(
|
|
513
|
+
LogEntry(
|
|
514
|
+
datetime=datetime_str.strip(),
|
|
515
|
+
facility=facility.strip(),
|
|
516
|
+
severity=severity.strip(),
|
|
517
|
+
message=message.strip(),
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return logs
|