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,348 @@
1
+ """
2
+ Implementation specifics for ESP devices with the firmware 5.2.x.
3
+ """
4
+
5
+ import atexit
6
+ import io
7
+ import json
8
+ import os
9
+ import ssl
10
+ import sys
11
+ from collections import deque
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+ from time import sleep, time
15
+
16
+ from ..constants import (
17
+ DEFAULT_KEEP_ALIVE_QUEUE_LEN,
18
+ DEFAULT_REQUEST_QUEUE_LEN,
19
+ )
20
+
21
+ if sys.version_info >= (3, 12):
22
+ from typing import IO, Any, AnyStr, Dict, List, Tuple, override
23
+ else:
24
+ from typing import IO, Any, AnyStr, Dict, List, Tuple
25
+
26
+ from typing_extensions import override
27
+
28
+ import logging
29
+ import threading
30
+
31
+ import websocket
32
+ from websocket import WebSocket
33
+
34
+ from .. import NetioManager
35
+ from ..exceptions import CommunicationError, ElementNotFound
36
+ from ..netio_device import NETIODevice
37
+ from . import esp_api, ws_api
38
+ from .esp_500_device import ESP500Device
39
+
40
+
41
+ class ESP520Device(ESP500Device):
42
+ """
43
+ A class to control ESP devices with the firmware 5.2.x.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ host: str,
49
+ username: str,
50
+ password: str,
51
+ sn_number: str,
52
+ hostname: str,
53
+ keep_alive: bool = True,
54
+ netio_manager: NetioManager | None = None,
55
+ use_https: bool = False,
56
+ **kwargs: Any,
57
+ ):
58
+ """
59
+ The 5.x.x versions of Netio firmware have been severely redesigned which includes the entirety of the API that is used for communication with the device therefore this init is completely separated from the base classes and does not call super().__init__ but instead makes a new class from scratch. If the base NETIODevice class gets updated this has to be done accordingly here.
60
+
61
+ Parameters
62
+ ----------
63
+ host : str
64
+
65
+ username : str
66
+
67
+ password : str
68
+
69
+ sn_number : str
70
+
71
+ hostname : str
72
+
73
+ : Any
74
+
75
+ keep_alive : bool
76
+
77
+ netio_manager : NetioManager | None
78
+
79
+ use_https : bool
80
+
81
+
82
+ """
83
+ self.host = host
84
+ self.username = username
85
+ self.password = password
86
+ self.sn_number = sn_number
87
+ self.hostname = hostname
88
+ self.session_id = ""
89
+ self.supported_features: dict[str, Any] = dict()
90
+ self.output_count = 4
91
+ self.user_permissions: list[str] = list()
92
+ self._keep_alive_flag = keep_alive
93
+ self.ws_req_id = kwargs.get("ws_req_id", 0)
94
+ self.use_https = use_https
95
+ if use_https:
96
+ from urllib3 import disable_warnings
97
+ from urllib3.exceptions import InsecureRequestWarning
98
+
99
+ disable_warnings(category=InsecureRequestWarning)
100
+
101
+ self._request_queue = deque(maxlen=DEFAULT_REQUEST_QUEUE_LEN)
102
+ self._pong_queue = deque(maxlen=DEFAULT_KEEP_ALIVE_QUEUE_LEN)
103
+
104
+ # Init from base ESP Device
105
+ self.ws: WebSocket | None = kwargs.get("ws_connection", None)
106
+ self.logger = logging.getLogger(__name__)
107
+ if kwargs.get("is_ws_auth", False):
108
+ self.session_id = "TODO AUTH"
109
+ else:
110
+ self.session_id = self.login(username, password)
111
+ # self.supported_features = self.get_features()
112
+ # self.output_count: int = self.supported_features["outputCount"]
113
+ # self.user_permissions = self.get_current_user()["privileges"]
114
+ if keep_alive:
115
+ self._ka_thread = threading.Timer(120, self._keep_alive)
116
+ self._ka_thread.daemon = True
117
+ self._ka_thread.start()
118
+ atexit.register(self._cleanup)
119
+
120
+ # New Init
121
+ self.ssl_options: dict[str, Any] | None = kwargs.get("ssl_options", dict())
122
+ self.logger = logging.getLogger(self.__class__.__name__)
123
+ self.netio_manager = netio_manager
124
+ # self.fw_version = self.get_version()
125
+
126
+ _system_info = self.get_system_info()
127
+ self.output_count = _system_info["outputCount"]
128
+ self.input_count = _system_info["inputCount"]
129
+ self.supported_features["wifi"] = _system_info["wifiSupport"]
130
+ self.supported_features["eth"] = _system_info["ethSupport"]
131
+
132
+ # TODO: Handle repeated code for uploading files using chunking
133
+ @override
134
+ def upload_mqtt_client_key(self, key: str) -> None:
135
+ import base64
136
+
137
+ ws_type = "SET"
138
+ ws_topic = "protocols/mqtt/clientkeyupload"
139
+ ws_data: dict[str, Any] = {}
140
+
141
+ self.logger.debug(f"Uploading MQTT client key to device {self.host}.")
142
+ upload_id = ws_api.send_request(self, ws_type, ws_topic, ws_data)["data"][
143
+ "uploadId"
144
+ ]
145
+ original_bytes = key.encode("utf-8")
146
+ base64_string = base64.b64encode(original_bytes).decode("utf-8")
147
+ ws_api.chunk_file_upload(self, base64_string, 0, len(key), len(key), upload_id)
148
+
149
+ @override
150
+ def upload_mqtt_ca_certificate(self, ca: str) -> None:
151
+ import base64
152
+
153
+ ws_type = "SET"
154
+ ws_topic = "protocols/mqtt/cacertupload"
155
+ ws_data: dict[str, Any] = {}
156
+
157
+ self.logger.debug(f"Uploading MQTT CA certificate to device {self.host}.")
158
+ upload_id = ws_api.send_request(self, ws_type, ws_topic, ws_data)["data"][
159
+ "uploadId"
160
+ ]
161
+ original_bytes = ca.encode("utf-8")
162
+ base64_string = base64.b64encode(original_bytes).decode("utf-8")
163
+ ws_api.chunk_file_upload(self, base64_string, 0, len(ca), len(ca), upload_id)
164
+
165
+ @override
166
+ def upload_mqtt_client_certificate(self, cert: str) -> None:
167
+ import base64
168
+
169
+ ws_type = "SET"
170
+ ws_topic = "protocols/mqtt/clientcertupload"
171
+ ws_data: dict[str, Any] = {}
172
+
173
+ self.logger.debug(f"Uploading MQTT client certificate to device {self.host}.")
174
+ upload_id = ws_api.send_request(self, ws_type, ws_topic, ws_data)["data"][
175
+ "uploadId"
176
+ ]
177
+ original_bytes = cert.encode("utf-8")
178
+ base64_string = base64.b64encode(original_bytes).decode("utf-8")
179
+ ws_api.chunk_file_upload(
180
+ self, base64_string, 0, len(cert), len(cert), upload_id
181
+ )
182
+
183
+ @override
184
+ def update_firmware(self, file: os.PathLike[AnyStr] | BytesIO) -> NETIODevice:
185
+ # TODO: Implement permission checks
186
+ # if "can_alter_settings" not in self.user_permissions:
187
+ # raise PermissionError(
188
+ # "You don't have permission to alter settings on this device."
189
+ # )
190
+ if self._ka_thread:
191
+ self._ka_thread.cancel()
192
+ self._ka_thread.join()
193
+
194
+ pre_reconnect_wait = 20
195
+ # TODO: Implement supported_features
196
+ try:
197
+ if self.supported_features["wifi"] == "yes":
198
+ wifi_settings = self.get_wifi_settings()
199
+ if (
200
+ wifi_settings["mode"] == "client"
201
+ and wifi_settings["client"]["status"] == "Connected"
202
+ ):
203
+ pre_reconnect_wait = 50
204
+ self.logger.debug(
205
+ "Increased wait time after fimrware update due to active Wi-Fi connection."
206
+ )
207
+ except KeyError:
208
+ pre_reconnect_wait = 100
209
+
210
+ # TODO: Checnk connectivity
211
+ # if esp_api.check_connectivity(self) > (pre_reconnect_wait / 10.0):
212
+ # pre_reconnect_wait = pre_reconnect_wait * 2
213
+ # self.logger.debug(
214
+ # "Increased wait time after firmware update due to poor connection quality."
215
+ # )
216
+
217
+ try:
218
+ ws_type = "SET"
219
+ ws_topic = "system/fwupdate"
220
+ ws_data = {}
221
+ upload_id = ws_api.send_request(self, ws_type, ws_topic, ws_data)["data"][
222
+ "uploadId"
223
+ ]
224
+
225
+ if isinstance(file, io.BufferedIOBase):
226
+ ws_api.upload_file(self, file, upload_id)
227
+ else:
228
+ self.logger.debug("Unsupported file format.")
229
+ except CommunicationError:
230
+ self.logger.warning(
231
+ f"Device {self.host} couldn't verify firmware update process beginning, this should be harmless if the device connects, waiting for connection."
232
+ )
233
+ self.logger.debug(
234
+ f"Uploaded firmware, device {self.host} might be unresponsive for a while."
235
+ )
236
+
237
+ # TODO: Sophisticated
238
+ pre_reconnect_wait = 30
239
+ sleep(pre_reconnect_wait)
240
+
241
+ self.logger.debug(
242
+ f"Retrying connection to device {self.host} after updating firmware to new version, the device class is now going to update."
243
+ )
244
+
245
+ # TODO: Check connectivity for new FW
246
+ # device_response_time = esp_api.check_connectivity(self)
247
+ # retry_limit = 3 if device_response_time == -1 else 0
248
+ # for _ in range(0, retry_limit):
249
+ # device_response_time = esp_api.check_connectivity(self)
250
+ #
251
+ # if device_response_time == -1:
252
+ # raise CommunicationError(
253
+ # "Device couldn't establish connection after firmware update."
254
+ # )
255
+
256
+ updated_instance = None
257
+ if type(self.netio_manager) is NetioManager:
258
+ updated_instance = self.netio_manager.update_device(self, ws_expected=True)
259
+
260
+ if isinstance(updated_instance, NETIODevice):
261
+ self.logger.info(
262
+ f"The device has updated to {updated_instance.get_version_detailed()}, the device object is now of the {type(updated_instance)} class, check documentation for supported features and changes."
263
+ )
264
+
265
+ if not updated_instance:
266
+ raise CommunicationError("Coudln't get updated instance.")
267
+
268
+ return updated_instance
269
+
270
+ @override
271
+ def import_config(self, file, **kwargs):
272
+ ws_type = "SET"
273
+ ws_topic = "system/cfgimport"
274
+ ws_data = {}
275
+ upload_id = ws_api.send_request(self, ws_type, ws_topic, ws_data)["data"][
276
+ "uploadId"
277
+ ]
278
+
279
+ if isinstance(file, io.BufferedIOBase):
280
+ self.logger.debug(f"Importing configuration to host {self.host}")
281
+ ws_api.upload_file(self, file, upload_id)
282
+ elif isinstance(file, str):
283
+ file_path = Path(file)
284
+ if file_path.exists():
285
+ with open(file, "rb") as file:
286
+ ws_api.upload_file(self, file, upload_id)
287
+ else:
288
+ self.logger.debug("Unsupported file format.")
289
+
290
+ @override
291
+ def ping(self) -> bool:
292
+ ws_type = "PING"
293
+ start_time = time()
294
+ ws_api.send_request(self, ws_type)
295
+ end_time = time()
296
+ self.logger.debug(
297
+ f"Pinging device {self.host} successful. Response time: {end_time - start_time}"
298
+ )
299
+ return True
300
+
301
+ @override
302
+ def export_config(self, save_file: str | None = None) -> dict[str, Any]:
303
+ ws_type = "SET"
304
+ ws_export_topics_list = [
305
+ "export/config/inputs",
306
+ "export/config/network",
307
+ "export/config/outputs",
308
+ "export/config/PAB",
309
+ "export/config/protocols",
310
+ "export/config/rules",
311
+ "export/config/schedules",
312
+ "export/config/system",
313
+ "export/config/users",
314
+ "export/config/wdtpingers",
315
+ "export/config/mqtt",
316
+ "export/config/nbus",
317
+ ]
318
+ ws_data: dict[str, Any] = {}
319
+
320
+ config_export: dict[str, Any] = dict()
321
+
322
+ for config_group in ws_export_topics_list:
323
+ self.logger.debug(
324
+ f"Exporting configuration for {config_group.split('/')[-1]} from device {self.host}"
325
+ )
326
+ group_data = ws_api.send_request(self, ws_type, config_group, ws_data).get(
327
+ "data", {}
328
+ )
329
+ config_export |= {config_group.split("/")[-1]: group_data}
330
+ if save_file is not None:
331
+ file_path = Path(save_file)
332
+ if file_path.exists():
333
+ self.logger.warning("Export config file exists, overriding.")
334
+ # TODO: Make override a method parameter
335
+
336
+ with open(save_file, "w+") as file:
337
+ conf_string = json.dumps(config_export, ensure_ascii=False, indent=4)
338
+ file.write(conf_string)
339
+
340
+ return config_export
341
+
342
+ @override
343
+ def _keep_alive(self) -> None:
344
+ self.logger.debug(f"Sending keep-alive to device {self.host}")
345
+ self._ka_thread = threading.Timer(120, self._keep_alive)
346
+ self._ka_thread.daemon = True
347
+ self._ka_thread.start()
348
+ self.ping()
@@ -0,0 +1,263 @@
1
+ """
2
+ Implementation specifics for ESP devices with the firmware 5.0.x.
3
+ """
4
+
5
+ import json
6
+ import ssl
7
+ from collections import deque
8
+ from time import sleep
9
+ from typing import Dict, List, Tuple
10
+
11
+ import websocket
12
+
13
+ from PyNetioConf.constants import (
14
+ DEFAULT_KEEP_ALIVE_QUEUE_LEN,
15
+ DEFAULT_REQUEST_QUEUE_LEN,
16
+ )
17
+
18
+ from .. import NetioManager
19
+ from ..exceptions import CommunicationError
20
+ from . import esp_api, ws_api
21
+ from .esp_400_device import ESP400Device
22
+
23
+
24
+ class ESP5BetaDevice(ESP400Device):
25
+ """
26
+ A class to control ESP devices with the firmware 5.0.x BETA.
27
+
28
+ DEPRICATED: This beta class will get removed in a future release.
29
+ WARNING: This release was and never will be fully supported, many features are missing and it is provided only for users who implemented their solution
30
+ alongside the development of the 5.0.x firmware. If anything doesn't work then please update the firmware of your device first.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ host: str,
36
+ username: str,
37
+ password: str,
38
+ sn_number: str,
39
+ hostname: str,
40
+ keep_alive: bool = True,
41
+ netio_manager: NetioManager = None,
42
+ use_https: bool = False,
43
+ ):
44
+ self.use_https = use_https
45
+ super().__init__(
46
+ host, username, password, sn_number, hostname, keep_alive, netio_manager
47
+ )
48
+
49
+ # Backporting these to the beta so it can remain compatible with ws_api for as long as possible
50
+ self._request_queue = deque(maxlen=DEFAULT_REQUEST_QUEUE_LEN)
51
+ self._pong_queue = deque(maxlen=DEFAULT_KEEP_ALIVE_QUEUE_LEN)
52
+
53
+ self.fw_version = self.get_version()
54
+
55
+ self._ssl_context = ssl.create_default_context()
56
+ self._ssl_context.check_hostname = False
57
+ self._ssl_context.verify_mode = ssl.CERT_NONE
58
+ if self.use_https:
59
+ self.ws = websocket.create_connection(
60
+ f"wss://{host}/emweb", sslopt={"cert_reqs": ssl.CERT_NONE}
61
+ )
62
+ else:
63
+ self.ws = websocket.create_connection(f"ws://{host}/emweb")
64
+ self.ws_req_id = 0
65
+ self._login_new(username, password)
66
+
67
+ def _login_new(self, username: str, password: str, logout=False) -> str:
68
+ if self.use_https:
69
+ self.ws = websocket.create_connection(
70
+ f"wss://{self.host}/emweb", sslopt={"cert_reqs": ssl.CERT_NONE}
71
+ )
72
+ else:
73
+ self.ws = websocket.create_connection(f"ws://{self.host}/emweb")
74
+ self.ws_req_id = 0
75
+ hello_response = ws_api.send_request(self, "HELO")
76
+
77
+ ws_api.login(
78
+ self,
79
+ hello_response["data"]["localTimestamp"],
80
+ hello_response["data"]["publicKey"],
81
+ username,
82
+ password,
83
+ )
84
+
85
+ def set_output(self, output_id: int, state: bool) -> None:
86
+ self._check_socket_index(output_id)
87
+ ws_api.send_request(
88
+ self,
89
+ "SET",
90
+ f"outputs/id/{output_id}/ctrl",
91
+ {"request": "on" if state else "off"},
92
+ )
93
+ self.logger.debug(
94
+ f"Setting output {output_id} on device {self.host} to {state}."
95
+ )
96
+
97
+ def set_json_api_state(
98
+ self,
99
+ protocol_enabled: bool,
100
+ read_enable: bool = None,
101
+ write_enable: bool = None,
102
+ read_auth: tuple[str, str] = None,
103
+ write_auth: tuple[str, str] = None,
104
+ ) -> None:
105
+ old_protocol_data = self.get_json_api_state()
106
+
107
+ protocol_data = {
108
+ "enable": protocol_enabled,
109
+ "port": 80,
110
+ "readOnlyEnable": read_enable
111
+ if read_enable is not None
112
+ else old_protocol_data["read"]["enable"],
113
+ "readUsername": read_auth[0]
114
+ if read_auth is not None
115
+ else old_protocol_data["read"]["username"],
116
+ "readPassword": read_auth[1]
117
+ if read_auth is not None
118
+ else old_protocol_data["read"]["password"],
119
+ "readWriteEnable": write_enable
120
+ if write_enable is not None
121
+ else old_protocol_data["write"]["enable"],
122
+ "writeUsername": write_auth[0]
123
+ if write_auth is not None
124
+ else old_protocol_data["write"]["username"],
125
+ "writePassword": write_auth[1]
126
+ if write_auth is not None
127
+ else old_protocol_data["write"]["password"],
128
+ }
129
+
130
+ ws_api.send_request(self, "SET", "protocols/json/config", protocol_data)
131
+
132
+ def get_output_states(self) -> list[tuple[int, bool]]:
133
+ socket_list = ws_api.send_request(self, "UNSUBSCRIBE", "outputs/measure")
134
+ return [
135
+ (socket["id"], socket["state"] == "on")
136
+ for socket in socket_list["data"]["items"]
137
+ ]
138
+
139
+ def get_measurement(self):
140
+ return ws_api.send_request(self, "UNSUBSCRIBE", "outputs/measure")
141
+
142
+ def upload_mqtt_client_key(self, key: str) -> None:
143
+ upload_path = ws_api.send_request(
144
+ self, "SET", "protocols/mqtt/clientkeyupload", {}
145
+ )["data"]["uploadPath"]
146
+ response = esp_api.send_file(self, upload_path, key)
147
+ sleep(0.1)
148
+
149
+ def upload_mqtt_client_certificate(self, cert: str) -> None:
150
+ upload_path = ws_api.send_request(
151
+ self, "SET", "protocols/mqtt/clientcertupload", {}
152
+ )["data"]["uploadPath"]
153
+ response = esp_api.send_file(self, upload_path, cert)
154
+ sleep(0.1)
155
+
156
+ def upload_mqtt_ca_certificate(self, ca: str) -> None:
157
+ upload_path = ws_api.send_request(
158
+ self, "SET", "protocols/mqtt/cacertupload", {}
159
+ )["data"]["uploadPath"]
160
+ response = esp_api.send_file(self, upload_path, ca)
161
+ sleep(0.1)
162
+
163
+ def get_mqttflex_state(self) -> dict:
164
+ return ws_api.send_request(self, "UNSUBSCRIBE", "protocols/mqtt/config")["data"]
165
+
166
+ def set_mqttflex_state(self, state: bool, config: dict = None) -> None:
167
+ if config is None:
168
+ config = self.get_mqttflex_state()["config"]
169
+ ws_api.send_request(
170
+ self,
171
+ "SET",
172
+ "protocols/mqtt/config",
173
+ {"enable": state, "config": json.dumps(config)},
174
+ )
175
+ sleep(1)
176
+
177
+ def export_config(self, save_file: str = None) -> dict:
178
+ config = ws_api.send_request(self, "SET", "system/cfgexport", data={})
179
+ if save_file:
180
+ with open(save_file, "w") as file:
181
+ json.dump(config["data"]["config"], file)
182
+ return config["data"]["config"]
183
+
184
+ def import_config(self, file, **kwargs) -> None:
185
+ if "can_alter_settings" not in self.user_permissions:
186
+ raise PermissionError(
187
+ "You don't have permission to alter settings on this device."
188
+ )
189
+ if self._ka_thread:
190
+ self._ka_thread.cancel()
191
+ self._ka_thread.join()
192
+ data = json.load(file)
193
+ encoded_data = json.dumps(data).encode("utf-8")
194
+ ws_api.send_request(self, "SET", "system/cfgimport", data={})
195
+ esp_api.send_file(self, "/cfgimport", encoded_data)
196
+ self.logger.info(
197
+ f"Imported configuration from {file.name}, device {self.host} is restarting..."
198
+ )
199
+ sleep(kwargs.get("sleep_time", 15))
200
+ username = kwargs.get("username", self.username)
201
+ password = kwargs.get("password", self.password)
202
+
203
+ self._login_new(username, password)
204
+ if self._ka_thread:
205
+ self._keep_alive()
206
+
207
+ def update_firmware(self, file) -> None:
208
+ if "can_alter_settings" not in self.user_permissions:
209
+ raise PermissionError(
210
+ "You don't have permission to alter settings on this device."
211
+ )
212
+ if self._ka_thread:
213
+ self._ka_thread.cancel()
214
+ self._ka_thread.join()
215
+
216
+ pre_reconnect_wait = 20
217
+ if self.supported_features["wifi"] == "yes":
218
+ wifi_settings = self.get_wifi_settings()
219
+ if (
220
+ wifi_settings["mode"] == "client"
221
+ and wifi_settings["client"]["status"] == "Connected"
222
+ ):
223
+ pre_reconnect_wait = 50
224
+ self.logger.debug(
225
+ "Increased wait time after fimrware update due to active Wi-Fi connection."
226
+ )
227
+
228
+ if esp_api.check_connectivity(self) > (pre_reconnect_wait / 10.0):
229
+ pre_reconnect_wait = pre_reconnect_wait * 2
230
+ self.logger.debug(
231
+ "Increased wait time after firmware update due to poor connection quality."
232
+ )
233
+
234
+ _ = esp_api.send_request(self, "prepFwUpgrade")
235
+ _ = esp_api.send_file(self, "/upload/firmware", file)
236
+ try:
237
+ _ = esp_api.send_request(self, "startUpgrade", close=True)
238
+ except CommunicationError:
239
+ self.logger.warn(
240
+ f"Device {self.host} couldn't verify firmware update process beginning, this should be harmless if the device connects, waiting for connection."
241
+ )
242
+ self.logger.debug(
243
+ f"Uploaded firmware {file.name}, device {self.host} might be unresponsive for a while."
244
+ )
245
+
246
+ sleep(pre_reconnect_wait)
247
+
248
+ self.logger.debug(
249
+ f"Retrying connection to device {self.host} after updating firmware to {file.name}."
250
+ )
251
+ device_response_time = esp_api.check_connectivity(self)
252
+ retry_limit = 3 if device_response_time == -1 else 0
253
+ for _ in range(0, retry_limit):
254
+ device_response_time = esp_api.check_connectivity(self)
255
+
256
+ if device_response_time == -1:
257
+ raise CommunicationError(
258
+ "Device couldn't establish connection after firmware update."
259
+ )
260
+
261
+ updated_instance = self.netio_manager.update_device(self)
262
+
263
+ return updated_instance