speedport-api-expanded 0.7.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.
speedport/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .speedport import Speedport
2
+
3
+ __all__ = ["Speedport"]
speedport/__main__.py ADDED
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import sys
8
+ import time
9
+ from getpass import getpass
10
+ from pathlib import Path
11
+
12
+ if __name__ == "__main__":
13
+ sys.path.insert(0, str(Path(__file__).parent.parent))
14
+
15
+ from speedport.speedport import Speedport
16
+
17
+ _LOGGER = logging.getLogger("speedport")
18
+
19
+
20
+ def set_logger(args):
21
+ logging.getLogger("aiohttp").setLevel(logging.WARNING)
22
+ if args["debug"]:
23
+ _LOGGER.setLevel(logging.DEBUG)
24
+ formatter = logging.Formatter("%(levelname)s - %(message)s")
25
+ elif args["quiet"]:
26
+ _LOGGER.setLevel(logging.WARNING)
27
+ formatter = logging.Formatter("%(message)s")
28
+ else:
29
+ _LOGGER.setLevel(logging.INFO)
30
+ formatter = logging.Formatter("%(message)s")
31
+ console_handler = logging.StreamHandler(sys.stdout)
32
+ console_handler.setFormatter(formatter)
33
+ _LOGGER.addHandler(console_handler)
34
+
35
+
36
+ def data_table(data, keys):
37
+ columns = {}
38
+ for key in keys:
39
+ columns[key] = max([len(str(d[key])) for d in data] + [len(key)])
40
+ line = (
41
+ "+-"
42
+ + "-+-".join([f"{'-' * length}" for key, length in columns.items()])
43
+ + "-+\n"
44
+ )
45
+ text = (
46
+ f"{line}| "
47
+ + " | ".join([f"{key:<{length}}" for key, length in columns.items()])
48
+ + f" |\n{line}"
49
+ )
50
+ for d in data:
51
+ text += (
52
+ "| "
53
+ + " | ".join(
54
+ [f"{str(d[key]):<{length}}" for key, length in columns.items()]
55
+ )
56
+ + " |\n"
57
+ )
58
+ return text + line
59
+
60
+
61
+ def batch_output(devices):
62
+ for device in devices.values():
63
+ print(
64
+ int(device.connected),
65
+ device.ipv4,
66
+ device.type,
67
+ device.name,
68
+ sep="\t",
69
+ )
70
+
71
+
72
+ def get_arguments():
73
+ """Get parsed arguments."""
74
+ parser = argparse.ArgumentParser(description="Speedport: Command Line Utility")
75
+ parser.add_argument(
76
+ "-H",
77
+ "--host",
78
+ help="ip address or hostname of Speedport webinterface",
79
+ default=os.environ.get("SPEEDPORT_HOST", "speedport.ip"),
80
+ )
81
+ parser.add_argument(
82
+ "-s", "--https", help="use https connection", action="store_true"
83
+ )
84
+ parser.add_argument(
85
+ "-p",
86
+ "--password",
87
+ help="password of Speedport webinterface (default: SPEEDPORT_PASSWORD)",
88
+ default=os.environ.get("SPEEDPORT_PASSWORD"),
89
+ )
90
+ parser.add_argument(
91
+ "-d", "--debug", help="enable debug logging", action="store_true"
92
+ )
93
+ parser.add_argument("-q", "--quiet", help="output only errors", action="store_true")
94
+ parser.add_argument(
95
+ "-b", "--batch", help="print output for batch processing", action="store_true"
96
+ )
97
+ parser.add_argument(
98
+ "-t", "--table", help="print output as table (default)", action="store_true"
99
+ )
100
+ subparser = parser.add_subparsers(
101
+ title="commands", metavar="COMMAND", required=True
102
+ )
103
+ for network in ["", "guest-", "office-"]:
104
+ wifi = subparser.add_parser(f"{network}wifi", help=f"Turn on/off {network}wifi")
105
+ wifi.add_argument(
106
+ f"{network}wifi", choices=["on", "off"], help=f"Turn on/off {network}wifi"
107
+ )
108
+ reconnect = subparser.add_parser(
109
+ "reconnect", help="Reconnect internet and receive new ip"
110
+ )
111
+ reconnect.set_defaults(reconnect=True)
112
+ reboot = subparser.add_parser("reboot", help="Reboot device")
113
+ reboot.set_defaults(reboot=True)
114
+ wps = subparser.add_parser("wps", help="Turn on wps for 2 minutes")
115
+ wps.set_defaults(wps=True)
116
+ devices = subparser.add_parser("devices", help="Output devices")
117
+ devices.set_defaults(devices=True)
118
+ calls = subparser.add_parser("calls", help="Output calls")
119
+ calls.set_defaults(calls=True)
120
+ ip_info = subparser.add_parser("ip-info", help="Output IPv4 and IPv6 information")
121
+ ip_info.set_defaults(ip_info=True)
122
+ ip_info.add_argument(
123
+ "--ipv4", help="Print only the public IPv4 address", action="store_true"
124
+ )
125
+ return vars(parser.parse_args())
126
+
127
+
128
+ async def check_args(speedport, args):
129
+ if args.get("ip_info"):
130
+ info = await speedport.ip_info
131
+ if args.get("ipv4"):
132
+ address = info.get("public_ip_v4")
133
+ if not address:
134
+ raise SystemExit("Router did not report a public IPv4 address")
135
+ print(address)
136
+ elif args.get("batch"):
137
+ for key, value in info.items():
138
+ print(key, value, sep="\t")
139
+ else:
140
+ print(
141
+ data_table(
142
+ [{"field": key, "value": value} for key, value in info.items()],
143
+ ["field", "value"],
144
+ )
145
+ )
146
+ if args.get("wps"):
147
+ await wps_enable(args, speedport)
148
+ if args.get("reconnect"):
149
+ await reconnect(args, speedport)
150
+ if args.get("reboot"):
151
+ await speedport.reboot()
152
+ if args.get("devices"):
153
+ if args.get("batch"):
154
+ batch_output(await speedport.devices)
155
+ else:
156
+ print(
157
+ data_table(
158
+ (await speedport.devices).values(),
159
+ ["ipv4", "name", "type", "connected"],
160
+ )
161
+ )
162
+ if args.get("calls"):
163
+ if args.get("batch"):
164
+ batch_output(await speedport.devices)
165
+ else:
166
+ print(
167
+ data_table(
168
+ await speedport.calls,
169
+ ["number", "type", "duration", "date"],
170
+ )
171
+ )
172
+
173
+
174
+ async def check_wifi_args(speedport, args):
175
+ if args.get("wifi") and args["wifi"] == "on":
176
+ await speedport.wifi_on()
177
+ elif args.get("wifi") and args["wifi"] == "off":
178
+ await speedport.wifi_off()
179
+ if args.get("guest-wifi") and args["guest-wifi"] == "on":
180
+ await speedport.wifi_guest_on()
181
+ elif args.get("guest-wifi") and args["guest-wifi"] == "off":
182
+ await speedport.wifi_guest_off()
183
+ if args.get("office-wifi") and args["office-wifi"] == "on":
184
+ await speedport.wifi_office_on()
185
+ elif args.get("office-wifi") and args["office-wifi"] == "off":
186
+ await speedport.wifi_office_off()
187
+
188
+
189
+ async def main():
190
+ args = get_arguments()
191
+ set_logger(args)
192
+ password = ""
193
+ if not args.get("devices"):
194
+ if not (password := args["password"]):
195
+ password = getpass("Password of Speedports webinterface: ")
196
+ async with Speedport(args["host"], password, args.get("https")) as speedport:
197
+ await check_wifi_args(speedport, args)
198
+ await check_args(speedport, args)
199
+
200
+
201
+ async def reconnect(args, speedport):
202
+ if not args.get("quiet"):
203
+ await speedport.update_ip_data()
204
+ _LOGGER.info(f"{speedport.public_ip_v4} / {speedport.public_ip_v6}")
205
+ await speedport.reconnect()
206
+ if not args.get("quiet"):
207
+ for i in range(240):
208
+ await speedport.update_ip_data()
209
+ if await speedport.online_status == "online":
210
+ _LOGGER.info(f"{speedport.public_ip_v4} / {speedport.public_ip_v6}")
211
+ break
212
+ await asyncio.sleep(0.5)
213
+ print(f"Connecting.{'.' * (i % 3)} ", end="\r", flush=True)
214
+
215
+
216
+ async def wps_enable(args, speedport):
217
+ await speedport.wps_on()
218
+ if not args.get("quiet"):
219
+ event = time.time()
220
+ next_time = event
221
+ while await speedport.wps_state == 1:
222
+ await asyncio.sleep(next_time - time.time())
223
+ next_time = time.time() + 1
224
+ print(
225
+ f"wps connect enabled for {120 - int(time.time() - event)}s...",
226
+ end="\r",
227
+ flush=True,
228
+ )
229
+
230
+
231
+ def start():
232
+ try:
233
+ asyncio.run(main())
234
+ except KeyboardInterrupt:
235
+ print("Aborted.")
236
+
237
+
238
+ if __name__ == "__main__":
239
+ start()
speedport/api.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ from datetime import datetime, timedelta
6
+
7
+ import aiohttp
8
+
9
+ from speedport import exceptions
10
+ from speedport.connection import Connection
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def need_auth(func):
16
+ @functools.wraps(func)
17
+ async def inner(self: "SpeedportApi", *args, **kwargs):
18
+ if not self.api.is_logged_in:
19
+ if not self.password:
20
+ error = f'You need to set a password to use "{func.__name__}"'
21
+ _LOGGER.error(error)
22
+ raise PermissionError(error)
23
+ await self.api.login(self.password)
24
+ try:
25
+ return await func(self, *args, **kwargs)
26
+ except exceptions.DecryptionKeyError as exception:
27
+ if not self.last_logout:
28
+ _LOGGER.info(f"Paused fetching for {self.pause_time} min")
29
+ self.last_logout = datetime.now()
30
+ if datetime.now() > (
31
+ time := self.last_logout + timedelta(minutes=self.pause_time)
32
+ ):
33
+ self.last_logout = None
34
+ await self.api.login(self.password)
35
+ return await func(self, *args, **kwargs)
36
+ remaining = time - datetime.now()
37
+ error = f"Paused for 00:{remaining.seconds // 60:02d}:{remaining.seconds % 60:02d}"
38
+ _LOGGER.debug(error)
39
+ raise exceptions.LoginPausedError(error) from exception
40
+
41
+ return inner
42
+
43
+
44
+ class SpeedportApi:
45
+ def __init__(
46
+ self,
47
+ host: str = "speedport.ip",
48
+ password: str = "",
49
+ https: bool = False,
50
+ session: aiohttp.ClientSession | None = None,
51
+ pause_time: int = 5,
52
+ ):
53
+ self._api: Connection | None = None
54
+ self._host: str = host
55
+ self._password: str = password
56
+ self._https: bool = https
57
+ self._url = f"https://{host}" if https else f"http://{host}"
58
+ self._session: aiohttp.ClientSession | None = session
59
+ self._pause_time: int = pause_time
60
+ self._last_logout: datetime | None = None
61
+
62
+ async def __aenter__(self):
63
+ return await self.create()
64
+
65
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
66
+ await self.close()
67
+
68
+ async def create(self):
69
+ self._api = await Connection(self._url, self._session).create()
70
+ return self
71
+
72
+ async def close(self):
73
+ if self._api:
74
+ await self._api.close()
75
+
76
+ @property
77
+ def api(self) -> Connection:
78
+ if self._api:
79
+ return self._api
80
+ raise ConnectionError()
81
+
82
+ @property
83
+ def password(self) -> str:
84
+ return self._password
85
+
86
+ @property
87
+ def url(self) -> str:
88
+ return self._url
89
+
90
+ @property
91
+ def pause_time(self) -> int:
92
+ return self._pause_time
93
+
94
+ @pause_time.setter
95
+ def pause_time(self, pause_time: int):
96
+ self._pause_time = pause_time
97
+
98
+ @property
99
+ def last_logout(self) -> datetime | None:
100
+ return self._last_logout
101
+
102
+ @last_logout.setter
103
+ def last_logout(self, last_logout: datetime | None):
104
+ self._last_logout = last_logout
105
+
106
+ @need_auth
107
+ async def get_secure_status(self):
108
+ return await self.api.get("data/SecureStatus.json", auth=True)
109
+
110
+ @need_auth
111
+ async def get_phone_handler(self):
112
+ return await self.api.get("data/IPPhoneHandler.json", auth=True)
113
+
114
+ async def get_router(self):
115
+ return await self.api.get("data/Router.json")
116
+
117
+ async def get_status(self):
118
+ return await self.api.get("data/Status.json")
119
+
120
+ async def get_devices(self):
121
+ return await self.api.get("data/DeviceList.json")
122
+
123
+ async def get_login(self):
124
+ return await self.api.get("data/Login.json")
125
+
126
+ @need_auth
127
+ async def get_ip_data(self):
128
+ referer = "html/content/internet/con_ipdata.html"
129
+ return await self.api.get("data/IPData.json", referer=referer, auth=True)
130
+
131
+ @need_auth
132
+ async def get_wps_state(self):
133
+ referer = "html/content/network/wlan_wps.html"
134
+ return await self.api.get("data/WPSStatus.json", referer=referer)
135
+
136
+ @need_auth
137
+ async def get_phone_calls(self):
138
+ referer = "html/content/phone/phone_call_taken.html"
139
+ return await self.api.get("data/PhoneCalls.json", referer=referer, auth=True)
140
+
141
+ @need_auth
142
+ async def set_wifi(self, status=True, guest=False, office=False):
143
+ """Set wifi on/off"""
144
+ extra = "guest" if guest else "office" if office else ""
145
+ _LOGGER.info(
146
+ "Turn %s %s wifi...", ["off", "on"][bool(status)], extra if extra else ""
147
+ )
148
+ data = (
149
+ {f"wlan_{extra}_active": str(int(status))}
150
+ if extra
151
+ else {"use_wlan": str(int(status))}
152
+ )
153
+ referer = f"html/content/network/wlan_{extra if extra else 'basic'}.html"
154
+ return await self.api.post(
155
+ f"data/{'WLANBasic' if extra else 'Modules'}.json", data, referer
156
+ )
157
+
158
+ @need_auth
159
+ async def wps_on(self):
160
+ _LOGGER.info("Enable wps connect...")
161
+ await self.api.post(
162
+ "data/WLANAccess.json",
163
+ {"wlan_add": "on", "wps_key": "connect"},
164
+ "html/content/network/wlan_wps.html",
165
+ )
166
+
167
+ @need_auth
168
+ async def reconnect(self):
169
+ _LOGGER.info("Reconnect with internet provider...")
170
+ await self.api.post(
171
+ "data/Connect.json",
172
+ {"req_connect": "reconnect"},
173
+ "html/content/internet/con_ipdata.html",
174
+ )
175
+
176
+ @need_auth
177
+ async def reboot(self):
178
+ _LOGGER.info("Reboot speedport...")
179
+ await self.api.post(
180
+ "data/Reboot.json",
181
+ {"reboot_device": "true"},
182
+ "html/content/config/restart.html",
183
+ )
184
+
185
+ async def login(self, password=""):
186
+ return await self.api.login(password or self.password)
speedport/call.py ADDED
@@ -0,0 +1,46 @@
1
+ from datetime import datetime
2
+
3
+
4
+ class Call:
5
+ def __init__(self, data, call_type):
6
+ self._data = data
7
+ self._call_type = call_type
8
+
9
+ def __str__(self):
10
+ return self.number
11
+
12
+ def __repr__(self):
13
+ return f"{self.__class__} ({self.__dict__})"
14
+
15
+ def __getitem__(self, item):
16
+ try:
17
+ return self.__getattribute__(item)
18
+ except AttributeError:
19
+ return self._data.get(
20
+ f"{self._call_type}_{item}", self.__getattribute__(item)
21
+ )
22
+
23
+ @property
24
+ def data(self):
25
+ return self._data
26
+
27
+ @property
28
+ def type(self):
29
+ return self._call_type.replace("calls", "")
30
+
31
+ @property
32
+ def connection(self):
33
+ return self._data.get(f"{self._call_type}_as")
34
+
35
+ @property
36
+ def duration(self):
37
+ return int(self._data.get(f"{self._call_type}_duration", 0))
38
+
39
+ @property
40
+ def date(self):
41
+ date = f'{self._data.get(f"{self._call_type}_date")} {self._data.get(f"{self._call_type}_time")}'
42
+ return datetime.strptime(date, "%d.%m.%y %H:%M:%S")
43
+
44
+ @property
45
+ def number(self):
46
+ return self._data.get(f"{self._call_type}_who")
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import re
7
+ from hashlib import sha256
8
+ from json import JSONDecodeError
9
+
10
+ import aiohttp
11
+ from Crypto.Cipher import AES
12
+
13
+ from speedport import const, exceptions
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ def decode(data, key: str = "") -> dict[str, list | dict | str] | str:
19
+ """Decode speedport's self-implemented encryption"""
20
+ key = key or const.DEFAULT_KEY
21
+ try:
22
+ ciphertext_tag = bytes.fromhex(data)
23
+ except ValueError as exc:
24
+ raise exceptions.DecryptionKeyError("Wrong decryption key") from exc
25
+ cipher = AES.new(bytes.fromhex(key), AES.MODE_CCM, bytes.fromhex(key)[:8])
26
+ decrypted = cipher.decrypt_and_verify(ciphertext_tag[:-16], ciphertext_tag[-16:])
27
+ try:
28
+ return simplify_response(json.loads(decrypted.decode()))
29
+ except JSONDecodeError:
30
+ return decrypted.decode()
31
+
32
+
33
+ def encode(data, key: str = "") -> str:
34
+ """Encode for speedport's self-implemented encryption"""
35
+ key = key or const.DEFAULT_KEY
36
+ cipher = AES.new(bytes.fromhex(key), AES.MODE_CCM, bytes.fromhex(key)[:8])
37
+ ciphertext, tag = cipher.encrypt_and_digest(data.encode())
38
+ return ciphertext.hex() + tag.hex()
39
+
40
+
41
+ def simplify_response(
42
+ data: list[dict[str, str | list[dict[str, str]]]],
43
+ ) -> dict[str, list | dict | str]:
44
+ """Remove 'varid' and 'varvalue' to have a flatter data structure without overhead"""
45
+ result = {}
46
+ for item in data:
47
+ if isinstance(item["varvalue"], list):
48
+ list_item = {}
49
+ for sub_item in item["varvalue"]:
50
+ list_item[sub_item["varid"]] = sub_item["varvalue"]
51
+ result.setdefault(item["varid"], []).append(list_item)
52
+ else:
53
+ result[item["varid"]] = item["varvalue"]
54
+ return result
55
+
56
+
57
+ class Connection:
58
+ def __init__(
59
+ self,
60
+ host_url: str = "",
61
+ session: aiohttp.ClientSession | None = None,
62
+ ):
63
+ self._login_key = ""
64
+ self._url = host_url
65
+ self._own_session = session is None
66
+ self._session: aiohttp.ClientSession | None = session
67
+ self._verify_ssl: bool = False
68
+ self._timeout: float = 30
69
+
70
+ async def __aenter__(self):
71
+ return await self.create()
72
+
73
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
74
+ await self.close()
75
+
76
+ @property
77
+ def _kwargs(self):
78
+ return {"verify_ssl": self._verify_ssl, "timeout": self._timeout}
79
+
80
+ async def create(self) -> Connection:
81
+ """Create aiohttp session"""
82
+ connector = aiohttp.TCPConnector(verify_ssl=self._verify_ssl)
83
+ if not self._session:
84
+ jar = aiohttp.CookieJar(unsafe=True)
85
+ self._session = aiohttp.ClientSession(connector=connector, cookie_jar=jar)
86
+ else:
87
+ self._session.cookie_jar._unsafe = True
88
+ return self
89
+
90
+ async def close(self):
91
+ """Close aiohttp session"""
92
+ if self._own_session:
93
+ await self._session.close()
94
+
95
+ async def get(self, path: str, auth: bool = False, referer: str = ""):
96
+ url = f"{self._url}/{path}"
97
+ if referer:
98
+ referer = f"{self._url}/{referer}"
99
+ if token := await self._get_httoken(referer):
100
+ url += f"?_tn={token}"
101
+ async with self._session.get(
102
+ url, headers={"Referer": referer}, **self._kwargs
103
+ ) as response:
104
+ _LOGGER.debug("GET - %s - %s", url, response.status)
105
+ key = self._login_key if auth else const.DEFAULT_KEY
106
+ return decode(await response.text(), key=key)
107
+
108
+ async def post(self, path: str, data: dict[str, str], referer: str):
109
+ url = f"{self._url}/{path}"
110
+ referer = f"{self._url}/{referer}"
111
+ if token := await self._get_httoken(referer):
112
+ data.update({"httoken": token})
113
+ data = "&".join([f"{k}={v}" for k, v in data.items()])
114
+ data = encode(data, key=self._login_key)
115
+ async with self._session.post(
116
+ url, headers={"Referer": referer}, data=data, **self._kwargs
117
+ ) as response:
118
+ _LOGGER.debug("POST - %s - %s", url, response.status)
119
+ return decode(await response.text(), key=self._login_key)
120
+
121
+ async def _get_httoken(self, url: str) -> str:
122
+ async with self._session.get(url, **self._kwargs) as response:
123
+ _LOGGER.debug("GET - %s - %s", url, response.status)
124
+ if token := re.findall("httoken = (\\d+)", await response.text()):
125
+ return token[0]
126
+ return ""
127
+
128
+ async def _get_login_key(self):
129
+ data = {"getChallenge": "1"}
130
+ if response := await self.post("data/Login.json", data, "/"):
131
+ self._login_key = response["challenge"]
132
+ return self._login_key
133
+
134
+ async def login(self, password: str):
135
+ url = f"{self._url}/data/Login.json"
136
+ self.clear()
137
+ login_data = f"{await self._get_login_key()}:{password}".encode()
138
+ login_key = sha256(login_data).hexdigest()
139
+ data = encode("showpw=0&password=" + login_key)
140
+ async with self._session.post(url, data=data, **self._kwargs) as response:
141
+ _LOGGER.debug("POST - %s - %s", url, response.status)
142
+ result = decode(await response.text())
143
+ _LOGGER.debug(result)
144
+ if (sleep_time := result.get("login_locked")) and int(sleep_time) < 60:
145
+ _LOGGER.warning("Can't login, wait %ss...", sleep_time)
146
+ await asyncio.sleep(int(sleep_time) + 1)
147
+ return await self.login(password)
148
+ if result["login"] != "success":
149
+ _LOGGER.error(result)
150
+ raise exceptions.LoginException("Can't login")
151
+ return result["login"]
152
+
153
+ @property
154
+ def is_logged_in(self):
155
+ return bool(self._login_key)
156
+
157
+ def clear(self):
158
+ self._login_key = ""
159
+ self._session.cookie_jar.clear_domain(self._url)
speedport/const.py ADDED
@@ -0,0 +1,2 @@
1
+ # Is this the default key for everyone or should we parse it?
2
+ DEFAULT_KEY = "cdc0cac1280b516e674f0057e4929bca84447cca8425007e33a88a5cf598a190"
speedport/device.py ADDED
@@ -0,0 +1,87 @@
1
+ class WlanDevice:
2
+ def __init__(self, data):
3
+ self._data = data
4
+
5
+ def __str__(self):
6
+ return self.name
7
+
8
+ def __repr__(self):
9
+ return f"{self.__class__} ({self.__dict__})"
10
+
11
+ def __getitem__(self, item):
12
+ try:
13
+ return self.__getattribute__(item)
14
+ except AttributeError:
15
+ return self._data.get(f"mdevice_{item}", self._data.get(item))
16
+
17
+ @property
18
+ def data(self):
19
+ return self._data
20
+
21
+ @property
22
+ def connected(self):
23
+ return bool(int(self._data.get("mdevice_connected") or "0"))
24
+
25
+ @property
26
+ def downspeed(self):
27
+ return int(self._data.get("mdevice_downspeed") or "0")
28
+
29
+ @property
30
+ def fix_dhcp(self):
31
+ return bool(int(self._data.get("mdevice_fix_dhcp") or "0"))
32
+
33
+ @property
34
+ def gua_ipv6(self):
35
+ return self._data.get("mdevice_gua_ipv6")
36
+
37
+ @property
38
+ def hasui(self):
39
+ return int(self._data.get("mdevice_hasui") or "0")
40
+
41
+ @property
42
+ def ipv4(self):
43
+ return self._data.get("mdevice_ipv4")
44
+
45
+ @property
46
+ def mac(self):
47
+ return self._data.get("mdevice_mac")
48
+
49
+ @property
50
+ def name(self):
51
+ return self._data.get("mdevice_name")
52
+
53
+ @property
54
+ def reservedip(self):
55
+ return self._data.get("mdevice_reservedip")
56
+
57
+ @property
58
+ def rssi(self):
59
+ return int(self._data.get("mdevice_rssi") or "0")
60
+
61
+ @property
62
+ def slave(self):
63
+ return bool(int(self._data.get("mdevice_slave") or "0"))
64
+
65
+ @property
66
+ def type(self):
67
+ return ["lan", "wlan", "wlan5"][int(self._data.get("mdevice_type", "0")) % 3]
68
+
69
+ @property
70
+ def ula_ipv6(self):
71
+ return self._data.get("mdevice_ula_ipv6")
72
+
73
+ @property
74
+ def upspeed(self):
75
+ return int(self._data.get("mdevice_upspeed") or "0")
76
+
77
+ @property
78
+ def use_dhcp(self):
79
+ return bool(int(self._data.get("mdevice_use_dhcp") or "0"))
80
+
81
+ @property
82
+ def wifi(self):
83
+ return int(self._data.get("mdevice_wifi") or "0")
84
+
85
+ @property
86
+ def id(self):
87
+ return int(self._data.get("id") or "0")
@@ -0,0 +1,10 @@
1
+ class LoginException(Exception):
2
+ pass
3
+
4
+
5
+ class DecryptionKeyError(PermissionError):
6
+ pass
7
+
8
+
9
+ class LoginPausedError(PermissionError):
10
+ pass
speedport/speedport.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import suppress
5
+ from datetime import datetime
6
+
7
+ import aiohttp
8
+
9
+ from . import exceptions
10
+ from .api import SpeedportApi
11
+ from .call import Call
12
+ from .device import WlanDevice
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+ _LOGGER.setLevel(logging.WARNING)
16
+
17
+
18
+ class LoginException(Exception):
19
+ pass
20
+
21
+
22
+ class Speedport:
23
+ def __init__(
24
+ self,
25
+ host: str = "speedport.ip",
26
+ password: str = "",
27
+ https: bool = False,
28
+ session: aiohttp.ClientSession | None = None,
29
+ pause_time: int = 5,
30
+ ):
31
+ self._api: SpeedportApi | None = None
32
+ self._host: str = host
33
+ self._password: str = password
34
+ self._https: bool = https
35
+ self._session: aiohttp.ClientSession | None = session
36
+ self._status = {}
37
+ self._ip_data = {}
38
+ self._pause_time = pause_time
39
+
40
+ async def __aenter__(self):
41
+ return await self.create()
42
+
43
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
44
+ await self.close()
45
+
46
+ def __getitem__(self, item):
47
+ if (status := self._status.get(item)) is not None:
48
+ return status
49
+ if (ip_data := self._ip_data.get(item)) is not None:
50
+ return ip_data
51
+ raise KeyError
52
+
53
+ def get(self, item, default=None):
54
+ try:
55
+ return self[item]
56
+ except KeyError:
57
+ return default
58
+
59
+ async def create(self):
60
+ self._api = await SpeedportApi(
61
+ self._host, self._password, self._https, self._session, self._pause_time
62
+ ).create()
63
+ await self.update_status()
64
+ return self
65
+
66
+ async def close(self):
67
+ if self._api:
68
+ await self._api.close()
69
+
70
+ def set_pause_time(self, pause_time: int):
71
+ if self._api:
72
+ self._api.pause_time = pause_time
73
+ self._pause_time = pause_time
74
+
75
+ @property
76
+ def api(self) -> SpeedportApi:
77
+ if self._api:
78
+ return self._api
79
+ raise ConnectionError()
80
+
81
+ @property
82
+ async def devices(self) -> dict[str, WlanDevice]:
83
+ data = await self.api.get_devices()
84
+ devices = data.get("addmlandevice", []) + data.get("addmwlan5device", [])
85
+ devices += data.get("addmwlandevice", []) + data.get("addmdevice", [])
86
+ devices = sorted(devices, key=lambda d: int(d["mdevice_ipv4"].split(".")[-1]))
87
+ return {device.get("mdevice_mac"): WlanDevice(device) for device in devices}
88
+
89
+ @property
90
+ async def calls(self) -> list[Call]:
91
+ data = await self.api.get_phone_calls()
92
+ types = ["dialedcalls", "missedcalls", "takencalls"]
93
+ result: list[Call] = []
94
+ for call_type in types:
95
+ for call in data.get(f"add{call_type}"):
96
+ result.append(Call(call, call_type))
97
+ result.sort(key=lambda d: d.date)
98
+ return result
99
+
100
+ async def wifi_on(self):
101
+ await self.api.set_wifi(status=True, guest=False)
102
+
103
+ async def wifi_off(self):
104
+ await self.api.set_wifi(status=False, guest=False)
105
+
106
+ async def wps_on(self):
107
+ await self.api.wps_on()
108
+
109
+ async def reconnect(self):
110
+ await self.api.reconnect()
111
+
112
+ async def reboot(self):
113
+ await self.api.reboot()
114
+
115
+ async def wifi_guest_on(self):
116
+ await self.api.set_wifi(status=True, guest=True)
117
+
118
+ async def wifi_guest_off(self):
119
+ await self.api.set_wifi(status=False, guest=True)
120
+
121
+ async def wifi_office_on(self):
122
+ await self.api.set_wifi(status=True, office=True)
123
+
124
+ async def wifi_office_off(self):
125
+ await self.api.set_wifi(status=False, office=True)
126
+
127
+ async def update_ip_data(self):
128
+ with suppress(exceptions.LoginPausedError):
129
+ self._ip_data = await self.api.get_ip_data()
130
+
131
+ @property
132
+ async def ip_info(self) -> dict:
133
+ """Fetch IPv4/IPv6 addresses, gateways, DNS servers and IPv6 prefixes."""
134
+ self._ip_data = await self.api.get_ip_data()
135
+ return self._ip_data.copy()
136
+
137
+ async def update_status(self):
138
+ self._status = await self.api.get_status()
139
+
140
+ @property
141
+ def device_name(self):
142
+ return self._status.get("device_name", "")
143
+
144
+ @property
145
+ def serial_number(self):
146
+ return self._status.get("serial_number", "")
147
+
148
+ @property
149
+ def firmware_version(self):
150
+ return self._status.get("firmware_version", "")
151
+
152
+ @property
153
+ def dsl_downstream(self):
154
+ return int(self._status.get("dsl_downstream", "0"))
155
+
156
+ @property
157
+ def dsl_upstream(self):
158
+ return int(self._status.get("dsl_upstream", "0"))
159
+
160
+ @property
161
+ def inet_download(self):
162
+ return int(self._status.get("inet_download", "0"))
163
+
164
+ @property
165
+ def inet_upload(self):
166
+ return int(self._status.get("inet_upload", "0"))
167
+
168
+ @property
169
+ def inet_uptime(self):
170
+ return datetime.fromisoformat(self._status.get("inet_uptime"))
171
+
172
+ @property
173
+ def online_status(self):
174
+ return self._status.get("onlinestatus", "")
175
+
176
+ @property
177
+ def login_state(self):
178
+ return bool(self._status.get("loginstate"))
179
+
180
+ @property
181
+ def rebooting(self):
182
+ return bool(self._status.get("rebooting"))
183
+
184
+ @property
185
+ def wlan_active(self):
186
+ return bool(int(self._status.get("use_wlan")))
187
+
188
+ @property
189
+ def wlan_guest_active(self):
190
+ return bool(int(self._status.get("wlan_guest_active")))
191
+
192
+ @property
193
+ def wlan_office_active(self):
194
+ return bool(int(self._status.get("wlan_office_active")))
195
+
196
+ @property
197
+ def wlan_ssid(self):
198
+ return self._status.get("wlan_ssid")
199
+
200
+ @property
201
+ def wlan_guest_ssid(self):
202
+ return self._status.get("wlan_guest_ssid")
203
+
204
+ @property
205
+ def wlan_office_ssid(self):
206
+ return self._status.get("wlan_office_ssid")
207
+
208
+ @property
209
+ def dns_v4(self):
210
+ return self._ip_data.get("dns_v4", "")
211
+
212
+ @property
213
+ def dns_v6(self):
214
+ return self._ip_data.get("dns_v6", "")
215
+
216
+ @property
217
+ def gateway_ip_v4(self):
218
+ return self._ip_data.get("gateway_ip_v4", "")
219
+
220
+ @property
221
+ def gateway_ip_v6(self):
222
+ return self._ip_data.get("gateway_ip_v6", "")
223
+
224
+ @property
225
+ def public_ip_v4(self):
226
+ return self._ip_data.get("public_ip_v4", "")
227
+
228
+ @property
229
+ def public_ip_v6(self):
230
+ return self._ip_data.get("public_ip_v6", "")
231
+
232
+ @property
233
+ def sec_dns_v4(self):
234
+ return self._ip_data.get("sec_dns_v4", "")
235
+
236
+ @property
237
+ def sec_dns_v6(self):
238
+ return self._ip_data.get("sec_dns_v6", "")
239
+
240
+ @property
241
+ def transmitted_ip_v6_pool_for_lan(self):
242
+ return self._ip_data.get("transmitted_ip_v6_pool_for_lan", "")
243
+
244
+ @property
245
+ def used_ip_v6_lan(self):
246
+ return self._ip_data.get("used_ip_v6_lan", "")
247
+
248
+ @property
249
+ async def wps_remaining(self):
250
+ return int((await self.api.get_wps_state())["wlan_wps_state"])
251
+
252
+ async def login(self, password=""):
253
+ return await self.api.login(password or self._password)
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: speedport-api-expanded
3
+ Version: 0.7.0
4
+ Summary: Control Telekom Speedport routers with Python
5
+ Home-page: https://github.com/kplrm/speedport-api-expanded
6
+ Author: Andre Basche, Diego Guillen-Rosaperez
7
+ Maintainer: Diego Guillen-Rosaperez
8
+ License: MIT
9
+ Project-URL: GitHub, https://github.com/kplrm/speedport-api-expanded
10
+ Project-URL: PyPI, https://pypi.org/project/speedport-api-expanded/
11
+ Project-URL: Upstream, https://github.com/Andre0512/speedport-api
12
+ Platform: any
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.8
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: aiohttp>=3.8
30
+ Requires-Dist: pycryptodome>=3.18
31
+ Dynamic: author
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: license
37
+ Dynamic: license-file
38
+ Dynamic: maintainer
39
+ Dynamic: platform
40
+ Dynamic: project-url
41
+ Dynamic: requires-dist
42
+ Dynamic: requires-python
43
+ Dynamic: summary
44
+
45
+ **This python package is unofficial and is not related in any way to Telekom. It was developed by reversed engineered http requests and can stop working at anytime!**
46
+
47
+ ## Speedport-API
48
+ [![PyPI - Status](https://img.shields.io/pypi/status/speedport-api-expanded)](https://pypi.org/project/speedport-api-expanded/)
49
+ [![PyPI](https://img.shields.io/pypi/v/speedport-api-expanded?color=blue)](https://pypi.org/project/speedport-api-expanded/)
50
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/speedport-api-expanded)](https://www.python.org/)
51
+ [![PyPI - License](https://img.shields.io/pypi/l/speedport-api-expanded)](https://github.com/kplrm/speedport-api-expanded/blob/main/LICENSE)
52
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/speedport-api-expanded)](https://pypistats.org/packages/speedport-api-expanded)
53
+
54
+ Control Telekom Speedport routers with Python!
55
+
56
+ ### Installation
57
+ ```commandline
58
+ pip install speedport-api-expanded
59
+ ```
60
+
61
+ For this fork's changes, install from a checkout with `python -m pip install -e .`
62
+ or download a wheel from [GitHub releases](https://github.com/kplrm/speedport-api-expanded/releases).
63
+ The Python import remains `from speedport import Speedport`.
64
+ See [local development and adding features](docs/development.md),
65
+ [contribution guidelines](CONTRIBUTING.md), and [contributors](CONTRIBUTORS.md).
66
+ Store local router credentials in an
67
+ ignored `.env` copied from [.env.example](.env.example); see the guide for loading it.
68
+
69
+ ### Supported Devices
70
+ * Speedport Smart 4
71
+
72
+ ### Commandline tool
73
+ With this shipped commandline tool can a speedport in your network be controlled.
74
+
75
+ #### Turn wifi off
76
+ ```commandline
77
+ $ speedport wifi off
78
+ Turn off wifi...
79
+ ```
80
+
81
+ #### Turn guest wifi on
82
+ ```commandline
83
+ $ speedport guest-wifi on
84
+ Turn on guest wifi...
85
+ ```
86
+
87
+ #### Reconnect for new ip address
88
+ ```commandline
89
+ $ speedport reconnect
90
+ 123.45.67.89 / 5403:f3:35aa:12f:7287:41cf:fb1c:3c83
91
+ Reconnect with internet provider...
92
+ 123.45.67.12 / 5403:f3:35fe:12f:7287:41cf:fb1c:3c83
93
+ ```
94
+
95
+ #### Query IPv4 and IPv6 information
96
+ ```commandline
97
+ speedport --host 192.168.2.1 ip-info
98
+ speedport --host 192.168.2.1 --batch ip-info
99
+ ```
100
+ Prints addresses, gateways, primary/secondary DNS servers, and IPv6 prefixes.
101
+ Batch output uses tab-separated field/value pairs. A password is prompted for
102
+ unless `SPEEDPORT_PASSWORD` is set.
103
+
104
+ To print only the public IPv4 address, without a label or table:
105
+ ```commandline
106
+ python -m speedport --host 192.168.2.1 ip-info --ipv4
107
+ 93.218.250.118
108
+ ```
109
+ If no public IPv4 address is available, the command exits with an error.
110
+
111
+ #### Enable wps connect
112
+ ```commandline
113
+ $ speedport wps
114
+ Enable wps connect...
115
+ wps connect enabled for 113s...
116
+ ```
117
+
118
+ #### Reboot device
119
+ ```commandline
120
+ $ speedport reboot
121
+ Reboot speedport...
122
+ ```
123
+
124
+ #### Print devices
125
+ ```commandline
126
+ $ speedport devices
127
+ +-------------+---------------------+-------+-----------+
128
+ | ipv4 | name | type | connected |
129
+ +-------------+---------------------+-------+-----------+
130
+ | 10.5.12.32 | Google-Home-Mini-1 | wlan | 1 |
131
+ | 10.5.12.157 | PC10-5-12-157 | lan | 0 |
132
+ | 10.5.12.227 | andre-xps | wlan5 | 1 |
133
+ ```
134
+
135
+
136
+ #### Print calls
137
+ ```commandline
138
+ $ speedport calls
139
+ +-------------+--------+----------+---------------------+
140
+ | number | type | duration | date |
141
+ +-------------+--------+----------+---------------------+
142
+ | 01578212345 | missed | 0 | 2024-04-04 09:34:35 |
143
+ | 026361245 | taken | 1337 | 2024-04-06 05:12:53 |
144
+ | 7866 | dialed | 20 | 2024-04-06 18:39:00 |
145
+ ```
146
+
147
+ ### Library
148
+
149
+ #### IP information example
150
+ ```python
151
+ import asyncio
152
+ import os
153
+ from speedport import Speedport
154
+
155
+ async def ip_info():
156
+ async with Speedport(
157
+ os.environ.get("SPEEDPORT_HOST", "192.168.2.1"),
158
+ os.environ["SPEEDPORT_PASSWORD"],
159
+ ) as router:
160
+ info = await router.ip_info
161
+ print(info["public_ip_v4"], info["public_ip_v6"])
162
+ print(info) # All fields, including DNS servers and IPv6 prefixes.
163
+
164
+ asyncio.run(ip_info())
165
+ ```
166
+
167
+ #### Reconnect example
168
+ ```python
169
+ import asyncio
170
+ from speedport import Speedport
171
+
172
+ async def reconnect():
173
+ speedport = Speedport("192.168.1.1")
174
+ await speedport.login("password123")
175
+ await speedport.reconnect()
176
+
177
+ asyncio.run(reconnect())
178
+ ```
179
+
180
+ #### Devices example
181
+ ```python
182
+ import asyncio
183
+ from speedport import Speedport
184
+
185
+ devices = asyncio.run(Speedport().devices)
186
+ for device in devices:
187
+ print(device.ipv4, device.connected)
188
+ ```
@@ -0,0 +1,15 @@
1
+ speedport/__init__.py,sha256=emHP7xbzqtem60b6_C2V-8Iks9eCwB8iWFdJirC2hPU,58
2
+ speedport/__main__.py,sha256=37TJblmz-CM6jybaLkHdqiDQv0oe7Wz2pK8gkxWx3D0,7765
3
+ speedport/api.py,sha256=sAWBA12UZL1Id01ptHMLAG7IGyxT-MKYnt2x7PPsSog,5824
4
+ speedport/call.py,sha256=OopvZcyz6n49ngLL2PezTTaN-UGtZil1aomMGVQdyYM,1169
5
+ speedport/connection.py,sha256=yYxBoDxRemV7cFUdFs9yIlTCLm81bK-MVR7ZgrtEWkE,5877
6
+ speedport/const.py,sha256=TFSJKaDPKUdOnepb2MUzpe1ecnaMOtMvoN0dBFUUwp8,143
7
+ speedport/device.py,sha256=XvOkcg0sxaRIGJKp_Z1VGo6TQJSOIPngNFFyIQ2IdDI,2083
8
+ speedport/exceptions.py,sha256=RLqgPTXKEE1m20egEgvei3mrOMT9sqjCj4X8iRv5P_g,148
9
+ speedport/speedport.py,sha256=ydpxvWjSfP9acxpeHBucf_uLMBIUXHnSpl0C4FyPAaE,6958
10
+ speedport_api_expanded-0.7.0.dist-info/licenses/LICENSE,sha256=j4F0U6fAmiZRpjNhTlP7Q6PiK_3kHxyoEgB6ura82aA,1112
11
+ speedport_api_expanded-0.7.0.dist-info/METADATA,sha256=4QHCELF8QDY419qMlRsPYOxXxSyByl42Fi16AGwDgUI,6028
12
+ speedport_api_expanded-0.7.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ speedport_api_expanded-0.7.0.dist-info/entry_points.txt,sha256=iMYZzg3YJVhauEu-I6QC8lQHvQEdueA4gjwriyHDYnY,55
14
+ speedport_api_expanded-0.7.0.dist-info/top_level.txt,sha256=9AObLD9lR2H0X-nJfCceDaKNdR5y2PV55HveX05H-b8,10
15
+ speedport_api_expanded-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ speedport = speedport.__main__:start
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Andre Basche
4
+ Copyright (c) 2026 Diego Guillen-Rosaperez
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ speedport