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.
- PyNetioConf/ESPCore/__init__.py +5 -0
- PyNetioConf/ESPCore/esp_200_device.py +16 -0
- PyNetioConf/ESPCore/esp_300_device.py +16 -0
- PyNetioConf/ESPCore/esp_400_device.py +16 -0
- PyNetioConf/ESPCore/esp_500_device.py +1826 -0
- PyNetioConf/ESPCore/esp_520_device.py +348 -0
- PyNetioConf/ESPCore/esp_5beta_device.py +263 -0
- PyNetioConf/ESPCore/esp_api.py +383 -0
- PyNetioConf/ESPCore/esp_device.py +1516 -0
- PyNetioConf/ESPCore/esp_device_init.py +328 -0
- PyNetioConf/ESPCore/ws_api.py +391 -0
- PyNetioConf/NetioManager.py +183 -0
- PyNetioConf/__init__.py +1 -0
- PyNetioConf/constants.py +12 -0
- PyNetioConf/exceptions.py +56 -0
- PyNetioConf/netio_device.py +1727 -0
- pynetioconf-0.3.0b1.dist-info/METADATA +173 -0
- pynetioconf-0.3.0b1.dist-info/RECORD +20 -0
- pynetioconf-0.3.0b1.dist-info/WHEEL +4 -0
- pynetioconf-0.3.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|
PyNetioConf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .NetioManager import NetioManager
|
PyNetioConf/constants.py
ADDED
|
@@ -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
|