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,328 @@
1
+ import logging
2
+ import re
3
+ import ssl
4
+ from time import perf_counter, sleep
5
+ from typing import TYPE_CHECKING, Any, Optional
6
+
7
+ import requests
8
+ import websocket
9
+ from websocket import WebSocket
10
+
11
+ from ..exceptions import CommunicationError, InvalidParameterValueError
12
+ from ..netio_device import NETIODevice
13
+ from . import ws_api
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _setup_ssl(**kwargs: Any) -> tuple[ssl.SSLContext, dict[str, Any]]:
19
+ _ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # Force TLS 1.2
20
+
21
+ DEFAULT_CIPHER_SUITES = [
22
+ # RSA
23
+ "TLS_RSA_WITH_AES_256_GCM_SHA384",
24
+ "TLS_RSA_WITH_AES_256_CCM",
25
+ "TLS_RSA_WITH_AES_128_GCM_SHA256",
26
+ "TLS_RSA_WITH_AES_128_CCM",
27
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
28
+ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
29
+ # ECC
30
+ "TLS_ECDHE_ECDSA_WITH_AES_256_SHA384",
31
+ "TLS_ECDHE_ECDSA_WITH_AES_128_SHA256",
32
+ "TLS_ECDHE_ECDSA_WITH_AES_256_SHA",
33
+ "TLS_ECDHE_ECDSA_WITH_AES_128_SHA",
34
+ "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
35
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CCM",
36
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
37
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
38
+ "TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8",
39
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
40
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CCM",
41
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
42
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
43
+ "TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8",
44
+ "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
45
+ "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
46
+ "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
47
+ "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
48
+ "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
49
+ "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
50
+ "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
51
+ "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
52
+ "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
53
+ "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
54
+ "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
55
+ "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
56
+ ]
57
+
58
+ cipher_suites = ":".join(DEFAULT_CIPHER_SUITES)
59
+
60
+ ssl_options: dict[str, Any] = {}
61
+ if kwargs.get("ssl_ciphers", None) is not None:
62
+ ssl_options["ciphers"] = kwargs.get("ssl_ciphers", cipher_suites)
63
+ _ssl_context.set_ciphers(kwargs.get("ssl_ciphers", cipher_suites))
64
+ else:
65
+ ssl_options["ciphers"] = cipher_suites
66
+ _ssl_context.set_ciphers(cipher_suites)
67
+
68
+ if kwargs.get("ssl_ca_cert_path", None) is not None:
69
+ ssl_options["ca_cert_path"] = kwargs.get("ssl_ca_cert_path")
70
+ _ssl_context.load_verify_locations(capath=kwargs.get("ssl_ca_cert_path"))
71
+
72
+ if kwargs.get("ssl_ecdh_curve", None) is not None:
73
+ ecdh_curve = kwargs.get("ssl_ecdh_curve")
74
+ if type(ecdh_curve) is str:
75
+ ssl_options["ecdh_curve"] = ecdh_curve
76
+ _ssl_context.set_ecdh_curve(ecdh_curve)
77
+ else:
78
+ raise TypeError("ssl_ecdh_curve must be a str")
79
+
80
+ if kwargs.get("ssl_ca_certs", None) is not None:
81
+ ssl_options["ca_certs"] = kwargs.get("ssl_ca_certs")
82
+ _ssl_context.load_verify_locations(cafile=kwargs.get("ssl_ca_certs"))
83
+
84
+ if kwargs.get("ssl_certfile", None) is not None:
85
+ ssl_options["certfile"] = kwargs.get("ssl_certfile")
86
+
87
+ if kwargs.get("ssl_keyfile", None) is not None:
88
+ ssl_options["keyfile"] = kwargs.get("ssl_keyfile")
89
+ if kwargs.get("ssl_password", None) is not None:
90
+ ssl_options["password"] = kwargs.get("ssl_password")
91
+ _ssl_context.load_cert_chain(
92
+ kwargs.get("ssl_certfile"),
93
+ kwargs.get("ssl_keyfile"),
94
+ kwargs.get("ssl_password"),
95
+ )
96
+ else:
97
+ _ssl_context.load_cert_chain(
98
+ kwargs.get("ssl_certfile"), kwargs.get("ssl_keyfile")
99
+ )
100
+ else:
101
+ _ssl_context.load_cert_chain(kwargs.get("ssl_certfile"))
102
+
103
+ if kwargs.get("ssl_check_hostname", None) is not None:
104
+ check_hostname = kwargs.get("ssl_check_hostname")
105
+ if type(check_hostname) is bool:
106
+ ssl_options["check_hostname"] = check_hostname
107
+ _ssl_context.check_hostname = check_hostname
108
+ else:
109
+ ssl_options["check_hostname"] = False
110
+ _ssl_context.check_hostname = False
111
+
112
+ if kwargs.get("ssl_cert_reqs", None) is not None:
113
+ cert_req = kwargs.get("ssl_cert_reqs")
114
+ if type(cert_req) is ssl.VerifyMode:
115
+ ssl_options["cert_reqs"] = cert_req
116
+ _ssl_context.verify_mode = cert_req
117
+ else:
118
+ ssl_options["cert_reqs"] = ssl.CERT_NONE
119
+ _ssl_context.verify_mode = ssl.CERT_NONE
120
+
121
+ ssl_options["context"] = kwargs.get("ssl_context", _ssl_context)
122
+ return (_ssl_context, ssl_options)
123
+
124
+
125
+ def _try_connect_websocket(
126
+ host: str, use_https: bool, try_count: int, **kwargs: dict[str, Any]
127
+ ) -> WebSocket:
128
+ ssl_options: dict[str, Any] | None = None
129
+ _ssl_context: ssl.SSLContext | None = None
130
+ if use_https:
131
+ _ssl_context, ssl_options = _setup_ssl(**kwargs)
132
+
133
+ logger.debug(f"Attempting websocket connection to {host}")
134
+ for attempt in range(try_count):
135
+ logger.debug(f"{host} websocket connection attempt {attempt + 1}/{try_count}")
136
+ try:
137
+ connection_dt_start = perf_counter()
138
+ if use_https:
139
+ ws = websocket.create_connection(
140
+ f"wss://{host}/emweb", sslopt=ssl_options, timeout=10
141
+ ) # pyright: ignore[reportUnknownMemberType]
142
+ else:
143
+ ws = websocket.create_connection(f"ws://{host}/emweb", timeout=10) # pyright: ignore[reportUnknownMemberType]
144
+
145
+ if isinstance(ws, WebSocket):
146
+ logger.debug(f"Succesfully connected to {host}")
147
+ break
148
+ else:
149
+ elapsed_time = perf_counter() - connection_dt_start
150
+ if elapsed_time < 10:
151
+ logger.debug(
152
+ "Couldn't establish ws connection in time, waiting to reconnect."
153
+ )
154
+ sleep(10 - elapsed_time)
155
+ except Exception as e:
156
+ logger.debug(f"Connection to {host} failed on {attempt + 1}/{try_count}")
157
+ sleep(1)
158
+ continue
159
+
160
+ if isinstance(ws, WebSocket):
161
+ logger.debug(f"Setting default timeout for {host}.")
162
+ ws.settimeout(60)
163
+ return ws
164
+
165
+
166
+ def initialize_esp(
167
+ host: str,
168
+ username: str,
169
+ password: str,
170
+ keep_alive: bool = True,
171
+ netio_manager: Optional["NetioManager"] = None, # type: ignore # noqa
172
+ use_https: bool = False,
173
+ **kwargs: Any,
174
+ ) -> NETIODevice:
175
+ if netio_manager is None:
176
+ raise InvalidParameterValueError
177
+
178
+ ws: websocket.WebSocket | None = None
179
+ version = 4 # TODO: Do version parsing here, not in NetioManager as this is ESP thing, not general NETIO thing
180
+ minor = 0
181
+ patch = 0
182
+ try:
183
+ try_count = 3 if not kwargs.get("ws_expected", False) else 15
184
+
185
+ ws = _try_connect_websocket(host, use_https, try_count, **kwargs)
186
+ ws_req_id = 0
187
+
188
+ hello_response = ws_api.device_init_request(ws, ws_req_id, "HELO", host)
189
+ ws_req_id += 1
190
+ ws_api.device_init_login(
191
+ ws,
192
+ ws_req_id,
193
+ hello_response["data"]["localTimestamp"],
194
+ hello_response["data"]["publicKey"],
195
+ username,
196
+ password,
197
+ host,
198
+ )
199
+ ws_req_id += 1
200
+ try:
201
+ version_str = hello_response["data"]["device"]["fwVersion"]
202
+ version_pattern = r"(\d+)\.(\d+)\.(\d+)"
203
+ match = re.search(version_pattern, version_str)
204
+ if match:
205
+ version, minor, patch = match.groups()
206
+ except KeyError:
207
+ logger.debug(
208
+ "Version not found in HELO message, fetching from system/info instead"
209
+ )
210
+ system_info = ws_api.device_init_request(
211
+ ws, ws_req_id, "SUBSCRIBE", host, "system/info"
212
+ )
213
+ ws_req_id += 2
214
+ version_str = system_info["data"]["fwVersion"]
215
+ version_pattern = r"(\d+)\.(\d+)\.(\d+)"
216
+ match = re.search(version_pattern, version_str)
217
+ if match:
218
+ version, minor, patch = match.groups()
219
+ else:
220
+ logger.warn(
221
+ f"Websocket on {host} is connected but version couldn't be verified, defaulting to 5beta firmware"
222
+ )
223
+ version, minor, patch = 5, 0, 0
224
+ except:
225
+ ws_req_id = 0
226
+ ws = None
227
+
228
+ if int(version) == 2:
229
+ from .esp_200_device import ESP200Device
230
+
231
+ netio_device = ESP200Device(
232
+ host,
233
+ username,
234
+ password,
235
+ "",
236
+ "",
237
+ keep_alive,
238
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
239
+ use_https,
240
+ )
241
+ return netio_device
242
+
243
+ if int(version) == 3:
244
+ from .esp_300_device import ESP300Device
245
+
246
+ netio_device = ESP300Device(
247
+ host,
248
+ username,
249
+ password,
250
+ "",
251
+ "",
252
+ keep_alive,
253
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
254
+ use_https,
255
+ )
256
+ return netio_device
257
+
258
+ if int(version) == 4:
259
+ from .esp_400_device import ESP400Device
260
+
261
+ netio_device = ESP400Device(
262
+ host,
263
+ username,
264
+ password,
265
+ "",
266
+ "",
267
+ keep_alive,
268
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
269
+ use_https,
270
+ )
271
+ return netio_device
272
+
273
+ if int(version) == 5:
274
+ if int(minor) >= 2:
275
+ from .esp_520_device import ESP520Device
276
+
277
+ netio_device = ESP520Device(
278
+ host,
279
+ username,
280
+ password,
281
+ "",
282
+ "",
283
+ keep_alive,
284
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
285
+ use_https,
286
+ ws_connection=ws,
287
+ ws_req_id=ws_req_id,
288
+ is_ws_auth=True if ws is not None else False,
289
+ ws_helo_data=hello_response,
290
+ )
291
+ return netio_device
292
+ elif int(minor) == 1:
293
+ from .esp_500_device import ESP500Device
294
+
295
+ netio_device = ESP500Device(
296
+ host,
297
+ username,
298
+ password,
299
+ "",
300
+ "",
301
+ keep_alive,
302
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
303
+ use_https,
304
+ ws_connection=ws,
305
+ ws_req_id=ws_req_id,
306
+ is_ws_auth=True if ws is not None else False,
307
+ ws_helo_data=hello_response,
308
+ )
309
+ return netio_device
310
+ else:
311
+ from .esp_5beta_device import ESP5BetaDevice
312
+
313
+ ws = None
314
+ netio_device = ESP5BetaDevice(
315
+ host,
316
+ username,
317
+ password,
318
+ "",
319
+ "",
320
+ keep_alive,
321
+ netio_manager, # pyright: ignore[reportUnknownArgumentType]
322
+ use_https,
323
+ )
324
+ return netio_device
325
+
326
+ raise InvalidParameterValueError(
327
+ f"There is no firmware with major version {version} supported"
328
+ )