pyezvizapi 1.0.0.0__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.

Potentially problematic release.


This version of pyezvizapi might be problematic. Click here for more details.

pyezvizapi/mqtt.py ADDED
@@ -0,0 +1,259 @@
1
+ """Ezviz cloud MQTT client for push messages."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ from typing import Any
10
+
11
+ import paho.mqtt.client as mqtt
12
+ import requests
13
+
14
+ from .api_endpoints import (
15
+ API_ENDPOINT_REGISTER_MQTT,
16
+ API_ENDPOINT_START_MQTT,
17
+ API_ENDPOINT_STOP_MQTT,
18
+ )
19
+ from .constants import (
20
+ APP_SECRET,
21
+ DEFAULT_TIMEOUT,
22
+ FEATURE_CODE,
23
+ MQTT_APP_KEY,
24
+ REQUEST_HEADER,
25
+ )
26
+ from .exceptions import HTTPError, InvalidURL, PyEzvizError
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class MQTTClient(threading.Thread):
32
+ """Open MQTT connection to ezviz cloud."""
33
+
34
+ def __init__(
35
+ self,
36
+ token: dict,
37
+ timeout: int = DEFAULT_TIMEOUT,
38
+ ) -> None:
39
+ """Initialize the client object."""
40
+ threading.Thread.__init__(self)
41
+ self._session = requests.session()
42
+ self._session.headers.update(REQUEST_HEADER)
43
+ self._token = token or {
44
+ "session_id": None,
45
+ "rf_session_id": None,
46
+ "username": None,
47
+ "api_url": "apiieu.ezvizlife.com",
48
+ }
49
+ self._timeout = timeout
50
+ self._stop_event = threading.Event()
51
+ self._mqtt_data = {
52
+ "mqtt_clientid": None,
53
+ "ticket": None,
54
+ "push_url": token["service_urls"]["pushAddr"],
55
+ }
56
+ self.mqtt_client = None
57
+ self.rcv_message: dict[Any, Any] = {}
58
+
59
+ def on_subscribe(
60
+ self, client: Any, userdata: Any, mid: Any, granted_qos: Any
61
+ ) -> None:
62
+ """On MQTT message subscribe."""
63
+ # pylint: disable=unused-argument
64
+ _LOGGER.info("Subscribed: %s %s", mid, granted_qos)
65
+
66
+ def on_connect(
67
+ self, client: Any, userdata: Any, flags: Any, return_code: Any
68
+ ) -> None:
69
+ """On MQTT connect."""
70
+ # pylint: disable=unused-argument
71
+ if return_code == 0:
72
+ _LOGGER.info("Connected OK with return code %s", return_code)
73
+ else:
74
+ _LOGGER.info("Connection Error with Return code %s", return_code)
75
+ client.reconnect()
76
+
77
+ def on_message(self, client: Any, userdata: Any, msg: Any) -> None:
78
+ """On MQTT message receive."""
79
+ # pylint: disable=unused-argument
80
+ try:
81
+ mqtt_message = json.loads(msg.payload)
82
+
83
+ except ValueError as err:
84
+ self.stop()
85
+ raise PyEzvizError(
86
+ "Impossible to decode mqtt message: " + str(err)
87
+ ) from err
88
+
89
+ mqtt_message["ext"] = mqtt_message["ext"].split(",")
90
+
91
+ # Format payload message and keep latest device message.
92
+ self.rcv_message[mqtt_message["ext"][2]] = {
93
+ "id": mqtt_message["id"],
94
+ "alert": mqtt_message["alert"],
95
+ "time": mqtt_message["ext"][1],
96
+ "alert type": mqtt_message["ext"][4],
97
+ "image": mqtt_message["ext"][16] if len(mqtt_message["ext"]) > 16 else None,
98
+ }
99
+
100
+ _LOGGER.debug(self.rcv_message, exc_info=True)
101
+
102
+ def _mqtt(self) -> mqtt.Client:
103
+ """Receive MQTT messages from ezviz server."""
104
+
105
+ ezviz_mqtt_client = mqtt.Client(
106
+ client_id=self._mqtt_data["mqtt_clientid"], protocol=4, transport="tcp"
107
+ )
108
+ ezviz_mqtt_client.on_connect = self.on_connect
109
+ ezviz_mqtt_client.on_subscribe = self.on_subscribe
110
+ ezviz_mqtt_client.on_message = self.on_message
111
+ ezviz_mqtt_client.username_pw_set(MQTT_APP_KEY, APP_SECRET)
112
+
113
+ ezviz_mqtt_client.connect(self._mqtt_data["push_url"], 1882, 60)
114
+ ezviz_mqtt_client.subscribe(
115
+ f"{MQTT_APP_KEY}/ticket/{self._mqtt_data['ticket']}", qos=2
116
+ )
117
+
118
+ ezviz_mqtt_client.loop_start()
119
+ return ezviz_mqtt_client
120
+
121
+ def _register_ezviz_push(self) -> None:
122
+ """Register for push messages."""
123
+
124
+ auth_seq = (
125
+ "Basic "
126
+ + base64.b64encode(f"{MQTT_APP_KEY}:{APP_SECRET}".encode("ascii")).decode()
127
+ )
128
+
129
+ payload = {
130
+ "appKey": MQTT_APP_KEY,
131
+ "clientType": "5",
132
+ "mac": FEATURE_CODE,
133
+ "token": "123456",
134
+ "version": "v1.3.0",
135
+ }
136
+
137
+ try:
138
+ req = self._session.post(
139
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_REGISTER_MQTT}",
140
+ allow_redirects=False,
141
+ headers={"Authorization": auth_seq},
142
+ data=payload,
143
+ timeout=self._timeout,
144
+ )
145
+
146
+ req.raise_for_status()
147
+
148
+ except requests.ConnectionError as err:
149
+ raise InvalidURL("A Invalid URL or Proxy error occured") from err
150
+
151
+ except requests.HTTPError as err:
152
+ raise HTTPError from err
153
+
154
+ try:
155
+ json_result = req.json()
156
+
157
+ except ValueError as err:
158
+ raise PyEzvizError(
159
+ "Impossible to decode response: "
160
+ + str(err)
161
+ + "\nResponse was: "
162
+ + str(req.text)
163
+ ) from err
164
+
165
+ self._mqtt_data["mqtt_clientid"] = json_result["data"]["clientId"]
166
+
167
+ def run(self) -> None:
168
+ """Start mqtt thread."""
169
+
170
+ if self._token.get("username") is None:
171
+ self._stop_event.set()
172
+ raise PyEzvizError(
173
+ "Ezviz internal username is required. Call EzvizClient login without token."
174
+ )
175
+
176
+ self._register_ezviz_push()
177
+ self._start_ezviz_push()
178
+ self.mqtt_client = self._mqtt()
179
+
180
+ def start(self) -> None:
181
+ """Start mqtt thread as application. Set logging first to see messages."""
182
+ self.run()
183
+
184
+ try:
185
+ while not self._stop_event.is_set():
186
+ time.sleep(1)
187
+ except KeyboardInterrupt:
188
+ self.stop()
189
+
190
+ def stop(self) -> None:
191
+ """Stop push notifications."""
192
+
193
+ payload = {
194
+ "appKey": MQTT_APP_KEY,
195
+ "clientId": self._mqtt_data["mqtt_clientid"],
196
+ "clientType": 5,
197
+ "sessionId": self._token["session_id"],
198
+ "username": self._token["username"],
199
+ }
200
+
201
+ try:
202
+ req = self._session.post(
203
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_STOP_MQTT}",
204
+ data=payload,
205
+ timeout=self._timeout,
206
+ )
207
+
208
+ req.raise_for_status()
209
+
210
+ except requests.ConnectionError as err:
211
+ raise InvalidURL("A Invalid URL or Proxy error occured") from err
212
+
213
+ except requests.HTTPError as err:
214
+ raise HTTPError from err
215
+
216
+ finally:
217
+ self._stop_event.set()
218
+ self.mqtt_client.loop_stop()
219
+
220
+ def _start_ezviz_push(self) -> None:
221
+ """Send start for push messages to ezviz api."""
222
+
223
+ payload = {
224
+ "appKey": MQTT_APP_KEY,
225
+ "clientId": self._mqtt_data["mqtt_clientid"],
226
+ "clientType": 5,
227
+ "sessionId": self._token["session_id"],
228
+ "username": self._token["username"],
229
+ "token": "123456",
230
+ }
231
+
232
+ try:
233
+ req = self._session.post(
234
+ f"https://{self._mqtt_data['push_url']}{API_ENDPOINT_START_MQTT}",
235
+ allow_redirects=False,
236
+ data=payload,
237
+ timeout=self._timeout,
238
+ )
239
+
240
+ req.raise_for_status()
241
+
242
+ except requests.ConnectionError as err:
243
+ raise InvalidURL("A Invalid URL or Proxy error occured") from err
244
+
245
+ except requests.HTTPError as err:
246
+ raise HTTPError from err
247
+
248
+ try:
249
+ json_result = req.json()
250
+
251
+ except ValueError as err:
252
+ raise PyEzvizError(
253
+ "Impossible to decode response: "
254
+ + str(err)
255
+ + "\nResponse was: "
256
+ + str(req.text)
257
+ ) from err
258
+
259
+ self._mqtt_data["ticket"] = json_result["ticket"]
@@ -0,0 +1,149 @@
1
+ """Test camera RTSP authentication."""
2
+ import base64
3
+ import hashlib
4
+ import socket
5
+
6
+ from .exceptions import AuthTestResultFailed, InvalidHost
7
+
8
+
9
+ def genmsg_describe(url, seq, user_agent, auth_seq):
10
+ """Generate RTSP describe message."""
11
+ msg_ret = "DESCRIBE " + url + " RTSP/1.0\r\n"
12
+ msg_ret += "CSeq: " + str(seq) + "\r\n"
13
+ msg_ret += "Authorization: " + auth_seq + "\r\n"
14
+ msg_ret += "User-Agent: " + user_agent + "\r\n"
15
+ msg_ret += "Accept: application/sdp\r\n"
16
+ msg_ret += "\r\n"
17
+ return msg_ret
18
+
19
+
20
+ class TestRTSPAuth:
21
+ """Test RTSP credentials."""
22
+
23
+ def __init__(
24
+ self,
25
+ ip_addr,
26
+ username=None,
27
+ password=None,
28
+ test_uri="",
29
+ ) -> None:
30
+ """Initialize RTSP credential test."""
31
+ self._rtsp_details = {
32
+ "bufLen": 1024,
33
+ "defaultServerIp": ip_addr,
34
+ "defaultServerPort": 554,
35
+ "defaultTestUri": test_uri,
36
+ "defaultUserAgent": "RTSP Client",
37
+ "defaultUsername": username,
38
+ "defaultPassword": password,
39
+ }
40
+
41
+ def generate_auth_string(self, realm, method, uri, nonce):
42
+ """Generate digest auth string."""
43
+ map_return_info = {}
44
+ m_1 = hashlib.md5(
45
+ f"{self._rtsp_details['defaultUsername']}:"
46
+ f"{realm.decode()}:"
47
+ f"{self._rtsp_details['defaultPassword']}".encode()
48
+ ).hexdigest()
49
+ m_2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
50
+ response = hashlib.md5(f"{m_1}:{nonce}:{m_2}".encode()).hexdigest()
51
+
52
+ map_return_info = (
53
+ f"Digest "
54
+ f"username=\"{self._rtsp_details['defaultUsername']}\", "
55
+ f'realm="{realm.decode()}", '
56
+ f'algorithm="MD5", '
57
+ f'nonce="{nonce.decode()}", '
58
+ f'uri="{uri}", '
59
+ f'response="{response}"'
60
+ )
61
+ return map_return_info
62
+
63
+ def main(self):
64
+ """Start main method."""
65
+ session = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
66
+
67
+ try:
68
+ session.connect(
69
+ (
70
+ self._rtsp_details["defaultServerIp"],
71
+ self._rtsp_details["defaultServerPort"],
72
+ )
73
+ )
74
+
75
+ except TimeoutError as err:
76
+ raise AuthTestResultFailed("Invalid ip or camera hibernating") from err
77
+
78
+ except (socket.gaierror, ConnectionRefusedError) as err:
79
+ raise InvalidHost("Invalid IP or Hostname") from err
80
+
81
+ seq = 1
82
+
83
+ url = (
84
+ "rtsp://"
85
+ + self._rtsp_details["defaultServerIp"]
86
+ + self._rtsp_details["defaultTestUri"]
87
+ )
88
+
89
+ auth_seq = base64.b64encode(
90
+ f"{self._rtsp_details['defaultUsername']}:"
91
+ f"{self._rtsp_details['defaultPassword']}".encode("ascii")
92
+ )
93
+ auth_seq = "Basic " + auth_seq.decode()
94
+
95
+ print(
96
+ genmsg_describe(url, seq, self._rtsp_details["defaultUserAgent"], auth_seq)
97
+ )
98
+ session.send(
99
+ genmsg_describe(
100
+ url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
101
+ ).encode()
102
+ )
103
+ msg1 = session.recv(self._rtsp_details["bufLen"])
104
+ seq = seq + 1
105
+
106
+ if msg1.decode().find("200 OK") > 1:
107
+ print(f"Basic auth result: {msg1.decode()}")
108
+ return print("Basic Auth test passed. Credentials Valid!")
109
+
110
+ if msg1.decode().find("Unauthorized") > 1:
111
+ # Basic failed, doing new DESCRIBE with digest authentication.
112
+ start = msg1.decode().find("realm")
113
+ begin = msg1.decode().find('"', start)
114
+ end = msg1.decode().find('"', begin + 1)
115
+ realm = msg1[begin + 1 : end]
116
+
117
+ start = msg1.decode().find("nonce")
118
+ begin = msg1.decode().find('"', start)
119
+ end = msg1.decode().find('"', begin + 1)
120
+ nonce = msg1[begin + 1 : end]
121
+
122
+ auth_seq = self.generate_auth_string(
123
+ realm,
124
+ "DESCRIBE",
125
+ self._rtsp_details["defaultTestUri"],
126
+ nonce,
127
+ )
128
+
129
+ print(
130
+ genmsg_describe(
131
+ url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
132
+ )
133
+ )
134
+
135
+ session.send(
136
+ genmsg_describe(
137
+ url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
138
+ ).encode()
139
+ )
140
+ msg1 = session.recv(self._rtsp_details["bufLen"])
141
+ print(f"Digest auth result: {msg1.decode()}")
142
+
143
+ if msg1.decode().find("200 OK") > 1:
144
+ return print("Digest Auth test Passed. Credentials Valid!")
145
+
146
+ if msg1.decode().find("401 Unauthorized") > 1:
147
+ raise AuthTestResultFailed("Credentials not valid!!")
148
+
149
+ return print("Basic Auth test passed. Credentials Valid!")
pyezvizapi/utils.py ADDED
@@ -0,0 +1,160 @@
1
+ """Decrypt camera images."""
2
+ from __future__ import annotations
3
+
4
+ from hashlib import md5
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from Crypto.Cipher import AES
10
+
11
+ from .exceptions import PyEzvizError
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ def convert_to_dict(data: Any) -> Any:
17
+ """Recursively convert a string representation of a dictionary to a dictionary."""
18
+ if isinstance(data, dict):
19
+ for key, value in data.items():
20
+ if isinstance(value, str):
21
+ try:
22
+ # Attempt to convert the string back into a dictionary
23
+ data[key] = json.loads(value)
24
+
25
+ except ValueError:
26
+ continue
27
+ continue
28
+
29
+ return data
30
+
31
+
32
+ def string_to_list(data: Any, separator: str = ",") -> Any:
33
+ """Convert a string representation of a list to a list."""
34
+ if isinstance(data, str):
35
+ if separator in data:
36
+ try:
37
+ # Attempt to convert the string into a list
38
+ return data.split(separator)
39
+
40
+ except AttributeError:
41
+ return data
42
+
43
+ return data
44
+
45
+
46
+ def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
47
+ """Fetch the value corresponding to the given nested keys in a dictionary.
48
+
49
+ If any of the keys in the path doesn't exist, the default value is returned.
50
+
51
+ Args:
52
+ data (dict): The nested dictionary to search for keys.
53
+ keys (list): A list of keys representing the path to the desired value.
54
+ default_value (optional): The value to return if any of the keys doesn't exist.
55
+
56
+ Returns:
57
+ The value corresponding to the nested keys or the default value.
58
+
59
+ """
60
+ try:
61
+ for key in keys:
62
+ data = data[key]
63
+ return data
64
+ except (KeyError, TypeError):
65
+ return default_value
66
+
67
+
68
+ def decrypt_image(input_data: bytes, password: str) -> bytes:
69
+ """Decrypts image data with provided password.
70
+
71
+ Args:
72
+ input_data (bytes): Encrypted image data
73
+ password (string): Verification code
74
+
75
+ Raises:
76
+ PyEzvizError
77
+
78
+ Returns:
79
+ bytes: Decrypted image data
80
+
81
+ """
82
+
83
+ if len(input_data) < 48:
84
+ raise PyEzvizError("Invalid image data")
85
+
86
+ # check header
87
+ if input_data[:16] != b"hikencodepicture":
88
+ _LOGGER.warning("Image header doesn't contain 'hikencodepicture'")
89
+ return input_data
90
+
91
+ file_hash = input_data[16:48]
92
+ passwd_hash = (
93
+ md5(str.encode(md5(str.encode(password)).digest().hex())).digest().hex()
94
+ )
95
+ if file_hash != str.encode(passwd_hash):
96
+ raise PyEzvizError("Invalid password")
97
+
98
+ key = str.encode(password.ljust(16, "\u0000")[:16])
99
+ iv_code = bytes([48, 49, 50, 51, 52, 53, 54, 55, 0, 0, 0, 0, 0, 0, 0, 0])
100
+ cipher = AES.new(key, AES.MODE_CBC, iv_code)
101
+
102
+ next_chunk = b""
103
+ output_data = b""
104
+ finished = False
105
+ i = 48 # offset hikencodepicture + hash
106
+ chunk_size = 1024 * AES.block_size
107
+ while not finished:
108
+ chunk, next_chunk = next_chunk, cipher.decrypt(input_data[i : i + chunk_size])
109
+ if len(next_chunk) == 0:
110
+ padding_length = chunk[-1]
111
+ chunk = chunk[:-padding_length]
112
+ finished = True
113
+ output_data += chunk
114
+ i += chunk_size
115
+ return output_data
116
+
117
+ def deep_merge(dict1, dict2):
118
+ """Recursively merges two dictionaries, handling lists as well.
119
+
120
+ Args:
121
+ dict1 (dict): The first dictionary.
122
+ dict2 (dict): The second dictionary.
123
+
124
+ Returns:
125
+ dict: The merged dictionary.
126
+
127
+ """
128
+ # If one of the dictionaries is None, return the other one
129
+ if dict1 is None:
130
+ return dict2
131
+ if dict2 is None:
132
+ return dict1
133
+
134
+ if not isinstance(dict1, dict) or not isinstance(dict2, dict):
135
+ if isinstance(dict1, list) and isinstance(dict2, list):
136
+ return dict1 + dict2
137
+ return dict2
138
+
139
+ # Create a new dictionary to store the merged result
140
+ merged = {}
141
+
142
+ # Merge keys from both dictionaries
143
+ for key in set(dict1.keys()) | set(dict2.keys()):
144
+ if key in dict1 and key in dict2:
145
+ if isinstance(dict1[key], dict) and isinstance(dict2[key], dict):
146
+ merged[key] = deep_merge(dict1[key], dict2[key])
147
+ elif isinstance(dict1[key], list) and isinstance(dict2[key], list):
148
+ merged[key] = dict1[key] + dict2[key]
149
+ else:
150
+ # If both values are not dictionaries or lists, keep the value from dict2
151
+ merged[key] = dict2[key]
152
+ elif key in dict1:
153
+ # If the key is only in dict1, keep its value
154
+ merged[key] = dict1[key]
155
+ else:
156
+ # If the key is only in dict2, keep its value
157
+ merged[key] = dict2[key]
158
+
159
+ return merged
160
+