pyezvizapi 1.0.2.3__py3-none-any.whl → 1.0.4.3__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.
- pyezvizapi/__init__.py +54 -0
- pyezvizapi/__main__.py +124 -10
- pyezvizapi/api_endpoints.py +53 -1
- pyezvizapi/camera.py +196 -15
- pyezvizapi/cas.py +4 -2
- pyezvizapi/client.py +3693 -953
- pyezvizapi/constants.py +33 -4
- pyezvizapi/feature.py +536 -0
- pyezvizapi/light_bulb.py +1 -1
- pyezvizapi/mqtt.py +22 -16
- pyezvizapi/test_cam_rtsp.py +43 -21
- pyezvizapi/test_mqtt.py +53 -11
- pyezvizapi/utils.py +182 -71
- pyezvizapi-1.0.4.3.dist-info/METADATA +286 -0
- pyezvizapi-1.0.4.3.dist-info/RECORD +21 -0
- pyezvizapi-1.0.2.3.dist-info/METADATA +0 -27
- pyezvizapi-1.0.2.3.dist-info/RECORD +0 -21
- pyezvizapi-1.0.2.3.dist-info/entry_points.txt +0 -2
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/WHEEL +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE.md +0 -0
- {pyezvizapi-1.0.2.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/top_level.txt +0 -0
pyezvizapi/utils.py
CHANGED
|
@@ -2,22 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
5
6
|
import datetime
|
|
6
7
|
from hashlib import md5
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import re as _re
|
|
10
11
|
from typing import Any
|
|
11
|
-
import uuid
|
|
12
12
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
13
|
|
|
14
14
|
from Crypto.Cipher import AES
|
|
15
15
|
|
|
16
|
+
from .constants import HIK_ENCRYPTION_HEADER
|
|
16
17
|
from .exceptions import PyEzvizError
|
|
17
18
|
|
|
18
19
|
_LOGGER = logging.getLogger(__name__)
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def coerce_int(value: Any) -> int | None:
|
|
23
|
+
"""Best-effort coercion to int for mixed payloads."""
|
|
24
|
+
|
|
25
|
+
if isinstance(value, bool):
|
|
26
|
+
return int(value)
|
|
27
|
+
|
|
28
|
+
if isinstance(value, (int, float)):
|
|
29
|
+
return int(value)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
return int(str(value))
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_json(value: Any) -> Any:
|
|
38
|
+
"""Decode a JSON string when possible, otherwise return the original value."""
|
|
39
|
+
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(value)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return None
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
21
48
|
def convert_to_dict(data: Any) -> Any:
|
|
22
49
|
"""Recursively convert a string representation of a dictionary to a dictionary."""
|
|
23
50
|
if isinstance(data, dict):
|
|
@@ -36,18 +63,64 @@ def convert_to_dict(data: Any) -> Any:
|
|
|
36
63
|
|
|
37
64
|
def string_to_list(data: Any, separator: str = ",") -> Any:
|
|
38
65
|
"""Convert a string representation of a list to a list."""
|
|
39
|
-
if isinstance(data, str):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return data.split(separator)
|
|
66
|
+
if isinstance(data, str) and separator in data:
|
|
67
|
+
try:
|
|
68
|
+
# Attempt to convert the string into a list
|
|
69
|
+
return data.split(separator)
|
|
44
70
|
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
except AttributeError:
|
|
72
|
+
return data
|
|
47
73
|
|
|
48
74
|
return data
|
|
49
75
|
|
|
50
76
|
|
|
77
|
+
PathComponent = str | int
|
|
78
|
+
WILDCARD_STEP = "*"
|
|
79
|
+
_MISSING = object()
|
|
80
|
+
MILLISECONDS_THRESHOLD = 1e11
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def iter_nested(data: Any, path: Iterable[PathComponent]) -> Iterator[Any]:
|
|
84
|
+
"""Yield values reachable by following a dotted path with optional wildcards."""
|
|
85
|
+
|
|
86
|
+
current: list[Any] = [data]
|
|
87
|
+
|
|
88
|
+
for step in path:
|
|
89
|
+
next_level: list[Any] = []
|
|
90
|
+
for candidate in current:
|
|
91
|
+
if step == WILDCARD_STEP:
|
|
92
|
+
if isinstance(candidate, dict):
|
|
93
|
+
next_level.extend(candidate.values())
|
|
94
|
+
elif isinstance(candidate, (list, tuple)):
|
|
95
|
+
next_level.extend(candidate)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if isinstance(candidate, dict) and step in candidate:
|
|
99
|
+
next_level.append(candidate[step])
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
isinstance(candidate, (list, tuple))
|
|
104
|
+
and isinstance(step, int)
|
|
105
|
+
and -len(candidate) <= step < len(candidate)
|
|
106
|
+
):
|
|
107
|
+
next_level.append(candidate[step])
|
|
108
|
+
|
|
109
|
+
current = next_level
|
|
110
|
+
if not current:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
yield from current
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def first_nested(
|
|
117
|
+
data: Any, path: Iterable[PathComponent], default: Any = None
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Return the first value produced by iter_nested or ``default``."""
|
|
120
|
+
|
|
121
|
+
return next(iter_nested(data, path), default)
|
|
122
|
+
|
|
123
|
+
|
|
51
124
|
def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
|
|
52
125
|
"""Fetch the value corresponding to the given nested keys in a dictionary.
|
|
53
126
|
|
|
@@ -62,14 +135,8 @@ def fetch_nested_value(data: Any, keys: list, default_value: Any = None) -> Any:
|
|
|
62
135
|
The value corresponding to the nested keys or the default value.
|
|
63
136
|
|
|
64
137
|
"""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
data = data[key]
|
|
68
|
-
|
|
69
|
-
except (KeyError, TypeError):
|
|
70
|
-
return default_value
|
|
71
|
-
|
|
72
|
-
return data
|
|
138
|
+
value = first_nested(data, keys, _MISSING)
|
|
139
|
+
return default_value if value is _MISSING else value
|
|
73
140
|
|
|
74
141
|
|
|
75
142
|
def decrypt_image(input_data: bytes, password: str) -> bytes:
|
|
@@ -86,37 +153,102 @@ def decrypt_image(input_data: bytes, password: str) -> bytes:
|
|
|
86
153
|
bytes: Decrypted image data
|
|
87
154
|
|
|
88
155
|
"""
|
|
89
|
-
|
|
156
|
+
header_len = len(HIK_ENCRYPTION_HEADER)
|
|
157
|
+
min_length = header_len + 32 # header + md5 hash
|
|
158
|
+
|
|
159
|
+
if len(input_data) < min_length:
|
|
90
160
|
raise PyEzvizError("Invalid image data")
|
|
91
161
|
|
|
92
|
-
|
|
93
|
-
if
|
|
94
|
-
_LOGGER.debug("Image header doesn't contain
|
|
162
|
+
header_index = input_data.find(HIK_ENCRYPTION_HEADER)
|
|
163
|
+
if header_index == -1:
|
|
164
|
+
_LOGGER.debug("Image header doesn't contain %s", HIK_ENCRYPTION_HEADER)
|
|
95
165
|
return input_data
|
|
96
166
|
|
|
97
|
-
|
|
167
|
+
if header_index:
|
|
168
|
+
_LOGGER.debug("Image header found at offset %s, trimming preamble", header_index)
|
|
169
|
+
input_data = input_data[header_index:]
|
|
170
|
+
if len(input_data) < min_length:
|
|
171
|
+
raise PyEzvizError("Invalid image data after trimming preamble")
|
|
172
|
+
|
|
173
|
+
hash_end = header_len + 32
|
|
174
|
+
blocks = _split_encrypted_blocks(input_data, header_len, min_length)
|
|
175
|
+
if not blocks:
|
|
176
|
+
raise PyEzvizError("Invalid image data")
|
|
177
|
+
|
|
178
|
+
decrypted_parts = [
|
|
179
|
+
_decrypt_single_block(block, password, header_len, hash_end) for block in blocks
|
|
180
|
+
]
|
|
181
|
+
if len(decrypted_parts) > 1:
|
|
182
|
+
_LOGGER.debug("Decrypted %s concatenated image blocks", len(decrypted_parts))
|
|
183
|
+
return b"".join(decrypted_parts)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _split_encrypted_blocks(
|
|
187
|
+
data: bytes, header_len: int, min_length: int
|
|
188
|
+
) -> list[bytes]:
|
|
189
|
+
"""Split concatenated hikencodepicture segments into individual blocks."""
|
|
190
|
+
blocks: list[bytes] = []
|
|
191
|
+
cursor = 0
|
|
192
|
+
data_len = len(data)
|
|
193
|
+
|
|
194
|
+
while cursor <= data_len - min_length:
|
|
195
|
+
if data[cursor : cursor + header_len] != HIK_ENCRYPTION_HEADER:
|
|
196
|
+
next_header = data.find(HIK_ENCRYPTION_HEADER, cursor + 1)
|
|
197
|
+
if next_header == -1:
|
|
198
|
+
break
|
|
199
|
+
cursor = next_header
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
next_header = data.find(HIK_ENCRYPTION_HEADER, cursor + header_len)
|
|
203
|
+
block = data[cursor : next_header if next_header != -1 else data_len]
|
|
204
|
+
if len(block) < min_length:
|
|
205
|
+
break
|
|
206
|
+
blocks.append(block)
|
|
207
|
+
if next_header == -1:
|
|
208
|
+
break
|
|
209
|
+
cursor = next_header
|
|
210
|
+
|
|
211
|
+
return blocks
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _decrypt_single_block(
|
|
215
|
+
block: bytes, password: str, header_len: int, hash_end: int
|
|
216
|
+
) -> bytes:
|
|
217
|
+
"""Decrypt a single hikencodepicture block."""
|
|
218
|
+
file_hash = block[header_len:hash_end]
|
|
98
219
|
passwd_hash = md5(str.encode(md5(str.encode(password)).hexdigest())).hexdigest()
|
|
99
220
|
if file_hash != str.encode(passwd_hash):
|
|
100
221
|
raise PyEzvizError("Invalid password")
|
|
101
222
|
|
|
223
|
+
ciphertext = block[hash_end:]
|
|
224
|
+
if not ciphertext:
|
|
225
|
+
raise PyEzvizError("Missing ciphertext payload")
|
|
226
|
+
|
|
227
|
+
remainder = len(ciphertext) % AES.block_size
|
|
228
|
+
if remainder:
|
|
229
|
+
_LOGGER.debug(
|
|
230
|
+
"Ciphertext not aligned to 16 bytes; trimming %s trailing bytes", remainder
|
|
231
|
+
)
|
|
232
|
+
ciphertext = ciphertext[:-remainder]
|
|
233
|
+
if not ciphertext:
|
|
234
|
+
raise PyEzvizError("Ciphertext too short after alignment adjustment")
|
|
235
|
+
|
|
102
236
|
key = str.encode(password.ljust(16, "\u0000")[:16])
|
|
103
237
|
iv_code = bytes([48, 49, 50, 51, 52, 53, 54, 55, 0, 0, 0, 0, 0, 0, 0, 0])
|
|
104
238
|
cipher = AES.new(key, AES.MODE_CBC, iv_code)
|
|
105
239
|
|
|
106
|
-
next_chunk = b""
|
|
107
|
-
output_data = b""
|
|
108
|
-
finished = False
|
|
109
|
-
i = 48 # offset hikencodepicture + hash
|
|
110
240
|
chunk_size = 1024 * AES.block_size
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
241
|
+
output_data = bytearray()
|
|
242
|
+
|
|
243
|
+
for start in range(0, len(ciphertext), chunk_size):
|
|
244
|
+
block_chunk = cipher.decrypt(ciphertext[start : start + chunk_size])
|
|
245
|
+
if start + chunk_size >= len(ciphertext):
|
|
246
|
+
padding_length = block_chunk[-1]
|
|
247
|
+
block_chunk = block_chunk[:-padding_length]
|
|
248
|
+
output_data.extend(block_chunk)
|
|
249
|
+
|
|
250
|
+
return bytes(output_data)
|
|
251
|
+
|
|
120
252
|
|
|
121
253
|
|
|
122
254
|
def return_password_hash(password: str) -> str:
|
|
@@ -169,34 +301,11 @@ def deep_merge(dict1: Any, dict2: Any) -> Any:
|
|
|
169
301
|
return merged
|
|
170
302
|
|
|
171
303
|
|
|
172
|
-
def generate_unique_code() -> str:
|
|
173
|
-
"""Generate a deterministic, platform-agnostic unique code for the current host.
|
|
174
|
-
|
|
175
|
-
This function retrieves the host's MAC address using Python's standard
|
|
176
|
-
`uuid.getnode()` (works on Windows, Linux, macOS), converts it to a
|
|
177
|
-
canonical string representation, and then hashes it using MD5 to produce
|
|
178
|
-
a fixed-length hexadecimal string.
|
|
179
|
-
|
|
180
|
-
Returns:
|
|
181
|
-
str: A 32-character hexadecimal string uniquely representing
|
|
182
|
-
the host's MAC address. For example:
|
|
183
|
-
'a94e6756hghjgfghg49e0f310d9e44a'.
|
|
184
|
-
|
|
185
|
-
Notes:
|
|
186
|
-
- The output is deterministic: the same machine returns the same code.
|
|
187
|
-
- If the MAC address changes (e.g., different network adapter),
|
|
188
|
-
the output will change.
|
|
189
|
-
- MD5 is used here only for ID generation, not for security.
|
|
190
|
-
"""
|
|
191
|
-
mac_int = uuid.getnode()
|
|
192
|
-
mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
|
|
193
|
-
return md5(mac_str.encode("utf-8")).hexdigest()
|
|
194
|
-
|
|
195
|
-
|
|
196
304
|
# ---------------------------------------------------------------------------
|
|
197
305
|
# Time helpers for alarm/motion handling
|
|
198
306
|
# ---------------------------------------------------------------------------
|
|
199
307
|
|
|
308
|
+
|
|
200
309
|
def normalize_alarm_time(
|
|
201
310
|
last_alarm: dict[str, Any], tzinfo: datetime.tzinfo
|
|
202
311
|
) -> tuple[datetime.datetime | None, datetime.datetime | None, str | None]:
|
|
@@ -228,7 +337,7 @@ def normalize_alarm_time(
|
|
|
228
337
|
if epoch is not None:
|
|
229
338
|
try:
|
|
230
339
|
ts = float(epoch if not isinstance(epoch, str) else float(epoch))
|
|
231
|
-
if ts >
|
|
340
|
+
if ts > MILLISECONDS_THRESHOLD: # milliseconds
|
|
232
341
|
ts /= 1000.0
|
|
233
342
|
event_utc = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
|
|
234
343
|
alarm_dt_local = event_utc.astimezone(tzinfo)
|
|
@@ -241,14 +350,15 @@ def normalize_alarm_time(
|
|
|
241
350
|
raw_norm, "%Y-%m-%d %H:%M:%S"
|
|
242
351
|
).replace(tzinfo=tzinfo)
|
|
243
352
|
diff = abs(
|
|
244
|
-
(
|
|
353
|
+
(
|
|
354
|
+
event_utc - dt_str_local.astimezone(datetime.UTC)
|
|
355
|
+
).total_seconds()
|
|
245
356
|
)
|
|
246
357
|
if diff > 120:
|
|
247
358
|
# Reinterpret epoch as local clock time in camera tz
|
|
248
|
-
naive_utc = (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
359
|
+
naive_utc = datetime.datetime.fromtimestamp(
|
|
360
|
+
ts, tz=datetime.UTC
|
|
361
|
+
).replace(tzinfo=None)
|
|
252
362
|
event_local_reint = naive_utc.replace(tzinfo=tzinfo)
|
|
253
363
|
alarm_dt_local = event_local_reint
|
|
254
364
|
alarm_dt_utc = event_local_reint.astimezone(datetime.UTC)
|
|
@@ -266,9 +376,9 @@ def normalize_alarm_time(
|
|
|
266
376
|
if raw_time_str:
|
|
267
377
|
raw = raw_time_str.replace("Today", str(now_local.date()))
|
|
268
378
|
try:
|
|
269
|
-
alarm_dt_local = datetime.datetime.strptime(
|
|
270
|
-
|
|
271
|
-
)
|
|
379
|
+
alarm_dt_local = datetime.datetime.strptime(
|
|
380
|
+
raw, "%Y-%m-%d %H:%M:%S"
|
|
381
|
+
).replace(tzinfo=tzinfo)
|
|
272
382
|
alarm_dt_utc = alarm_dt_local.astimezone(datetime.UTC)
|
|
273
383
|
alarm_str = alarm_dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
|
274
384
|
except ValueError:
|
|
@@ -294,10 +404,11 @@ def compute_motion_from_alarm(
|
|
|
294
404
|
now_local = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
|
|
295
405
|
now_utc = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0)
|
|
296
406
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
407
|
+
delta = (
|
|
408
|
+
now_utc - alarm_dt_utc
|
|
409
|
+
if alarm_dt_utc is not None
|
|
410
|
+
else now_local - alarm_dt_local
|
|
411
|
+
)
|
|
301
412
|
|
|
302
413
|
seconds = float(delta.total_seconds())
|
|
303
414
|
if seconds < 0:
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyezvizapi
|
|
3
|
+
Version: 1.0.4.3
|
|
4
|
+
Summary: EZVIZ API client for Home Assistant and CLI
|
|
5
|
+
Home-page: https://github.com/RenierM26/pyEzvizApi/
|
|
6
|
+
Author: Renier Moorcroft
|
|
7
|
+
Author-email: RenierM26@users.github.com
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: requests
|
|
13
|
+
Requires-Dist: xmltodict
|
|
14
|
+
Requires-Dist: pycryptodome
|
|
15
|
+
Requires-Dist: paho-mqtt
|
|
16
|
+
Requires-Dist: pandas
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: ruff; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
22
|
+
Dynamic: author
|
|
23
|
+
Dynamic: author-email
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
|
|
28
|
+
# Ezviz PyPi
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Overview
|
|
33
|
+
|
|
34
|
+
Pilot your Ezviz cameras (and light bulbs) with this module. It is used by:
|
|
35
|
+
|
|
36
|
+
- The official Ezviz integration in Home Assistant
|
|
37
|
+
- The EZVIZ (Beta) custom integration for Home Assistant
|
|
38
|
+
|
|
39
|
+
You can also use it directly from the command line for quick checks and scripting.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Inspect device and connection status in table or JSON form
|
|
44
|
+
- Control cameras: PTZ, privacy/sleep/audio/IR/state LEDs, alarm settings
|
|
45
|
+
- Control light bulbs: toggle, status, brightness and color temperature
|
|
46
|
+
- Dump raw pagelist and device infos JSON for exploration/debugging
|
|
47
|
+
- Reuse a saved session token (no credentials needed after first login)
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
From PyPI:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install pyezvizapi
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
After installation, a `pyezvizapi` command is available on your PATH.
|
|
58
|
+
|
|
59
|
+
### Dependencies (development/local usage)
|
|
60
|
+
|
|
61
|
+
If you are running from a clone of this repository or using the helper scripts directly, ensure these packages are available:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install requests paho-mqtt pycryptodome pandas
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# See available commands and options
|
|
71
|
+
pyezvizapi --help
|
|
72
|
+
|
|
73
|
+
# First-time login and save token for reuse
|
|
74
|
+
pyezvizapi -u YOUR_EZVIZ_USERNAME -p YOUR_EZVIZ_PASSWORD --save-token devices status
|
|
75
|
+
|
|
76
|
+
# Subsequent runs can reuse the saved token (no credentials needed)
|
|
77
|
+
pyezvizapi devices status --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## CLI Authentication
|
|
81
|
+
|
|
82
|
+
- Username/password: `-u/--username` and `-p/--password`
|
|
83
|
+
- Token file: `--token-file` (defaults to `ezviz_token.json` in the current directory)
|
|
84
|
+
- Save token: `--save-token` writes the current token after login
|
|
85
|
+
- MFA: The CLI prompts for a code if required by your account
|
|
86
|
+
- Region: `-r/--region` overrides the default region (`apiieu.ezvizlife.com`)
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# First-time login and save token locally
|
|
92
|
+
pyezvizapi -u YOUR_EZVIZ_USERNAME -p YOUR_EZVIZ_PASSWORD --save-token devices status
|
|
93
|
+
|
|
94
|
+
# Reuse saved token (no credentials)
|
|
95
|
+
pyezvizapi devices status --json
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Output Modes
|
|
99
|
+
|
|
100
|
+
- Default: human-readable tables (for list/status views)
|
|
101
|
+
- JSON: add `--json` for easy parsing and editor-friendly exploration
|
|
102
|
+
|
|
103
|
+
## CLI Commands
|
|
104
|
+
|
|
105
|
+
All commands are subcommands of the module runner:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pyezvizapi <command> [options]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### devices
|
|
112
|
+
|
|
113
|
+
- Actions: `device`, `status`, `switch`, `connection`
|
|
114
|
+
- Examples:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Table view
|
|
118
|
+
pyezvizapi devices status
|
|
119
|
+
|
|
120
|
+
# JSON view
|
|
121
|
+
pyezvizapi devices status --json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Sample table columns include:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
name | status | device_category | device_sub_category | sleep | privacy | audio | ir_led | state_led | local_ip | local_rtsp_port | battery_level | alarm_schedules_enabled | alarm_notify | Motion_Trigger
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The CLI also computes a `switch_flags` map for each device (all switch states by name, e.g. `privacy`, `sleep`, `sound`, `infrared_light`, `light`, etc.).
|
|
131
|
+
|
|
132
|
+
### camera
|
|
133
|
+
|
|
134
|
+
Requires `--serial`.
|
|
135
|
+
|
|
136
|
+
- Actions: `status`, `move`, `move_coords`, `unlock-door`, `unlock-gate`, `switch`, `alarm`, `select`
|
|
137
|
+
- Examples:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Camera status
|
|
141
|
+
pyezvizapi camera --serial ABC123 status
|
|
142
|
+
|
|
143
|
+
# PTZ move
|
|
144
|
+
pyezvizapi camera --serial ABC123 move --direction up --speed 5
|
|
145
|
+
|
|
146
|
+
# Move by coordinates
|
|
147
|
+
pyezvizapi camera --serial ABC123 move_coords --x 0.4 --y 0.6
|
|
148
|
+
|
|
149
|
+
# Switch setters
|
|
150
|
+
pyezvizapi camera --serial ABC123 switch --switch privacy --enable 1
|
|
151
|
+
|
|
152
|
+
# Alarm settings (push notify, sound level, do-not-disturb)
|
|
153
|
+
pyezvizapi camera --serial ABC123 alarm --notify 1 --sound 2 --do_not_disturb 0
|
|
154
|
+
|
|
155
|
+
# Battery camera work mode
|
|
156
|
+
pyezvizapi camera --serial ABC123 select --battery_work_mode POWER_SAVE
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### devices_light
|
|
160
|
+
|
|
161
|
+
- Actions: `status`
|
|
162
|
+
- Example:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pyezvizapi devices_light status
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### home_defence_mode
|
|
169
|
+
|
|
170
|
+
Set global defence mode for the account/home.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pyezvizapi home_defence_mode --mode HOME_MODE
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### mqtt
|
|
177
|
+
|
|
178
|
+
Connect to Ezviz MQTT push notifications using the current session token. Use `--debug` to see connection details.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pyezvizapi mqtt
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### MQTT push test script (standalone)
|
|
185
|
+
|
|
186
|
+
For quick experimentation, a small helper script is included which can use a saved token file or prompt for credentials with MFA and save the session token:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# With a previously saved token file
|
|
190
|
+
python config/custom_components/ezviz_cloud/pyezvizapi/test_mqtt.py --token-file ezviz_token.json
|
|
191
|
+
|
|
192
|
+
# Interactive login, then save token for next time
|
|
193
|
+
python config/custom_components/ezviz_cloud/pyezvizapi/test_mqtt.py --save-token
|
|
194
|
+
|
|
195
|
+
# Explicit credentials (not recommended for shared terminals)
|
|
196
|
+
python config/custom_components/ezviz_cloud/pyezvizapi/test_mqtt.py -u USER -p PASS --save-token
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### pagelist
|
|
200
|
+
|
|
201
|
+
Dump the complete raw pagelist JSON. Great for exploring unknown fields in an editor (e.g. Notepad++).
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
pyezvizapi pagelist > pagelist.json
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### device_infos
|
|
208
|
+
|
|
209
|
+
Dump the processed device infos mapping (what the integration consumes). Optionally filter to one serial:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# All devices
|
|
213
|
+
pyezvizapi device_infos > device_infos.json
|
|
214
|
+
|
|
215
|
+
# Single device
|
|
216
|
+
pyezvizapi device_infos --serial ABC123 > ABC123.json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Remote door and gate unlock (CS-HPD7)
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pyezvizapi camera --serial BAXXXXXXX-BAYYYYYYY unlock-door
|
|
223
|
+
pyezvizapi camera --serial BAXXXXXXX-BAYYYYYYY unlock-gate
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## RTSP authentication test (Basic → Digest)
|
|
227
|
+
|
|
228
|
+
Validate RTSP credentials by issuing a DESCRIBE request. Falls back from Basic to Digest auth automatically.
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
python -c "from config.custom_components.ezviz_cloud.pyezvizapi.test_cam_rtsp import TestRTSPAuth as T; T('<IP>', '<USER>', '<PASS>', '/Streaming/Channels/101').main()"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
On success, the script prints a confirmation. On failure it raises one of:
|
|
235
|
+
|
|
236
|
+
- `InvalidHost`: Hostname/IP or port issue
|
|
237
|
+
- `AuthTestResultFailed`: Invalid credentials
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
Please format with Ruff and check typing with mypy.
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
ruff check .
|
|
245
|
+
mypy config/custom_components/ezviz_cloud/pyezvizapi
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Run style fixes where possible:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
ruff check --fix config/custom_components/ezviz_cloud/pyezvizapi
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Run tests with tox:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
tox
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Side Notes
|
|
261
|
+
|
|
262
|
+
There is no official API documentation. Much of this is based on reverse-engineering the Ezviz mobile app (Android/iOS). Some regions operate on separate endpoints; US example: `apiius.ezvizlife.com`.
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pyezvizapi -u username@domain.com -p PASS@123 -r apius.ezvizlife.com devices status
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
For advanced troubleshooting or new feature research, MITM proxy tools like mitmproxy/Charles/Fiddler can be used to inspect traffic from the app (see community guides for SSL unpinning and WSA usage).
|
|
271
|
+
|
|
272
|
+
## Contributing
|
|
273
|
+
|
|
274
|
+
Contributions are welcome — the API surface is large and there are many improvements possible.
|
|
275
|
+
|
|
276
|
+
## Versioning
|
|
277
|
+
|
|
278
|
+
We follow SemVer when publishing the library. See repository tags for released versions.
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
Apache 2.0 — see `LICENSE.md`.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
Draft versions: 0.0.x
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
pyezvizapi/__init__.py,sha256=-OxHqxA9h0JZQW__GoZd1aByGquIHawCnNdeNlIg2Qo,3382
|
|
2
|
+
pyezvizapi/__main__.py,sha256=GmV-9mfXhbzZq3g--cpM6Tex8EX4DBHXE1jeDhD8UXs,24112
|
|
3
|
+
pyezvizapi/api_endpoints.py,sha256=NSUKafpmd_DaNtmlOsRplXeQB1PYjZ4roClt_BNnNyE,5802
|
|
4
|
+
pyezvizapi/camera.py,sha256=yHP4ENva6ep2BY85VvlyHvimCOCrHCwOCnCfpAC2mNY,28517
|
|
5
|
+
pyezvizapi/cas.py,sha256=3zHe-_a0KchCmGeAj1of-pV6oMPRUmSCIiDqBFsTK8A,6025
|
|
6
|
+
pyezvizapi/client.py,sha256=4c93Eg-0AQvxjxgBMX4dhi3ywOftxocr-4iIoM_HxRM,153968
|
|
7
|
+
pyezvizapi/constants.py,sha256=Ae838wTtHpOyNBnLQf56CFGHXpG9kPmgotjP9--J5V4,13570
|
|
8
|
+
pyezvizapi/exceptions.py,sha256=8rmxEUQdrziqMe-M1SeeRd0HtP2IDQ2xpJVj7wvOQyo,976
|
|
9
|
+
pyezvizapi/feature.py,sha256=8QeAo6vnIev_ovlDnVvnRbC39Xe6piLgBlFEuFE7qIA,16789
|
|
10
|
+
pyezvizapi/light_bulb.py,sha256=7kuOJmKsmAmE6KGJaUScjrRSTic8IhuToYrMRM-Y76s,7795
|
|
11
|
+
pyezvizapi/models.py,sha256=NQzwTP0yEe2IWU-Vc6nAn87xulpTuo0MX2Rcf0WxifA,4176
|
|
12
|
+
pyezvizapi/mqtt.py,sha256=uzEFT69LLCtP4paTSdCgMBkGAkuqRD02scfgqtK1bxU,22656
|
|
13
|
+
pyezvizapi/test_cam_rtsp.py,sha256=pbuanoKs_Pryt2f5QctHIngzJG1nD6kv8nulQYh2yPc,5162
|
|
14
|
+
pyezvizapi/test_mqtt.py,sha256=xUtyw5I3KBkMVGe2xhok7SWXFyZZMXrlMADoPo_5l_k,5744
|
|
15
|
+
pyezvizapi/utils.py,sha256=B-Lt4p14TTcJyrZvJ699GiUGj6qcd4vGmkKUf13bf_I,15455
|
|
16
|
+
pyezvizapi-1.0.4.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
17
|
+
pyezvizapi-1.0.4.3.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
18
|
+
pyezvizapi-1.0.4.3.dist-info/METADATA,sha256=WWMQuriHh98Vj2f3uTsQhFdlC0PdHrp8l_zmP7eqcaQ,7586
|
|
19
|
+
pyezvizapi-1.0.4.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
+
pyezvizapi-1.0.4.3.dist-info/top_level.txt,sha256=gMZTelIi8z7pXyTCQLLaIkxVRrDQ_lS2NEv0WgfHrHs,11
|
|
21
|
+
pyezvizapi-1.0.4.3.dist-info/RECORD,,
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pyezvizapi
|
|
3
|
-
Version: 1.0.2.3
|
|
4
|
-
Summary: Pilot your Ezviz cameras
|
|
5
|
-
Home-page: https://github.com/RenierM26/pyEzvizApi/
|
|
6
|
-
Author: Renier Moorcroft
|
|
7
|
-
Author-email: RenierM26@users.github.com
|
|
8
|
-
License: Apache Software License 2.0
|
|
9
|
-
Requires-Python: >=3.11
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
License-File: LICENSE.md
|
|
12
|
-
Requires-Dist: requests
|
|
13
|
-
Requires-Dist: pandas
|
|
14
|
-
Requires-Dist: paho-mqtt
|
|
15
|
-
Requires-Dist: xmltodict
|
|
16
|
-
Requires-Dist: pycryptodome
|
|
17
|
-
Dynamic: author
|
|
18
|
-
Dynamic: author-email
|
|
19
|
-
Dynamic: description
|
|
20
|
-
Dynamic: home-page
|
|
21
|
-
Dynamic: license
|
|
22
|
-
Dynamic: license-file
|
|
23
|
-
Dynamic: requires-dist
|
|
24
|
-
Dynamic: requires-python
|
|
25
|
-
Dynamic: summary
|
|
26
|
-
|
|
27
|
-
Pilot your Ezviz cameras with this module. Please view readme on github
|