AutoDialer 0.2.2__tar.gz → 0.2.3__tar.gz

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.
Files changed (42) hide show
  1. {autodialer-0.2.2/src/AutoDialer.egg-info → autodialer-0.2.3}/PKG-INFO +9 -5
  2. {autodialer-0.2.2 → autodialer-0.2.3}/README.MD +9 -5
  3. {autodialer-0.2.2 → autodialer-0.2.3/src/AutoDialer.egg-info}/PKG-INFO +9 -5
  4. {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/SOURCES.txt +9 -3
  5. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/__init__.py +3 -1
  6. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/__init__.py +3 -1
  7. autodialer-0.2.3/src/autodialer/apis/routers/asus/__init__.py +3 -0
  8. autodialer-0.2.3/src/autodialer/apis/routers/asus/asus_api.py +454 -0
  9. autodialer-0.2.3/src/autodialer/apis/routers/base_api.py +11 -0
  10. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/tplink/tplink_api.py +31 -20
  11. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/utils/check_isp.py +17 -12
  12. autodialer-0.2.2/src/autodialer/apis/utils/check_router_vendor.py → autodialer-0.2.3/src/autodialer/apis/utils/check_vendor.py +11 -5
  13. autodialer-0.2.3/src/autodialer/apis/utils/get_gateway.py +199 -0
  14. autodialer-0.2.3/src/autodialer/apis/utils/get_vendor_api.py +75 -0
  15. autodialer-0.2.2/src/autodialer/apis/routers/tplink/tplink_get_devices.py → autodialer-0.2.3/src/autodialer/apis/utils/print_devices_table.py +0 -9
  16. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/config/config.py +1 -0
  17. autodialer-0.2.3/src/autodialer/get_devices.py +37 -0
  18. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/reconnection.py +28 -26
  19. autodialer-0.2.3/tests/test_asus_api.py +193 -0
  20. autodialer-0.2.3/tests/test_get_gateway.py +96 -0
  21. autodialer-0.2.3/tests/test_get_vendor_api.py +39 -0
  22. {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_reconnection.py +20 -1
  23. autodialer-0.2.3/tests/test_tplink_api.py +105 -0
  24. autodialer-0.2.2/src/autodialer/apis/utils/get_gateway.py +0 -39
  25. autodialer-0.2.2/src/autodialer/apis/utils/get_vendor_api.py +0 -12
  26. autodialer-0.2.2/src/autodialer/get_devices.py +0 -23
  27. autodialer-0.2.2/tests/test_get_gateway.py +0 -51
  28. {autodialer-0.2.2 → autodialer-0.2.3}/LICENSE +0 -0
  29. {autodialer-0.2.2 → autodialer-0.2.3}/pyproject.toml +0 -0
  30. {autodialer-0.2.2 → autodialer-0.2.3}/setup.cfg +0 -0
  31. {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/dependency_links.txt +0 -0
  32. {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/entry_points.txt +0 -0
  33. {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/requires.txt +0 -0
  34. {autodialer-0.2.2 → autodialer-0.2.3}/src/AutoDialer.egg-info/top_level.txt +0 -0
  35. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/__init__.py +0 -0
  36. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/routers/tplink/__init__.py +0 -0
  37. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/apis/utils/is_target_asn.py +0 -0
  38. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/config/__init__.py +0 -0
  39. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/encode/__init__.py +0 -0
  40. {autodialer-0.2.2 → autodialer-0.2.3}/src/autodialer/encode/tplink_security_encode.py +0 -0
  41. {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_check_isp.py +0 -0
  42. {autodialer-0.2.2 → autodialer-0.2.3}/tests/test_pppoe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoDialer
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: AutoDialer is an automation script designed to interact with routers using APIs.
5
5
  Author-email: Byte Flow <fakeshadow1337@gmail.com>
6
6
  License-Expression: GPL-3.0-only
@@ -76,13 +76,15 @@ Create a `.env` file in your working directory:
76
76
 
77
77
  | Variable | Description |
78
78
  | :--- | :--- |
79
+ | `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
79
80
  | `PANEL_PASSWORD` | Router panel password |
80
- | `PPPOE_USERNAME` | ISP PPPoE username (required for PPPoE lines) |
81
- | `PPPOE_PASSWORD` | ISP PPPoE password (required for PPPoE lines) |
81
+ | `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
82
+ | `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
82
83
  | `ASN` | Target ASN (optional unless not using `--force`) |
83
84
 
84
85
  Example:
85
86
  ```bash
87
+ PANEL_USERNAME='admin'
86
88
  PANEL_PASSWORD='your_router_panel_password'
87
89
  PPPOE_USERNAME='your_pppoe_username'
88
90
  PPPOE_PASSWORD='your_pppoe_password'
@@ -106,11 +108,13 @@ Arguments:
106
108
 
107
109
  Behavior:
108
110
  - AutoDialer detects current WAN protocol and applies matching reconnection action.
109
- - PPPoE uses disconnect/connect flow with configured PPPoE credentials.
111
+ - PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
112
+ - If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
110
113
  - DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
114
+ - ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
111
115
 
112
116
  ## Notes
113
- - Current API payloads are primarily tailored for TP-Link router firmware behavior.
117
+ - TP-Link and ASUS routers are supported by default.
114
118
  - Keep `.env` private and never commit credentials.
115
119
 
116
120
  ---
@@ -52,13 +52,15 @@ Create a `.env` file in your working directory:
52
52
 
53
53
  | Variable | Description |
54
54
  | :--- | :--- |
55
+ | `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
55
56
  | `PANEL_PASSWORD` | Router panel password |
56
- | `PPPOE_USERNAME` | ISP PPPoE username (required for PPPoE lines) |
57
- | `PPPOE_PASSWORD` | ISP PPPoE password (required for PPPoE lines) |
57
+ | `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
58
+ | `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
58
59
  | `ASN` | Target ASN (optional unless not using `--force`) |
59
60
 
60
61
  Example:
61
62
  ```bash
63
+ PANEL_USERNAME='admin'
62
64
  PANEL_PASSWORD='your_router_panel_password'
63
65
  PPPOE_USERNAME='your_pppoe_username'
64
66
  PPPOE_PASSWORD='your_pppoe_password'
@@ -82,12 +84,14 @@ Arguments:
82
84
 
83
85
  Behavior:
84
86
  - AutoDialer detects current WAN protocol and applies matching reconnection action.
85
- - PPPoE uses disconnect/connect flow with configured PPPoE credentials.
87
+ - PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
88
+ - If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
86
89
  - DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
90
+ - ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
87
91
 
88
92
  ## Notes
89
- - Current API payloads are primarily tailored for TP-Link router firmware behavior.
93
+ - TP-Link and ASUS routers are supported by default.
90
94
  - Keep `.env` private and never commit credentials.
91
95
 
92
96
  ---
93
- Thanks for using AutoDialer.
97
+ Thanks for using AutoDialer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoDialer
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: AutoDialer is an automation script designed to interact with routers using APIs.
5
5
  Author-email: Byte Flow <fakeshadow1337@gmail.com>
6
6
  License-Expression: GPL-3.0-only
@@ -76,13 +76,15 @@ Create a `.env` file in your working directory:
76
76
 
77
77
  | Variable | Description |
78
78
  | :--- | :--- |
79
+ | `PANEL_USERNAME` | Router panel username (defaults to `admin`) |
79
80
  | `PANEL_PASSWORD` | Router panel password |
80
- | `PPPOE_USERNAME` | ISP PPPoE username (required for PPPoE lines) |
81
- | `PPPOE_PASSWORD` | ISP PPPoE password (required for PPPoE lines) |
81
+ | `PPPOE_USERNAME` | ISP PPPoE username (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
82
+ | `PPPOE_PASSWORD` | ISP PPPoE password (optional; used when you want to overwrite the router's saved PPPoE config before reconnecting) |
82
83
  | `ASN` | Target ASN (optional unless not using `--force`) |
83
84
 
84
85
  Example:
85
86
  ```bash
87
+ PANEL_USERNAME='admin'
86
88
  PANEL_PASSWORD='your_router_panel_password'
87
89
  PPPOE_USERNAME='your_pppoe_username'
88
90
  PPPOE_PASSWORD='your_pppoe_password'
@@ -106,11 +108,13 @@ Arguments:
106
108
 
107
109
  Behavior:
108
110
  - AutoDialer detects current WAN protocol and applies matching reconnection action.
109
- - PPPoE uses disconnect/connect flow with configured PPPoE credentials.
111
+ - PPPoE uses disconnect/connect flow and reuses the router's saved credentials by default.
112
+ - If `PPPOE_USERNAME` and `PPPOE_PASSWORD` are set, AutoDialer updates the router's PPPoE config before reconnecting.
110
113
  - DHCP uses DHCP renew flow and shares the same ASN/check/retry control logic.
114
+ - ASUSWRT routers use panel authentication plus WAN restart through the ASUS web API.
111
115
 
112
116
  ## Notes
113
- - Current API payloads are primarily tailored for TP-Link router firmware behavior.
117
+ - TP-Link and ASUS routers are supported by default.
114
118
  - Keep `.env` private and never commit credentials.
115
119
 
116
120
  ---
@@ -12,19 +12,25 @@ src/autodialer/get_devices.py
12
12
  src/autodialer/reconnection.py
13
13
  src/autodialer/apis/__init__.py
14
14
  src/autodialer/apis/routers/__init__.py
15
+ src/autodialer/apis/routers/base_api.py
16
+ src/autodialer/apis/routers/asus/__init__.py
17
+ src/autodialer/apis/routers/asus/asus_api.py
15
18
  src/autodialer/apis/routers/tplink/__init__.py
16
19
  src/autodialer/apis/routers/tplink/tplink_api.py
17
- src/autodialer/apis/routers/tplink/tplink_get_devices.py
18
20
  src/autodialer/apis/utils/check_isp.py
19
- src/autodialer/apis/utils/check_router_vendor.py
21
+ src/autodialer/apis/utils/check_vendor.py
20
22
  src/autodialer/apis/utils/get_gateway.py
21
23
  src/autodialer/apis/utils/get_vendor_api.py
22
24
  src/autodialer/apis/utils/is_target_asn.py
25
+ src/autodialer/apis/utils/print_devices_table.py
23
26
  src/autodialer/config/__init__.py
24
27
  src/autodialer/config/config.py
25
28
  src/autodialer/encode/__init__.py
26
29
  src/autodialer/encode/tplink_security_encode.py
30
+ tests/test_asus_api.py
27
31
  tests/test_check_isp.py
28
32
  tests/test_get_gateway.py
33
+ tests/test_get_vendor_api.py
29
34
  tests/test_pppoe.py
30
- tests/test_reconnection.py
35
+ tests/test_reconnection.py
36
+ tests/test_tplink_api.py
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.2"
1
+ __version__ = "0.2.3"
2
2
 
3
3
  from .apis.utils.check_isp import check_isp, check_isp_with_retries
4
4
  from .apis.utils.get_gateway import (
@@ -6,10 +6,12 @@ from .apis.utils.get_gateway import (
6
6
  get_gateway_ip_on_unix,
7
7
  get_gateway_ip_on_windows,
8
8
  )
9
+ from .apis.routers.asus.asus_api import AsusAPI
9
10
  from .apis.routers.tplink.tplink_api import TPLinkAPI
10
11
  from .apis.utils.is_target_asn import is_target_asn
11
12
 
12
13
  __all__ = [
14
+ "AsusAPI",
13
15
  "check_isp",
14
16
  "check_isp_with_retries",
15
17
  "get_gateway_ip_on_linux",
@@ -1,10 +1,12 @@
1
+ from .routers.asus.asus_api import AsusAPI
1
2
  from .routers.tplink.tplink_api import TPLinkAPI
2
3
  from .utils.check_isp import check_isp
3
4
  from .utils.check_isp import check_isp_with_retries
4
5
  from .utils.get_gateway import get_gateway_ip
5
- from .utils.check_router_vendor import check_router_vendor
6
+ from .utils.check_vendor import check_router_vendor
6
7
 
7
8
  __all__ = [
9
+ "AsusAPI",
8
10
  "TPLinkAPI",
9
11
  "check_isp",
10
12
  "check_isp_with_retries",
@@ -0,0 +1,3 @@
1
+ from .asus_api import AsusAPI
2
+
3
+ __all__ = ["AsusAPI"]
@@ -0,0 +1,454 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import re
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ from autodialer.apis.utils.get_gateway import format_ip_for_url_host, get_gateway_ip
11
+ from autodialer.config.config import PANEL_PASSWORD, PANEL_USERNAME
12
+
13
+ USER_AGENT = "AutoDialer"
14
+ REQUEST_TIMEOUT = 5
15
+ WAN_STATUS_HOOK = "get_wan_unit();nvram_get(wan0_proto);nvram_get(wan1_proto);"
16
+ UPDATE_CLIENTS_PATTERN = re.compile(
17
+ r"originData = (.*)networkmap_fullscan = ",
18
+ re.DOTALL,
19
+ )
20
+ MAC_ADDRESS_PATTERN = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AsusAPI:
27
+ """Interact with ASUSWRT routers using the web API."""
28
+
29
+ SUPPORTED_VENDORS = ("ASUS", "ASUS AiMesh")
30
+
31
+ router_ip: str
32
+ panel_username: str
33
+ panel_password: str
34
+ base_url: str
35
+ token: str
36
+ verify_ssl: bool
37
+
38
+ def __init__(self):
39
+ router_ip = get_gateway_ip()
40
+ if router_ip is None:
41
+ logger.error("Could not determine router IP address.")
42
+ exit(1)
43
+
44
+ if PANEL_PASSWORD is None:
45
+ logger.error("Missing required environment variable: PANEL_PASSWORD")
46
+ exit(1)
47
+
48
+ self.router_ip = router_ip
49
+ self.panel_username = PANEL_USERNAME or "admin"
50
+ self.panel_password = PANEL_PASSWORD
51
+ self.session = requests.Session()
52
+ self.base_url, self.verify_ssl, self.token = self._login_router()
53
+
54
+ def _candidate_base_urls(self) -> list[tuple[str, bool]]:
55
+ router_host = format_ip_for_url_host(self.router_ip)
56
+ return [
57
+ (f"http://{router_host}", True),
58
+ (f"https://{router_host}", False),
59
+ ]
60
+
61
+ @staticmethod
62
+ def _dict_to_request(data: dict[str, Any]) -> str:
63
+ parts: list[str] = []
64
+ for key, value in data.items():
65
+ safe_key = str(key).replace("'", "\\'")
66
+ safe_value = str(value).replace("'", "\\'")
67
+ parts.append(f"'{safe_key}':'{safe_value}'")
68
+ return ";".join(parts)
69
+
70
+ @staticmethod
71
+ def _read_json_response(response: requests.Response) -> dict[str, Any] | None:
72
+ try:
73
+ data = response.json()
74
+ except ValueError:
75
+ return None
76
+
77
+ return data if isinstance(data, dict) else None
78
+
79
+ def _post_request(
80
+ self,
81
+ endpoint: str,
82
+ raw_payload: str,
83
+ form_payload: dict[str, Any] | None = None,
84
+ require_json: bool = True,
85
+ base_url: str | None = None,
86
+ verify_ssl: bool | None = None,
87
+ headers: dict[str, str] | None = None,
88
+ ) -> tuple[bool, dict[str, Any] | None]:
89
+ url = f"{base_url or self.base_url}/{endpoint}"
90
+ ssl_verify = self.verify_ssl if verify_ssl is None else verify_ssl
91
+ payload_variants: list[Any] = [quote(raw_payload)]
92
+ if form_payload is not None:
93
+ payload_variants.append(form_payload)
94
+
95
+ last_error: requests.RequestException | None = None
96
+
97
+ for payload in payload_variants:
98
+ try:
99
+ response = self.session.post(
100
+ url,
101
+ data=payload,
102
+ headers=headers,
103
+ timeout=REQUEST_TIMEOUT,
104
+ verify=ssl_verify,
105
+ )
106
+ response.raise_for_status()
107
+ except requests.RequestException as exc:
108
+ last_error = exc
109
+ continue
110
+
111
+ data = self._read_json_response(response)
112
+ if data is None and require_json:
113
+ continue
114
+
115
+ return True, data
116
+
117
+ if last_error is not None:
118
+ logger.error("Error connecting to router: %s", last_error)
119
+
120
+ return False, None
121
+
122
+ def _post_text_request(
123
+ self,
124
+ endpoint: str,
125
+ raw_payload: str,
126
+ form_payload: dict[str, Any] | None = None,
127
+ base_url: str | None = None,
128
+ verify_ssl: bool | None = None,
129
+ headers: dict[str, str] | None = None,
130
+ ) -> tuple[bool, str | None]:
131
+ url = f"{base_url or self.base_url}/{endpoint}"
132
+ ssl_verify = self.verify_ssl if verify_ssl is None else verify_ssl
133
+ payload_variants: list[Any] = [quote(raw_payload)]
134
+ if form_payload is not None:
135
+ payload_variants.append(form_payload)
136
+
137
+ last_error: requests.RequestException | None = None
138
+
139
+ for payload in payload_variants:
140
+ try:
141
+ response = self.session.post(
142
+ url,
143
+ data=payload,
144
+ headers=headers,
145
+ timeout=REQUEST_TIMEOUT,
146
+ verify=ssl_verify,
147
+ )
148
+ response.raise_for_status()
149
+ except requests.RequestException as exc:
150
+ last_error = exc
151
+ continue
152
+
153
+ return True, response.text
154
+
155
+ if last_error is not None:
156
+ logger.error("Error connecting to router: %s", last_error)
157
+
158
+ return False, None
159
+
160
+ @staticmethod
161
+ def _read_dict_json(content: str) -> dict[str, Any]:
162
+ try:
163
+ data = json.loads(content)
164
+ except json.JSONDecodeError:
165
+ return {}
166
+
167
+ return data if isinstance(data, dict) else {}
168
+
169
+ @classmethod
170
+ def _read_update_clients_data(cls, content: str) -> dict[str, Any]:
171
+ match = UPDATE_CLIENTS_PATTERN.search(content.replace("\n", ""))
172
+ if not match:
173
+ return {}
174
+
175
+ payload = re.sub(
176
+ r"\b(fromNetworkmapd|nmpClient)\b\s*:",
177
+ r'"\1":',
178
+ match.group(1),
179
+ )
180
+ return cls._read_dict_json(payload)
181
+
182
+ @staticmethod
183
+ def _read_client_map(value: Any) -> dict[str, Any]:
184
+ if isinstance(value, list) and value and isinstance(value[0], dict):
185
+ return value[0]
186
+ if isinstance(value, dict):
187
+ return value
188
+ return {}
189
+
190
+ @classmethod
191
+ def _is_mac_address(cls, value: Any) -> bool:
192
+ return (
193
+ isinstance(value, str) and MAC_ADDRESS_PATTERN.fullmatch(value) is not None
194
+ )
195
+
196
+ @staticmethod
197
+ def _merge_client_metadata(
198
+ client: dict[str, Any],
199
+ fallback: dict[str, Any] | None,
200
+ ) -> dict[str, Any]:
201
+ if not fallback:
202
+ return client
203
+
204
+ merged = client.copy()
205
+ for key in (
206
+ "name",
207
+ "nickName",
208
+ "vendor",
209
+ "vendorclass",
210
+ "type",
211
+ "defaultType",
212
+ ):
213
+ if merged.get(key) in (None, "") and fallback.get(key) not in (
214
+ None,
215
+ "",
216
+ ):
217
+ merged[key] = fallback[key]
218
+
219
+ return merged
220
+
221
+ @staticmethod
222
+ def _read_device_name(client: dict[str, Any]) -> str:
223
+ for key in ("nickName", "name", "vendor"):
224
+ value = client.get(key)
225
+ if isinstance(value, str):
226
+ value = value.strip()
227
+ if value:
228
+ return value
229
+
230
+ return "(unknown)"
231
+
232
+ @staticmethod
233
+ def _read_speed(value: Any) -> int:
234
+ if isinstance(value, (int, float)):
235
+ return max(0, int(round(value)))
236
+
237
+ if isinstance(value, str):
238
+ value = value.strip()
239
+ if not value:
240
+ return 0
241
+ try:
242
+ return max(0, int(round(float(value))))
243
+ except ValueError:
244
+ return 0
245
+
246
+ return 0
247
+
248
+ @staticmethod
249
+ def _is_online(client: dict[str, Any]) -> bool:
250
+ return str(client.get("isOnline", client.get("online", "0"))).strip() == "1"
251
+
252
+ @staticmethod
253
+ def _read_connection_type(client: dict[str, Any]) -> str:
254
+ value = client.get(
255
+ "isWL",
256
+ client.get("wireless", client.get("is_wireless", "0")),
257
+ )
258
+ return "wireless" if str(value).strip() not in ("", "0", "None") else "wired"
259
+
260
+ def _login_router(self) -> tuple[str, bool, str]:
261
+ auth = f"{self.panel_username}:{self.panel_password}".encode("ascii")
262
+ token_value = base64.b64encode(auth).decode("ascii")
263
+ raw_payload = f"login_authorization={token_value}"
264
+ form_payload = {"login_authorization": token_value}
265
+ headers = {"user-agent": USER_AGENT}
266
+
267
+ for base_url, verify_ssl in self._candidate_base_urls():
268
+ ok, data = self._post_request(
269
+ endpoint="login.cgi",
270
+ raw_payload=raw_payload,
271
+ form_payload=form_payload,
272
+ require_json=True,
273
+ base_url=base_url,
274
+ verify_ssl=verify_ssl,
275
+ headers=headers,
276
+ )
277
+ if not ok or not data:
278
+ continue
279
+
280
+ token = data.get("asus_token")
281
+ if isinstance(token, str) and token:
282
+ return base_url, verify_ssl, token
283
+
284
+ logger.error("Login failed.")
285
+ exit(1)
286
+
287
+ def _auth_headers(self) -> dict[str, str]:
288
+ return {
289
+ "user-agent": USER_AGENT,
290
+ "cookie": f"asus_token={self.token}",
291
+ }
292
+
293
+ def get_wan_status(self) -> dict[str, Any]:
294
+ ok, data = self._post_request(
295
+ endpoint="appGet.cgi",
296
+ raw_payload=f"hook={WAN_STATUS_HOOK}",
297
+ form_payload={"hook": WAN_STATUS_HOOK},
298
+ require_json=True,
299
+ headers=self._auth_headers(),
300
+ )
301
+ if not ok or not data:
302
+ logger.error("Failed to get ASUS WAN status.")
303
+ return {}
304
+
305
+ return data
306
+
307
+ @staticmethod
308
+ def _read_wan_unit(status: dict[str, Any]) -> int | None:
309
+ raw_unit = status.get("get_wan_unit")
310
+ if isinstance(raw_unit, int):
311
+ return raw_unit
312
+ if isinstance(raw_unit, str) and raw_unit.isdigit():
313
+ return int(raw_unit)
314
+ return None
315
+
316
+ def get_wan_proto(self) -> str | None:
317
+ status = self.get_wan_status()
318
+ if not status:
319
+ return None
320
+
321
+ unit = self._read_wan_unit(status)
322
+ candidates: list[Any] = []
323
+ if unit in (0, 1):
324
+ candidates.append(status.get(f"wan{unit}_proto"))
325
+ candidates.extend([status.get("wan0_proto"), status.get("wan1_proto")])
326
+
327
+ for proto in candidates:
328
+ if isinstance(proto, str) and proto:
329
+ return proto.lower()
330
+
331
+ return None
332
+
333
+ def _run_service(self, service: str) -> bool:
334
+ payload = {
335
+ "rc_service": service,
336
+ "action_mode": "apply",
337
+ }
338
+ ok, data = self._post_request(
339
+ endpoint="applyapp.cgi",
340
+ raw_payload=self._dict_to_request(payload),
341
+ form_payload=payload,
342
+ require_json=False,
343
+ headers=self._auth_headers(),
344
+ )
345
+ if not ok:
346
+ return False
347
+
348
+ if not data:
349
+ return True
350
+
351
+ error_status = data.get("error_status")
352
+ if error_status not in (None, "", 0, "0", False):
353
+ logger.error("ASUS service error: %s", error_status)
354
+ return False
355
+
356
+ run_service = data.get("run_service")
357
+ if not isinstance(run_service, str) or not run_service:
358
+ return True
359
+
360
+ accepted_services = {service}
361
+ if service.startswith("restart_wan"):
362
+ accepted_services.add("restart_wan")
363
+
364
+ if run_service not in accepted_services:
365
+ logger.error(
366
+ "Unexpected ASUS service response: expected %s, got %s.",
367
+ service,
368
+ run_service,
369
+ )
370
+ return False
371
+
372
+ return True
373
+
374
+ def _restart_wan(self) -> bool:
375
+ status = self.get_wan_status()
376
+ unit = self._read_wan_unit(status) if status else None
377
+
378
+ services_to_try: list[str] = []
379
+ if unit in (0, 1):
380
+ services_to_try.append(f"restart_wan{unit}")
381
+ services_to_try.append("restart_wan")
382
+
383
+ for service in services_to_try:
384
+ if self._run_service(service):
385
+ return True
386
+
387
+ return False
388
+
389
+ def make_pppoe_reconnection(self) -> bool:
390
+ if self._restart_wan():
391
+ return True
392
+
393
+ logger.error("Failed to reconnect pppoe.")
394
+ return False
395
+
396
+ def dhcp_renew(self) -> bool:
397
+ if self._restart_wan():
398
+ return True
399
+
400
+ logger.error("Failed to renew dhcp.")
401
+ return False
402
+
403
+ def get_connected_devices(self) -> list[dict[str, Any]]:
404
+ ok, content = self._post_text_request(
405
+ endpoint="update_clients.asp",
406
+ raw_payload="",
407
+ form_payload={},
408
+ headers=self._auth_headers(),
409
+ )
410
+ if not ok or content is None:
411
+ logger.error("Failed to get ASUS connected devices.")
412
+ return []
413
+
414
+ data = self._read_update_clients_data(content)
415
+ if not data:
416
+ logger.error("Failed to parse ASUS connected devices.")
417
+ return []
418
+
419
+ current_clients = self._read_client_map(data.get("fromNetworkmapd"))
420
+ historical_clients = self._read_client_map(data.get("nmpClient"))
421
+
422
+ devices: list[dict[str, Any]] = []
423
+ for mac, client in current_clients.items():
424
+ if not self._is_mac_address(mac) or not isinstance(client, dict):
425
+ continue
426
+
427
+ fallback = historical_clients.get(mac)
428
+ merged_client = self._merge_client_metadata(
429
+ client,
430
+ fallback if isinstance(fallback, dict) else None,
431
+ )
432
+ if not self._is_online(merged_client):
433
+ continue
434
+
435
+ devices.append(
436
+ {
437
+ "hostname": self._read_device_name(merged_client),
438
+ "ip": merged_client.get("ip", "-") or "-",
439
+ "mac": mac,
440
+ "type": self._read_connection_type(merged_client),
441
+ "is_current": str(merged_client.get("isLogin", "0")).strip() == "1",
442
+ "up_kbps": self._read_speed(merged_client.get("curTx")),
443
+ "down_kbps": self._read_speed(merged_client.get("curRx")),
444
+ }
445
+ )
446
+
447
+ devices.sort(
448
+ key=lambda device: (
449
+ not device["is_current"],
450
+ device["hostname"].lower(),
451
+ device["mac"],
452
+ )
453
+ )
454
+ return devices
@@ -0,0 +1,11 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class RouterAPI(Protocol):
5
+ def get_wan_proto(self) -> str | None: ...
6
+
7
+ def make_pppoe_reconnection(self) -> bool: ...
8
+
9
+ def dhcp_renew(self) -> bool: ...
10
+
11
+ def get_connected_devices(self) -> list: ...