PyNetioConf 0.3.0b1__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,391 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import logging
5
+ import math
6
+ import random
7
+ from collections.abc import Generator
8
+ from io import BufferedIOBase
9
+ from time import sleep
10
+ from typing import Any, BinaryIO, NamedTuple, Tuple
11
+
12
+ from websocket import WebSocket, WebSocketConnectionClosedException
13
+
14
+ from PyNetioConf.constants import WS_EXECUTION_DELAY
15
+
16
+ from ..exceptions import CommunicationError
17
+ from ..netio_device import NETIODevice
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class _FileChunk(NamedTuple):
23
+ base64: str
24
+ original_len: int
25
+ index: int
26
+ bytes_from: int
27
+ bytes_to: int
28
+
29
+
30
+ def send_request(
31
+ device: NETIODevice,
32
+ type: str,
33
+ topic: str | None = None,
34
+ data: dict[str, Any] | None = None,
35
+ ) -> dict[str, Any]:
36
+ """
37
+ Send a request to the device's websocket API and return the response.
38
+ Parameters
39
+ ----------
40
+ device : ESPDevice
41
+ The ESPDevice object of the device.
42
+ type : str
43
+ The type of the request. Can be one of the following: "HELO", "AUTH", "SET", "SUBSCRIBE", "UNSUBSCRIBE".
44
+ topic : str
45
+ The target of the request if applicable.
46
+ data : Dict
47
+ The data to send with the request if applicable.
48
+
49
+ Returns
50
+ -------
51
+ Upon successful communication returns the direct API response from the device to be further parsed.
52
+ """
53
+ if device.ws is None:
54
+ device.login(device.username, device.password)
55
+
56
+ request: dict[str, Any] = {"type": type, "reqId": device.ws_req_id}
57
+ unsubscribe_needed = False
58
+ expected_reqest_id = device.ws_req_id
59
+ if type == "SUBSCRIBE":
60
+ unsubscribe_needed = True
61
+ if topic:
62
+ request["topic"] = topic
63
+ if data:
64
+ request["data"] = data
65
+ logger.debug(f"Sending request to {device.host} with payload {request}")
66
+ while True:
67
+ try:
68
+ if device.ws is None:
69
+ raise CommunicationError(
70
+ "No websocket connection associated with the device"
71
+ )
72
+ device.ws.send(json.dumps(request, ensure_ascii=False))
73
+ sleep(
74
+ WS_EXECUTION_DELAY # TODO: Tie to device/NM
75
+ ) # Due to internal timers it is safer to wait after sending to not overwhelm the device
76
+ device.ws_req_id += 1
77
+
78
+ # Since we do not look for events we should not leave a hanging SUBSCRIBE on the websocket
79
+ # This is best done right after the SUBSCRIBE, so as little EVENT data gets transmitted
80
+ if unsubscribe_needed:
81
+ unsubscribe_request: dict[str, Any] = {
82
+ "type": "UNSUBSCRIBE",
83
+ "reqId": device.ws_req_id,
84
+ }
85
+ if topic:
86
+ unsubscribe_request["topic"] = topic
87
+ logger.debug(
88
+ f"Sending request to {device.host} with payload {unsubscribe_request}"
89
+ )
90
+ device.ws.send(json.dumps(unsubscribe_request, ensure_ascii=False))
91
+ sleep(
92
+ WS_EXECUTION_DELAY
93
+ ) # Due to internal timers it is safer to wait after sending to not overwhelm the device
94
+ device.ws_req_id += 1
95
+
96
+ # Create a more proper message filtering
97
+ waiting_for_reply = True
98
+ while waiting_for_reply:
99
+ message = device.ws.recv()
100
+ logger.debug(f"Received response {message}")
101
+ message_data = json.loads(message)
102
+ if message_data["type"] != "EVENT":
103
+ if message_data["type"] == "PONG":
104
+ device._pong_queue.append(message_data)
105
+ else:
106
+ device._request_queue.append(message_data)
107
+ try:
108
+ if message_data["reqId"] == expected_reqest_id:
109
+ waiting_for_reply = False
110
+ logger.debug(
111
+ f"Processing correct request {message_data['reqId']}"
112
+ )
113
+ break
114
+ elif message_data["reqId"] > expected_reqest_id:
115
+ if message_data["type"] == "PONG":
116
+ match = next(
117
+ (
118
+ dq_req_id
119
+ for dq_req_id in device._pong_queue
120
+ if dq_req_id.get("reqId") == expected_reqest_id
121
+ ),
122
+ None,
123
+ )
124
+ else:
125
+ match = next(
126
+ (
127
+ dq_req_id
128
+ for dq_req_id in device._request_queue
129
+ if dq_req_id.get("reqId") == expected_reqest_id
130
+ ),
131
+ None,
132
+ )
133
+ if match:
134
+ logger.debug(
135
+ f"Processing correct request {message_data['reqId']}"
136
+ )
137
+ break
138
+ else:
139
+ raise NotImplementedError
140
+
141
+ else:
142
+ logger.debug(
143
+ f"Processing wrong message: [{message_data['reqId']}], continuing to next one."
144
+ )
145
+ except KeyError:
146
+ # We shouldn't be able to get here with queuing since we filter EVENTs completely now
147
+ logger.debug(f"Throwing away websocket message: {message}")
148
+ continue
149
+
150
+ if message_data:
151
+ return message_data
152
+ else:
153
+ return json.loads(message)
154
+ except (BrokenPipeError, WebSocketConnectionClosedException):
155
+ # This most likely means that the device has rebooted, or just lost connection for other reasons.
156
+ # Try to reconnect first, then consider the connection lost.
157
+ device.login(device.username, device.password, logout=True)
158
+ continue
159
+ except Exception as e:
160
+ raise CommunicationError(f"Failed to send request to {device.host}", str(e))
161
+
162
+
163
+ def _gen_file_chunks(
164
+ file_obj: BufferedIOBase, chunk_size: int = 2048
165
+ ) -> Generator[_FileChunk, None, None]:
166
+ """Reads a binary file in chunks.
167
+
168
+ Yields:
169
+ Tuple[str, int, int, int, int]:
170
+ (base64_string, original_chunk_len, chunk_index, bytes_from, bytes_to)
171
+ """
172
+ chunk_iterator = iter(lambda: file_obj.read(chunk_size), b"")
173
+ current_byte = 0
174
+
175
+ for index, chunk in enumerate(chunk_iterator, start=0):
176
+ chunk_len = len(chunk)
177
+ b64_string = base64.b64encode(chunk).decode("utf-8")
178
+
179
+ bytes_from = current_byte
180
+ bytes_to = current_byte + chunk_len # Inclusive boundary
181
+
182
+ yield _FileChunk(
183
+ base64=b64_string,
184
+ original_len=chunk_len,
185
+ index=index,
186
+ bytes_from=bytes_from,
187
+ bytes_to=bytes_to,
188
+ )
189
+
190
+ current_byte += chunk_len
191
+
192
+
193
+ def upload_file(device: NETIODevice, file: BufferedIOBase, upload_id: str):
194
+ CHUNK_SIZE = 2048
195
+ file_chunks = list(_gen_file_chunks(file, CHUNK_SIZE))
196
+ chunks_total = len(file_chunks)
197
+ total_size = sum(chunk.original_len for chunk in file_chunks)
198
+ for chunk in file_chunks:
199
+ uploaded_chunk = chunk_file_upload(
200
+ device,
201
+ chunk.base64,
202
+ chunk.bytes_from,
203
+ chunk.bytes_to,
204
+ total_size,
205
+ upload_id,
206
+ chunk.index,
207
+ chunks_total,
208
+ False if chunk.index < (chunks_total - 1) else True,
209
+ )
210
+ sleep(0.3)
211
+
212
+
213
+ def chunk_file_upload(
214
+ device: NETIODevice,
215
+ byte_data: str,
216
+ bytes_from: int,
217
+ bytes_to: int,
218
+ bytes_total: int,
219
+ upload_id: str,
220
+ chunk_index: int = 0,
221
+ chunk_total: int = 1,
222
+ complete: bool = True,
223
+ ) -> dict[str, Any]:
224
+ chunk_topic = "upload/chunk"
225
+ ws_type = "SET"
226
+ chunk_data = {
227
+ "b64Data": byte_data,
228
+ "bytesFrom": bytes_from,
229
+ "bytesTo": bytes_to,
230
+ "bytesTotal": bytes_total,
231
+ "chunkIndex": chunk_index,
232
+ "chunksTotal": chunk_total,
233
+ "complete": complete,
234
+ "uploadId": upload_id,
235
+ }
236
+ ws_request = {
237
+ "data": chunk_data,
238
+ "reqId": device.ws_req_id,
239
+ "topic": chunk_topic,
240
+ "type": ws_type,
241
+ }
242
+ import copy
243
+
244
+ _debug_request = {
245
+ **ws_request,
246
+ "data": {
247
+ **chunk_data,
248
+ "b64Data": f"... [{len(byte_data)} bytes] ...",
249
+ },
250
+ }
251
+ if device.ws is None:
252
+ raise CommunicationError("No websocket connection associated with the device")
253
+ logger.debug(f"Sending request to {device.host} with payload {_debug_request}")
254
+ device.ws.send(json.dumps(ws_request, ensure_ascii=False))
255
+ device.ws_req_id += 1
256
+ chunk_reply = device.ws.recv()
257
+ logger.debug(f"Received response from {device.host} with payload {chunk_reply}")
258
+ return json.loads(chunk_reply)
259
+
260
+
261
+ def generate_salt() -> str:
262
+ base = random.randint(0, 2**32)
263
+ # create a sha256 hash of the base
264
+ return hashlib.sha256(str(base).encode()).hexdigest()
265
+
266
+
267
+ def generate_password_hash(username: str, password: str, public_key: str) -> str:
268
+ salted_hash = hashlib.sha256(f"{username}{public_key}{password}".encode()).digest()
269
+ # convert the salted hash to base64
270
+ return base64.b64encode(salted_hash).decode()
271
+
272
+
273
+ def generate_password_token(salt: str, password_hash: str) -> tuple[str, str]:
274
+ # create a sha256 hash of the salt and password hash
275
+ pwd_hash = hashlib.sha256(f"{salt}{password_hash}".encode()).hexdigest()
276
+ return salt, pwd_hash
277
+
278
+
279
+ def generate_auth_token(password_token: tuple[str, str], local_timestamp: int) -> str:
280
+ time_mark = str(math.floor(local_timestamp / 10))
281
+ token_hash = hashlib.sha256(f"{time_mark}{password_token[1]}".encode()).hexdigest()
282
+ return f"{password_token[0]}.{token_hash}"
283
+
284
+
285
+ def login(
286
+ device: NETIODevice, timestamp: int, public_key: str, username: str, password: str
287
+ ) -> dict[str, Any]:
288
+ salt = generate_salt()
289
+ password_hash = generate_password_hash(username, password, public_key)
290
+ password_token = generate_password_token(salt, password_hash)
291
+ auth_token = generate_auth_token(password_token, timestamp)
292
+ request = {
293
+ "type": "AUTH",
294
+ "reqId": device.ws_req_id,
295
+ "username": username,
296
+ "token": auth_token,
297
+ }
298
+ if device.ws is None:
299
+ raise CommunicationError("No websocket connection associated with the device")
300
+ device.ws.send(json.dumps(request, ensure_ascii=False))
301
+ logger.debug(f"Sending authentication request to {device.host}, payload: {request}")
302
+ device.ws_req_id += 1
303
+ message = device.ws.recv()
304
+ logger.debug(
305
+ f"Received authentication response from {device.host}, payload: {message}"
306
+ )
307
+ return json.loads(message)
308
+
309
+
310
+ def device_init_login(
311
+ ws: WebSocket,
312
+ ws_req_id: int,
313
+ timestamp: int,
314
+ public_key: str,
315
+ username: str,
316
+ password: str,
317
+ host: str,
318
+ ) -> dict[str, Any]:
319
+ salt = generate_salt()
320
+ password_hash = generate_password_hash(username, password, public_key)
321
+ password_token = generate_password_token(salt, password_hash)
322
+ auth_token = generate_auth_token(password_token, timestamp)
323
+ request = {
324
+ "type": "AUTH",
325
+ "reqId": ws_req_id,
326
+ "username": username,
327
+ "token": auth_token,
328
+ }
329
+ ws.send(json.dumps(request, ensure_ascii=False))
330
+ logger.debug(f"Sending authentication request to {host}, payload: {request}")
331
+ ws_req_id += 1
332
+ message = ws.recv()
333
+ logger.debug(f"Received authentication response from {host}, payload: {message}")
334
+ return json.loads(message)
335
+
336
+
337
+ def device_init_request(
338
+ ws: WebSocket,
339
+ ws_req_id: int,
340
+ type: str,
341
+ host: str,
342
+ topic: str | None = None,
343
+ data: dict[str, Any] | None = None,
344
+ ) -> dict[str, Any]:
345
+ """
346
+ This is a simplified version of the send_request() function used for communication between PyNetioConf and a device which is yet to initialize.
347
+ """
348
+ request: dict[str, Any] = {"type": type, "reqId": ws_req_id}
349
+ unsubscribe_needed = False
350
+ expected_reqest_id = ws_req_id
351
+ if type == "SUBSCRIBE":
352
+ unsubscribe_needed = True
353
+ if topic:
354
+ request["topic"] = topic
355
+ if data:
356
+ request["data"] = data
357
+ logger.debug(f"Sending request to {host} with payload {request}")
358
+ try:
359
+ ws.send(json.dumps(request, ensure_ascii=False))
360
+
361
+ # Since we do not look for events we should not leave a hanging SUBSCRIBE on the websocket
362
+ if unsubscribe_needed:
363
+ ws_req_id += 1
364
+ unsubscribe_request: dict[str, Any] = {
365
+ "type": "UNSUBSCRIBE",
366
+ "reqId": ws_req_id,
367
+ }
368
+ if topic:
369
+ unsubscribe_request["topic"] = topic
370
+ logger.debug(
371
+ f"Sending request to {host} with payload {unsubscribe_request}"
372
+ )
373
+ ws.send(json.dumps(unsubscribe_request, ensure_ascii=False))
374
+ ws_req_id += 1
375
+ message = ws.recv()
376
+ # Create a more proper message filtering
377
+ waiting_for_reply = True
378
+ while waiting_for_reply:
379
+ try:
380
+ if json.loads(message)["reqId"] == expected_reqest_id:
381
+ waiting_for_reply = False
382
+ else:
383
+ logger.debug(f"Throwing away websocket message: {message}")
384
+ message = ws.recv()
385
+ except KeyError:
386
+ logger.debug(f"Throwing away websocket message: {message}")
387
+ message = ws.recv()
388
+ logger.debug(f"Received response from {host} with payload {message}")
389
+ return json.loads(message)
390
+ except Exception as e:
391
+ raise CommunicationError(f"Failed to send request to {host}", str(e))
@@ -0,0 +1,183 @@
1
+ """
2
+ Module for managing NETIO devices across a session without creating multiple connections to the same device.
3
+ """
4
+
5
+ import atexit
6
+ import re
7
+ from typing import Any, Dict, List, Tuple
8
+
9
+ import requests
10
+
11
+ from PyNetioConf.exceptions import CommunicationError, FirmwareVersionNotSupported
12
+
13
+ from .ESPCore import esp_device_init
14
+ from .netio_device import NETIODevice
15
+
16
+
17
+ class NetioManager:
18
+ """
19
+ A class to create and manage NETIO devices across a session.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._connected_devices: list[NETIODevice] = list()
24
+ atexit.register(self.logout_all)
25
+
26
+ def logout_all(self) -> None:
27
+ """
28
+ Logs out of all connected devices, keeps the device list, but invalidates the sessions, if a request will be
29
+ sent to any of the devices, a new session will be created.
30
+ """
31
+ for device in self._connected_devices:
32
+ if device.session_id != "" or device.ws is not None:
33
+ device.logout()
34
+
35
+ def init_device(
36
+ self,
37
+ host: str,
38
+ username: str,
39
+ password: str,
40
+ keep_alive: bool = True,
41
+ use_https: bool = False,
42
+ **kwargs: dict[str, Any],
43
+ ) -> NETIODevice:
44
+ """
45
+ Initialize a Netio device object, create a connection to the device, get its platform type and return
46
+ an object based on that platform.
47
+
48
+ Parameters
49
+ ----------
50
+ host: str
51
+ Device IP address (just the IP address, without any URL parts such as 'http://')
52
+ username: str
53
+ Username that will be used to log in to the device. (Note many actions require administrator privileges)
54
+ password: str
55
+ Password for the user.
56
+ keep_alive: bool
57
+ If True, the connection will be kept alive by sending a keep alive packet every 30 seconds.
58
+ use_https: bool
59
+ Makes the communication with the device based on HTTPs, the protocol must be enabled on the device
60
+ in the security settings.
61
+ kwargs: dict[str, Any]
62
+ Used mainly for setting up custom SSL options while using HTTPS
63
+
64
+ Returns
65
+ -------
66
+ NETIODevice compatible object based on the platform of the connected device.
67
+ """
68
+ try:
69
+ netio_device = esp_device_init.initialize_esp(
70
+ host, username, password, keep_alive, self, use_https, **kwargs
71
+ ) # pyright: ignore[reportUnknownMemberType]
72
+ except requests.exceptions.ConnectionError:
73
+ raise NotImplementedError
74
+ self._connected_devices.append(netio_device)
75
+ return netio_device
76
+
77
+ def update_device(self, netio_device: NETIODevice, **kwargs) -> NETIODevice:
78
+ """
79
+ Update the device object in the device list and return its new instance,
80
+ main use for updating firmware between major versions.
81
+
82
+ Parameters
83
+ ----------
84
+ netio_device: NETIODevice
85
+ The device object to update.
86
+
87
+ Returns
88
+ -------
89
+ The updated device object.
90
+ """
91
+ for index, device in enumerate(self._connected_devices):
92
+ if netio_device.sn_number == device.sn_number:
93
+ updated_device = esp_device_init.initialize_esp(
94
+ device.host,
95
+ device.username,
96
+ device.password,
97
+ device._keep_alive_flag,
98
+ self,
99
+ device.use_https,
100
+ **kwargs,
101
+ )
102
+ self._connected_devices[index] = updated_device
103
+ return updated_device
104
+ raise CommunicationError("Device could not be reinstated after update.")
105
+
106
+ @staticmethod
107
+ def parse_fw_version(host: str) -> tuple[int, int, int]:
108
+ """
109
+ Parse the firmware info string and return the version numbers.
110
+
111
+ Parameters
112
+ ----------
113
+ host: str
114
+ The URL of the device.
115
+
116
+ Returns
117
+ -------
118
+ A tuple containing the major, minor and bugfix version numbers.
119
+ """
120
+ fw_info = NetioManager.get_info(host)["data"]["version"]
121
+ fw_version = fw_info.split("-")[0]
122
+ version_info = re.search(r"(\d+)\.(\d+)\.(\d+)", fw_version)
123
+
124
+ if version_info:
125
+ return (
126
+ int(version_info.group(1)),
127
+ int(version_info.group(2)),
128
+ int(version_info.group(3)),
129
+ )
130
+ else:
131
+ raise ValueError("Invalid firmware version string")
132
+
133
+ @staticmethod
134
+ def get_info(host: str) -> dict:
135
+ """
136
+ Get version information from the device.
137
+
138
+ Parameters
139
+ ----------
140
+ host: str
141
+ IP address of the device.
142
+ Returns
143
+ -------
144
+ Dictionary containing the version info from the device
145
+ """
146
+ session = requests.Session()
147
+ json_payload = {"sessionId": "", "action": "getVersion"}
148
+ from urllib3.exceptions import MaxRetryError
149
+
150
+ try:
151
+ response = session.post(
152
+ f"http://{host}/api", json=json_payload, timeout=300
153
+ ) # noqa
154
+ except MaxRetryError:
155
+ from time import sleep
156
+
157
+ sleep(5)
158
+ response = session.get(f"http://{host}/api", json=json_payload, timeout=300)
159
+
160
+ if "data" not in response.json():
161
+ raise ConnectionError("Invalid response from device")
162
+ return response.json()
163
+
164
+ @staticmethod
165
+ def esp_get_platform(host) -> tuple[str, str]:
166
+ """
167
+ Get platform information from the device, as well as the serial number.
168
+
169
+ Parameters
170
+ ----------
171
+ host: str
172
+ URL of the device.
173
+
174
+ Returns
175
+ -------
176
+ A tuple containing the platform type and the serial number.
177
+ """
178
+ json_response = NetioManager.get_info(host)
179
+
180
+ if "platform" not in json_response["data"]:
181
+ raise ValueError("Invalid response from device") # todo custom exception
182
+
183
+ return json_response["data"]["platform"], json_response["data"]["deviceSN"]
@@ -0,0 +1 @@
1
+ from .NetioManager import NetioManager
@@ -0,0 +1,12 @@
1
+ # Timings and timeouts
2
+ WS_EXECUTION_DELAY = 0.035 # Serves to give breathing room to the internal device timers and reduce unwanted restarts
3
+ WS_DEFAULT_TIMEOUT = 60
4
+
5
+ CLOUD_DEFAULT_CONNECTION_WAIT = 10
6
+ CLOUD_ACTION_COMMUNICATION_DELAY = 3
7
+
8
+ DEVICE_RESET_GRACE_PERIOD = 5
9
+
10
+ # WebSocket 5.0.0+ communication constants
11
+ DEFAULT_REQUEST_QUEUE_LEN = 256
12
+ DEFAULT_KEEP_ALIVE_QUEUE_LEN = 64
@@ -0,0 +1,56 @@
1
+ class NetioException(Exception):
2
+ """Base class for all exceptions raised by this module"""
3
+ pass
4
+
5
+
6
+ class ElementAlreadyExists(NetioException):
7
+ """Raised when trying to create an element that already exists"""
8
+ pass
9
+
10
+
11
+ class ProtocolNotEnabled(NetioException):
12
+ """Raised when the protocol is not enabled on the device"""
13
+ pass
14
+
15
+
16
+ class CommunicationError(NetioException):
17
+ """Raised when the connection to the Netio device fails"""
18
+
19
+ def __init__(self, message: str, error: str = ""):
20
+ super().__init__(message)
21
+ self.error = error
22
+
23
+
24
+ class AuthError(NetioException):
25
+ """Raised when the authentication with the Netio device fails"""
26
+ pass
27
+
28
+
29
+ class FeatureNotSupported(NetioException):
30
+ """Raised when the device type is not supported"""
31
+ pass
32
+
33
+
34
+ class FirmwareVersionNotSupported(NetioException):
35
+ """Raised when the firmware version is not supported"""
36
+ pass
37
+
38
+
39
+ class DeviceNotYetSupported(NetioException):
40
+ """Raised when the device is not yet supported"""
41
+ pass
42
+
43
+
44
+ class InvalidSocketIndex(NetioException):
45
+ """Raised when the socket index is invalid"""
46
+ pass
47
+
48
+
49
+ class ElementNotFound(NetioException):
50
+ """Raised when looking for an element that doesn't exist on the device."""
51
+ pass
52
+
53
+
54
+ class InvalidParameterValueError(NetioException):
55
+ """Raised when trying to set an invalid paremeter in protocol or configuration methods"""
56
+ pass