tinytoolslib 0.2.5__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.
@@ -0,0 +1 @@
1
+ from .__version__ import __version__
@@ -0,0 +1 @@
1
+ __version__ = '0.2.5'
@@ -0,0 +1,14 @@
1
+ """Constants related to tinycontrol devices."""
2
+
3
+ # Discovery related stuff
4
+ LK_UDP_PORT = 30403
5
+ LK_UDP_BOOTLOADER_MSG = b"\x12\xf4\x81"
6
+ LK_UDP_DISCOVERY_MSG = b"Discovery: Who is out there?"
7
+
8
+ # Device families. PS and DCDC are pretty much the same as LK (UI differs)
9
+ FAMILY_LK = "LK"
10
+ FAMILY_PS = "PS" # Power socket
11
+ FAMILY_DCDC = "DCDC" # Converter DC/DC
12
+ FAMILY_TCPDU = "tcPDU"
13
+
14
+ FW_URL_TEMPLATE = "https://tinycontrol.pl/firmware/{}/latest/"
@@ -0,0 +1,167 @@
1
+ import concurrent.futures
2
+ import logging
3
+ import socket
4
+ import socketserver
5
+ import threading
6
+ import time
7
+
8
+ import netifaces
9
+
10
+ from tinytoolslib.constants import LK_UDP_DISCOVERY_MSG, LK_UDP_PORT
11
+ from tinytoolslib.models import detect_version, get_device_info
12
+
13
+
14
+ def get_ips():
15
+ """Return list of IPs to check."""
16
+ try:
17
+ gateways = netifaces.gateways()
18
+ interfaces = []
19
+ for key, value in gateways.items():
20
+ if key != "default":
21
+ for item in value:
22
+ interfaces.append(item[1])
23
+ addresses = set()
24
+ for interface in interfaces:
25
+ addresses_tmp = netifaces.ifaddresses(interface).get(2)
26
+ if addresses_tmp:
27
+ for addr in addresses_tmp:
28
+ addresses.add(addr["addr"])
29
+ except ValueError:
30
+ addresses = socket.gethostbyname_ex(socket.gethostname())[2]
31
+ return addresses
32
+
33
+
34
+ def run_discovery_single(address, timelimit=3):
35
+ """Run discovery for given address (tuple) in timelimit."""
36
+ devices = []
37
+ with DiscoveryServer(address, DiscoveryHandler) as server:
38
+ server_thread = threading.Thread(target=server.serve_forever)
39
+ server_thread.start()
40
+ time.sleep(timelimit)
41
+ server.shutdown()
42
+ devices = server.devices.copy()
43
+ del server_thread
44
+ return devices
45
+
46
+
47
+ def serve_forever(server):
48
+ """Wrapper for serve_forever method of server."""
49
+ server.serve_forever()
50
+
51
+
52
+ def run_discovery_all(timelimit=3, port=LK_UDP_PORT, version=2, addresses=None):
53
+ """Run discovery on all available addresses.
54
+
55
+ `version` - 1 and 2 are parallel, where time execution of 2
56
+ is closer to timelimit; 3 is sequential run;
57
+ """
58
+ if addresses is None:
59
+ addresses = [ip for ip in get_ips() if not ip.startswith("169.254")]
60
+ devices = []
61
+ if version == 1:
62
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
63
+ servers = [
64
+ DiscoveryServer((address, port), DiscoveryHandler)
65
+ for address in addresses
66
+ ]
67
+ executor.map(serve_forever, servers)
68
+ time.sleep(timelimit)
69
+ for server in servers:
70
+ server.shutdown()
71
+ devices.extend(server.devices)
72
+ elif version == 2:
73
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
74
+ servers = [
75
+ DiscoveryServerAuto(
76
+ (address, port), DiscoveryHandler, timelimit=timelimit
77
+ )
78
+ for address in addresses
79
+ ]
80
+ futures = {
81
+ executor.submit(server.serve_forever): server for server in servers
82
+ }
83
+ for future in concurrent.futures.as_completed(futures):
84
+ try:
85
+ data = future.result()
86
+ except Exception as exc:
87
+ logging.warning("discovery error: %s", str(exc))
88
+ else:
89
+ devices.extend(data)
90
+ else:
91
+ for address in addresses:
92
+ temp = run_discovery_single((address, port))
93
+ devices.extend(temp)
94
+ return devices
95
+
96
+
97
+ class DiscoveryHandler(socketserver.BaseRequestHandler):
98
+ """Handler for LK discovery server."""
99
+
100
+ def handle(self):
101
+ data = self.request[0].strip()
102
+ if self.server.server_address[0] != self.client_address[0]:
103
+ try:
104
+ device_response = data.decode(errors="ignore").splitlines()
105
+ device_data = {
106
+ "ip_address": self.client_address[0],
107
+ "name": device_response[0],
108
+ "mac_address": device_response[1].replace("-", ":"),
109
+ "software_version": None,
110
+ "hardware_version": None,
111
+ }
112
+ if len(device_response) == 4:
113
+ device_data["software_version"] = device_response[2]
114
+ device_data["hardware_version"] = device_response[3]
115
+ else:
116
+ # LK2.0/2.5 response do not include hardware_version, so detect it.
117
+ device_data["software_version"] = device_response[2][:-1]
118
+ device_data["hardware_version"] = detect_version(
119
+ device_data["software_version"]
120
+ )
121
+ device_data.update(
122
+ get_device_info(
123
+ device_data["hardware_version"],
124
+ device_data["software_version"],
125
+ asdict_=True,
126
+ )
127
+ )
128
+ self.server.devices.append(device_data)
129
+ except (UnicodeDecodeError, IndexError):
130
+ pass
131
+
132
+
133
+ class DiscoveryServer(socketserver.UDPServer):
134
+ """Server for finding LKs in networks."""
135
+
136
+ def __init__(self, *args, **kwargs):
137
+ super().__init__(*args, **kwargs)
138
+ self.devices = []
139
+
140
+ def server_activate(self):
141
+ """Send discovery message after activation."""
142
+ super().server_activate()
143
+ dst_ip = ".".join(self.server_address[0].split(".")[:3]) + ".255"
144
+ dst_port = self.server_address[1]
145
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
146
+ self.socket.sendto(LK_UDP_DISCOVERY_MSG, (dst_ip, dst_port))
147
+
148
+
149
+ class DiscoveryServerAuto(DiscoveryServer):
150
+ """DiscoveryServer that autmatically shuts down."""
151
+
152
+ def __init__(self, *args, **kwargs):
153
+ self.started_at = time.time()
154
+ self.timelimit = kwargs.pop("timelimit", 3)
155
+ super().__init__(*args, **kwargs)
156
+
157
+ def service_actions(self):
158
+ super().service_actions()
159
+ now = time.time()
160
+ if now - self.started_at >= self.timelimit:
161
+ if not self._BaseServer__shutdown_request:
162
+ self._BaseServer__shutdown_request = True
163
+
164
+ def serve_forever(self, *args, **kwargs):
165
+ """Return devices list after auto shutdown."""
166
+ super().serve_forever(*args, **kwargs)
167
+ return self.devices
@@ -0,0 +1,49 @@
1
+ """Exceptions for tinyToolsLib."""
2
+
3
+
4
+ class TinyToolsError(Exception):
5
+ """Generic tinyTools exception."""
6
+
7
+
8
+ class TinyToolsUnsupported(TinyToolsError):
9
+ """Unsupported action of tinycontrol device."""
10
+
11
+
12
+ # region Request related errors
13
+ class TinyToolsRequestError(TinyToolsError):
14
+ """Base exception for request errors."""
15
+
16
+
17
+ class TinyToolsRequestTimeout(TinyToolsRequestError):
18
+ """Request timed out while trying to connect to server."""
19
+
20
+
21
+ class TinyToolsRequestConnectionError(TinyToolsRequestError):
22
+ """Connection error occurred."""
23
+
24
+
25
+ class TinyToolsRequestSSLError(TinyToolsRequestConnectionError):
26
+ """An SSL error occurred."""
27
+
28
+
29
+ class TinyToolsRequestHTTPError(TinyToolsRequestError):
30
+ """HTTP error occurred while handling request(HTTP 4xx/5xx)."""
31
+
32
+
33
+ class TinyToolsRequestUnauthenticated(TinyToolsRequestHTTPError):
34
+ """Request requires authentication or is invalid (HTTP 401)."""
35
+
36
+
37
+ class TinyToolsRequestNotFound(TinyToolsRequestHTTPError):
38
+ """Server could not find the requested resource (HTTP 404)."""
39
+
40
+
41
+ class TinyToolsRequestInternalServerError(TinyToolsRequestHTTPError):
42
+ """Server has encountered a situation it does not know how to handle. (HTTP 500)."""
43
+
44
+
45
+ # endregion
46
+
47
+
48
+ class TinyToolsFlashError(TinyToolsError):
49
+ """tinyTools flashing exception."""
tinytoolslib/flash.py ADDED
@@ -0,0 +1,316 @@
1
+ import logging
2
+ import os
3
+ import socket
4
+ import time
5
+ from functools import wraps
6
+ from math import ceil
7
+ from threading import Event
8
+
9
+ from tftpy import SOCK_TIMEOUT, TftpClient, TftpTimeout
10
+
11
+ from tinytoolslib.constants import LK_UDP_BOOTLOADER_MSG, LK_UDP_PORT
12
+ from tinytoolslib.requests import get, post
13
+ from tinytoolslib.exceptions import TinyToolsFlashError, TinyToolsRequestError
14
+ from tinytoolslib.models import (
15
+ LK_HW_20,
16
+ LK_HW_20_PS,
17
+ LK_HW_25,
18
+ LK_HW_25_PS,
19
+ get_version,
20
+ )
21
+
22
+
23
+ class Flasher:
24
+ """Class with all flash related functions.
25
+
26
+ Generally use as:
27
+ flasher = Flasher()
28
+ flasher.run(...)
29
+ """
30
+
31
+ def __init__(self, callback=None):
32
+ """Initialize Flasher.
33
+
34
+ callback - None/function that will be called during flashing
35
+ with progress information. 4 parameters: current, total,
36
+ percent, unit ('packet', 'B').
37
+ """
38
+ self.callback = callback
39
+ self.context = {}
40
+ self.canceled = Event()
41
+
42
+ # region TFTP flashing
43
+ @staticmethod
44
+ def get_optimal_number_of_attempts(version_info):
45
+ """Return number of attempts, so for HW2.X it quits earlier.
46
+
47
+ Each attempt takes SOCK_TIMEOUT*TIMEOUT_RETRIES
48
+ """
49
+ logging.debug("Getting optimal number of flash attempts (less for LK2.X)")
50
+ lk_2X_models = [
51
+ LK_HW_20_PS.info.model,
52
+ LK_HW_20.info.model,
53
+ LK_HW_25.info.model,
54
+ LK_HW_25_PS.info.model,
55
+ ]
56
+ if version_info is not None and version_info["model"] in lk_2X_models:
57
+ return 1
58
+ return 4
59
+
60
+ def start_bootloader(self, host, username, password, schema, port):
61
+ """Start bootloader mode on device (LK2.X, LK3.X)."""
62
+ success = False
63
+ # First try http method.
64
+ try:
65
+ # First check if upgrade is enabled else enable it
66
+ resp = get(host, "/xml/st.xml", schema, port, username, password)["parsed"]
67
+ if resp.get("upgr") == "0":
68
+ logging.info("Upgrade is disabled on device - trying to enable it.")
69
+ cmd = "/stm.cgi?auth={}{}{}".format(resp["auth"], 1, resp["userpass"])
70
+ get(host, cmd, schema, port, username, password)["parsed"]
71
+ logging.info("Starting bootloader mode via HTTP...")
72
+ get(host, "/stm.cgi?upgrade=lkstart3", schema, port, username, password)
73
+ success = True
74
+ except (KeyError, ValueError, TinyToolsRequestError) as exc:
75
+ logging.error("Failed to enable bootloader via HTTP: %s", str(exc))
76
+ if not success:
77
+ # Try UDP method.
78
+ logging.info("Starting bootloader mode via UDP...")
79
+ with socket.socket(
80
+ type=socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP
81
+ ) as sock:
82
+ sock.connect((host, LK_UDP_PORT))
83
+ sock.sendall(LK_UDP_BOOTLOADER_MSG)
84
+ success = True
85
+ return success
86
+
87
+ def flash_hook(self, packet):
88
+ """Display flashing progress."""
89
+ if self.canceled.is_set() and packet.opcode == 2:
90
+ # Cancel if stop flag set and still waiting for transfer.
91
+ raise TinyToolsFlashError("Flash canceled by user")
92
+ if packet.opcode == 3:
93
+ logging.debug("Packet %d/%d", packet.blocknumber, self.context["packets"])
94
+ if callable(self.callback):
95
+ # Call with <packet no>, <total packets>, <progress %>
96
+ self.callback(
97
+ packet.blocknumber,
98
+ self.context["packets"],
99
+ packet.blocknumber / self.context["packets"] * 100,
100
+ "packet",
101
+ )
102
+
103
+ def flash_firmware_via_tftp(self, host, firmware_path, attempts_limit):
104
+ """Try to flash firmware and display progress."""
105
+ firmware_name = os.path.basename(firmware_path)
106
+ bytes_size = os.stat(firmware_path).st_size
107
+ self.context.update(
108
+ {
109
+ "size": bytes_size,
110
+ "packets": ceil(bytes_size / 512),
111
+ }
112
+ )
113
+ logging.info(
114
+ "Uploading firmware %s with size of %dB in %d packets",
115
+ firmware_name,
116
+ self.context["size"],
117
+ self.context["packets"],
118
+ )
119
+ client = TftpClient(host)
120
+ attempt = 0
121
+ flashed = False
122
+ canceled = False
123
+ while attempt < attempts_limit and not flashed:
124
+ try:
125
+ if self.canceled.is_set():
126
+ # Stop before starting flash.
127
+ raise TinyToolsFlashError("Flash canceled by user")
128
+ client.upload(firmware_name, firmware_path, self.flash_hook)
129
+ except TftpTimeout:
130
+ attempt += 1
131
+ except (ConnectionError, socket.gaierror):
132
+ attempt += 1
133
+ time.sleep(SOCK_TIMEOUT)
134
+ except TinyToolsFlashError:
135
+ canceled = True
136
+ break
137
+ else:
138
+ flashed = True
139
+ if canceled:
140
+ logging.info("Canceled flashing")
141
+ return False
142
+ elif not flashed:
143
+ logging.warning("Unable to connect with device. Try again.")
144
+ return False
145
+ else:
146
+ logging.info(
147
+ "Uploaded firmware in %.1fs with avg speed of %.0f kbps.",
148
+ client.context.metrics.duration,
149
+ client.context.metrics.kbps,
150
+ )
151
+ return True
152
+
153
+ # endregion
154
+
155
+ def update_firmware_via_http(
156
+ self, firmware_path, host, username, password, schema, port
157
+ ):
158
+ """Update firmware via HTTP for LK4/tcPDU."""
159
+ try:
160
+ with open(firmware_path, "rb") as fread:
161
+ bytes_size = os.stat(firmware_path).st_size
162
+ self.context.update(
163
+ {
164
+ "size": bytes_size,
165
+ "uploaded": 0,
166
+ }
167
+ )
168
+ # Modify stream object to update progress
169
+ func = getattr(fread, "read")
170
+
171
+ @wraps(func)
172
+ def read(data, *args, **kwargs):
173
+ res = func(data, *args, **kwargs)
174
+ self.context["uploaded"] += data
175
+ if self.context["uploaded"] > self.context["size"]:
176
+ self.context["uploaded"] = self.context["size"]
177
+ logging.debug(
178
+ "Uploaded %.0f/%.0f kB (%.1f %%)",
179
+ self.context["uploaded"] / 1024,
180
+ self.context["size"] / 1024,
181
+ self.context["uploaded"] / self.context["size"] * 100,
182
+ )
183
+ if callable(self.callback):
184
+ # Call with <uploaded B>, <total B>, <progress %>
185
+ self.callback(
186
+ self.context["uploaded"],
187
+ self.context["size"],
188
+ self.context["uploaded"] / self.context["size"] * 100,
189
+ "B",
190
+ )
191
+ return res
192
+
193
+ setattr(fread, "read", read)
194
+ # Upload file
195
+ if callable(self.callback):
196
+ # Call with <0 B>, <total B>, <0 %>
197
+ self.callback(0, self.context["size"], 0, "B")
198
+ resp = post(
199
+ host,
200
+ "/api/v1/upload_firmware/new_firmware",
201
+ fread,
202
+ schema,
203
+ port,
204
+ username,
205
+ password,
206
+ )
207
+ # Restart device
208
+ get(host, "/api/v1/save/?restart=1", schema, port, username, password)
209
+ except Exception as exc:
210
+ logging.warning("Error occurred: %s. Try again.", str(exc))
211
+ return False
212
+ else:
213
+ logging.info(
214
+ "Uploaded firmware in %.1fs with avg speed of %.0f kbps.",
215
+ resp.elapsed.total_seconds(),
216
+ self.context["size"] / 1024 * 8 / resp.elapsed.total_seconds(),
217
+ )
218
+ return True
219
+
220
+ def run(
221
+ self, firmware_path, host, username, password, port=80, progress_callback=None
222
+ ):
223
+ """Main entry to flashing.
224
+
225
+ Depending on input and data found from device it will select
226
+ tftp or http method.
227
+ """
228
+ if progress_callback is not None:
229
+ self.callback = progress_callback
230
+ self.context = {}
231
+ if (
232
+ isinstance(firmware_path, str)
233
+ and firmware_path
234
+ and os.path.isfile(firmware_path)
235
+ ):
236
+ # Try to check what device type are we working with. For now,
237
+ # assume that lk4/tcpdu always respond via HTTP.
238
+ version_info = get_version(host, port, username, password)
239
+ if version_info and version_info["network_info"].get("schema") == "https":
240
+ schema = "https"
241
+ port = 443
242
+ else:
243
+ schema = "http"
244
+ if version_info and version_info.get("http_update"):
245
+ return self.update_firmware_via_http(
246
+ firmware_path, host, username, password, schema, port
247
+ )
248
+ else:
249
+ attempts = self.get_optimal_number_of_attempts(version_info)
250
+ logging.info("Preparing device for flashing...")
251
+ self.start_bootloader(host, username, password, schema, port)
252
+ return self.flash_firmware_via_tftp(host, firmware_path, attempts)
253
+ else:
254
+ logging.warning("Invalid file for flashing.")
255
+ return False
256
+
257
+
258
+ # region getting new firmware file
259
+ def check_for_latest_firmware(version_info):
260
+ """Check latest available version of firmware."""
261
+ if version_info["fw_url"] is None:
262
+ return (
263
+ False,
264
+ "Cannot get firmware files for this device directly. "
265
+ "You can look for it at https://tinycontrol.pl.",
266
+ )
267
+ try:
268
+ resp = get(version_info["fw_url"], None, timeout=5)
269
+ except TinyToolsRequestError as exc:
270
+ return False, str(exc)
271
+ else:
272
+ result = resp["parsed"]
273
+ result.update({"hardware_version": version_info["hardware_version"]})
274
+ return True, result
275
+
276
+
277
+ def get_latest_firmware(host, username, password, firmware_directory):
278
+ """Get latest firmware for device."""
279
+ version_info = get_version(host, username=username, password=password)
280
+ if version_info:
281
+ latest_version = check_for_latest_firmware(version_info)
282
+ if latest_version[0]:
283
+ # Check if downloaded else download
284
+ firmware_name = latest_version[1]["url"].split("/")[-1]
285
+ firmware_path = os.path.join(firmware_directory, firmware_name)
286
+ if os.path.isfile(firmware_path) or download_firmware(
287
+ latest_version[1]["url"], firmware_path
288
+ ):
289
+ version_info.update(
290
+ {
291
+ "path": firmware_path,
292
+ "new_sw": latest_version[1]["name"],
293
+ }
294
+ )
295
+ return True, version_info
296
+ else:
297
+ return False, "Failed to download file"
298
+ else:
299
+ return False, latest_version[1]
300
+ return False, "Cannot get information about latest firmware"
301
+
302
+
303
+ def download_firmware(download_url, save_location):
304
+ """Download firmware from given url."""
305
+ try:
306
+ resp = get(download_url, None, timeout=5)
307
+ except TinyToolsRequestError:
308
+ return False
309
+ else:
310
+ os.makedirs(os.path.dirname(save_location), exist_ok=True)
311
+ with open(save_location, "wb") as f:
312
+ f.write(resp["_response"].content)
313
+ return True
314
+
315
+
316
+ # endregion