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/mqtt.py
CHANGED
|
@@ -83,27 +83,27 @@ class MqttData(TypedDict):
|
|
|
83
83
|
# Payload decoding helpers
|
|
84
84
|
# ---------------------------------------------------------------------------
|
|
85
85
|
|
|
86
|
-
# Field names in the comma
|
|
86
|
+
# Field names in the comma-separated ``ext`` payload from EZVIZ.
|
|
87
87
|
EXT_FIELD_NAMES: Final[tuple[str, ...]] = (
|
|
88
88
|
"channel_type",
|
|
89
89
|
"time",
|
|
90
90
|
"device_serial",
|
|
91
91
|
"channel_no",
|
|
92
92
|
"alert_type_code",
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
93
|
+
"default_pic_url",
|
|
94
|
+
"media_url_alt1",
|
|
95
|
+
"media_url_alt2",
|
|
96
|
+
"resource_type",
|
|
97
97
|
"status_flag",
|
|
98
98
|
"file_id",
|
|
99
99
|
"is_encrypted",
|
|
100
100
|
"picChecksum",
|
|
101
|
-
"
|
|
102
|
-
"
|
|
101
|
+
"is_dev_video",
|
|
102
|
+
"metadata",
|
|
103
103
|
"msgId",
|
|
104
104
|
"image",
|
|
105
105
|
"device_name",
|
|
106
|
-
"
|
|
106
|
+
"reserved",
|
|
107
107
|
"sequence_number",
|
|
108
108
|
)
|
|
109
109
|
|
|
@@ -113,8 +113,10 @@ EXT_INT_FIELDS: Final[frozenset[str]] = frozenset(
|
|
|
113
113
|
"channel_type",
|
|
114
114
|
"channel_no",
|
|
115
115
|
"alert_type_code",
|
|
116
|
+
"resource_type",
|
|
116
117
|
"status_flag",
|
|
117
118
|
"is_encrypted",
|
|
119
|
+
"is_dev_video",
|
|
118
120
|
"sequence_number",
|
|
119
121
|
}
|
|
120
122
|
)
|
|
@@ -247,7 +249,7 @@ class MQTTClient:
|
|
|
247
249
|
# Stop background thread and disconnect
|
|
248
250
|
self.mqtt_client.loop_stop()
|
|
249
251
|
self.mqtt_client.disconnect()
|
|
250
|
-
except
|
|
252
|
+
except (OSError, ValueError, RuntimeError) as err:
|
|
251
253
|
_LOGGER.debug("MQTT disconnect failed: %s", err)
|
|
252
254
|
# Always attempt to stop push on server side
|
|
253
255
|
self._stop_ezviz_push()
|
|
@@ -535,13 +537,17 @@ class MQTTClient:
|
|
|
535
537
|
"""
|
|
536
538
|
broker = self._mqtt_data["push_url"]
|
|
537
539
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
540
|
+
client_kwargs: dict[str, Any] = {
|
|
541
|
+
"client_id": self._mqtt_data["mqtt_clientid"],
|
|
542
|
+
"clean_session": clean_session,
|
|
543
|
+
"protocol": mqtt.MQTTv311,
|
|
544
|
+
"transport": "tcp",
|
|
545
|
+
}
|
|
546
|
+
callback_api_version = getattr(mqtt, "CallbackAPIVersion", None)
|
|
547
|
+
if callback_api_version is not None:
|
|
548
|
+
client_kwargs["callback_api_version"] = callback_api_version.VERSION1
|
|
549
|
+
|
|
550
|
+
self.mqtt_client = mqtt.Client(**client_kwargs)
|
|
545
551
|
|
|
546
552
|
# Bind callbacks
|
|
547
553
|
self.mqtt_client.on_connect = self._on_connect
|
pyezvizapi/test_cam_rtsp.py
CHANGED
|
@@ -4,11 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import hashlib
|
|
7
|
+
import logging
|
|
7
8
|
import socket
|
|
8
9
|
from typing import TypedDict
|
|
9
10
|
|
|
10
11
|
from .exceptions import AuthTestResultFailed, InvalidHost
|
|
11
12
|
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
def genmsg_describe(url: str, seq: int, user_agent: str, auth_seq: str) -> str:
|
|
14
17
|
"""Generate RTSP DESCRIBE request message."""
|
|
@@ -22,6 +25,7 @@ def genmsg_describe(url: str, seq: int, user_agent: str, auth_seq: str) -> str:
|
|
|
22
25
|
|
|
23
26
|
class RTSPDetails(TypedDict):
|
|
24
27
|
"""Typed structure for RTSP test parameters."""
|
|
28
|
+
|
|
25
29
|
bufLen: int
|
|
26
30
|
defaultServerIp: str
|
|
27
31
|
defaultServerPort: int
|
|
@@ -37,7 +41,11 @@ class TestRTSPAuth:
|
|
|
37
41
|
_rtsp_details: RTSPDetails
|
|
38
42
|
|
|
39
43
|
def __init__(
|
|
40
|
-
self,
|
|
44
|
+
self,
|
|
45
|
+
ip_addr: str,
|
|
46
|
+
username: str | None = None,
|
|
47
|
+
password: str | None = None,
|
|
48
|
+
test_uri: str = "",
|
|
41
49
|
) -> None:
|
|
42
50
|
"""Initialize RTSP credential test."""
|
|
43
51
|
self._rtsp_details = RTSPDetails(
|
|
@@ -50,7 +58,9 @@ class TestRTSPAuth:
|
|
|
50
58
|
defaultPassword=password,
|
|
51
59
|
)
|
|
52
60
|
|
|
53
|
-
def generate_auth_string(
|
|
61
|
+
def generate_auth_string(
|
|
62
|
+
self, realm: bytes, method: str, uri: str, nonce: bytes
|
|
63
|
+
) -> str:
|
|
54
64
|
"""Generate the HTTP Digest Authorization header value."""
|
|
55
65
|
m_1 = hashlib.md5(
|
|
56
66
|
f"{self._rtsp_details['defaultUsername']}:{realm.decode()}:{self._rtsp_details['defaultPassword']}".encode()
|
|
@@ -60,12 +70,12 @@ class TestRTSPAuth:
|
|
|
60
70
|
|
|
61
71
|
return (
|
|
62
72
|
"Digest "
|
|
63
|
-
f
|
|
64
|
-
f
|
|
73
|
+
f'username="{self._rtsp_details["defaultUsername"]}", '
|
|
74
|
+
f'realm="{realm.decode()}", '
|
|
65
75
|
'algorithm="MD5", '
|
|
66
|
-
f
|
|
67
|
-
f
|
|
68
|
-
f
|
|
76
|
+
f'nonce="{nonce.decode()}", '
|
|
77
|
+
f'uri="{uri}", '
|
|
78
|
+
f'response="{response}"'
|
|
69
79
|
)
|
|
70
80
|
|
|
71
81
|
def main(self) -> None:
|
|
@@ -74,7 +84,10 @@ class TestRTSPAuth:
|
|
|
74
84
|
|
|
75
85
|
try:
|
|
76
86
|
session.connect(
|
|
77
|
-
(
|
|
87
|
+
(
|
|
88
|
+
self._rtsp_details["defaultServerIp"],
|
|
89
|
+
self._rtsp_details["defaultServerPort"],
|
|
90
|
+
)
|
|
78
91
|
)
|
|
79
92
|
except TimeoutError as err:
|
|
80
93
|
raise AuthTestResultFailed("Invalid ip or camera hibernating") from err
|
|
@@ -83,24 +96,31 @@ class TestRTSPAuth:
|
|
|
83
96
|
|
|
84
97
|
seq: int = 1
|
|
85
98
|
|
|
86
|
-
url: str =
|
|
99
|
+
url: str = (
|
|
100
|
+
"rtsp://"
|
|
101
|
+
+ self._rtsp_details["defaultServerIp"]
|
|
102
|
+
+ self._rtsp_details["defaultTestUri"]
|
|
103
|
+
)
|
|
87
104
|
|
|
88
105
|
# Basic Authorization header
|
|
89
106
|
auth_b64: bytes = base64.b64encode(
|
|
90
|
-
f"{self._rtsp_details['defaultUsername']}:{self._rtsp_details['defaultPassword']}".encode(
|
|
107
|
+
f"{self._rtsp_details['defaultUsername']}:{self._rtsp_details['defaultPassword']}".encode(
|
|
108
|
+
"ascii"
|
|
109
|
+
)
|
|
91
110
|
)
|
|
92
111
|
auth_seq: str = "Basic " + auth_b64.decode()
|
|
93
112
|
|
|
94
|
-
describe = genmsg_describe(
|
|
95
|
-
|
|
113
|
+
describe = genmsg_describe(
|
|
114
|
+
url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
|
|
115
|
+
)
|
|
116
|
+
_LOGGER.debug("RTSP DESCRIBE (basic):\n%s", describe)
|
|
96
117
|
session.send(describe.encode())
|
|
97
118
|
msg1: bytes = session.recv(self._rtsp_details["bufLen"])
|
|
98
119
|
seq += 1
|
|
99
120
|
|
|
100
121
|
decoded = msg1.decode()
|
|
101
122
|
if "200 OK" in decoded:
|
|
102
|
-
|
|
103
|
-
print("Basic Auth test passed. Credentials Valid!")
|
|
123
|
+
_LOGGER.info("Basic auth result: %s", decoded)
|
|
104
124
|
return
|
|
105
125
|
|
|
106
126
|
if "Unauthorized" in decoded:
|
|
@@ -115,21 +135,23 @@ class TestRTSPAuth:
|
|
|
115
135
|
end = decoded.find('"', begin + 1)
|
|
116
136
|
nonce: bytes = msg1[begin + 1 : end]
|
|
117
137
|
|
|
118
|
-
auth_seq = self.generate_auth_string(
|
|
138
|
+
auth_seq = self.generate_auth_string(
|
|
139
|
+
realm, "DESCRIBE", self._rtsp_details["defaultTestUri"], nonce
|
|
140
|
+
)
|
|
119
141
|
|
|
120
|
-
describe = genmsg_describe(
|
|
121
|
-
|
|
142
|
+
describe = genmsg_describe(
|
|
143
|
+
url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
|
|
144
|
+
)
|
|
145
|
+
_LOGGER.debug("RTSP DESCRIBE (digest):\n%s", describe)
|
|
122
146
|
session.send(describe.encode())
|
|
123
147
|
msg1 = session.recv(self._rtsp_details["bufLen"])
|
|
124
148
|
decoded = msg1.decode()
|
|
125
|
-
|
|
149
|
+
_LOGGER.info("Digest auth result: %s", decoded)
|
|
126
150
|
|
|
127
151
|
if "200 OK" in decoded:
|
|
128
|
-
print("Digest Auth test Passed. Credentials Valid!")
|
|
129
152
|
return
|
|
130
153
|
|
|
131
154
|
if "401 Unauthorized" in decoded:
|
|
132
155
|
raise AuthTestResultFailed("Credentials not valid!!")
|
|
133
156
|
|
|
134
|
-
|
|
135
|
-
# ruff: noqa: T201
|
|
157
|
+
_LOGGER.info("Basic Auth test passed. Credentials Valid!")
|
pyezvizapi/test_mqtt.py
CHANGED
|
@@ -8,6 +8,7 @@ with MFA similar to the main CLI.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
|
+
import base64
|
|
11
12
|
from getpass import getpass
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
@@ -18,19 +19,60 @@ from typing import Any, cast
|
|
|
18
19
|
|
|
19
20
|
from .client import EzvizClient
|
|
20
21
|
from .exceptions import EzvizAuthVerificationCode, PyEzvizError
|
|
22
|
+
from .mqtt import MQTTClient
|
|
21
23
|
|
|
22
24
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
25
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
26
|
|
|
24
27
|
LOG_FILE = Path("mqtt_messages.jsonl") # JSON Lines format
|
|
28
|
+
RAW_LOG_FILE = Path("mqtt_raw_messages.jsonl")
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
def message_handler(msg: dict[str, Any]) -> None:
|
|
28
32
|
"""Handle new MQTT messages by printing and saving them to a file."""
|
|
29
|
-
|
|
33
|
+
_LOGGER.info("📩 New MQTT message: %s", msg)
|
|
30
34
|
with LOG_FILE.open("a", encoding="utf-8") as f:
|
|
31
35
|
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
|
32
36
|
|
|
33
37
|
|
|
38
|
+
def _log_raw_payload(payload: bytes) -> None:
|
|
39
|
+
"""Persist the raw MQTT payload to a log file for debugging."""
|
|
40
|
+
entry: dict[str, Any]
|
|
41
|
+
try:
|
|
42
|
+
decoded = payload.decode("utf-8")
|
|
43
|
+
entry = {"encoding": "utf-8", "payload": decoded}
|
|
44
|
+
except UnicodeDecodeError:
|
|
45
|
+
entry = {
|
|
46
|
+
"encoding": "base64",
|
|
47
|
+
"payload": base64.b64encode(payload).decode("ascii"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
entry["timestamp"] = time.time()
|
|
51
|
+
_LOGGER.info("🧾 Raw MQTT payload (%s): %s", entry["encoding"], entry["payload"])
|
|
52
|
+
with RAW_LOG_FILE.open("a", encoding="utf-8") as f:
|
|
53
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _enable_raw_logging(mqtt_client: MQTTClient) -> None:
|
|
57
|
+
"""Wrap the internal paho-mqtt callback to capture raw payloads."""
|
|
58
|
+
if getattr(mqtt_client, "_raw_logging_enabled", False):
|
|
59
|
+
return
|
|
60
|
+
paho_client = getattr(mqtt_client, "mqtt_client", None)
|
|
61
|
+
if paho_client is None:
|
|
62
|
+
_LOGGER.warning("Unable to enable raw logging: MQTT client not configured yet")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
original_on_message = paho_client.on_message
|
|
66
|
+
|
|
67
|
+
def _raw_logging_wrapper(client: Any, userdata: Any, msg: Any) -> None:
|
|
68
|
+
_log_raw_payload(getattr(msg, "payload", b""))
|
|
69
|
+
if original_on_message:
|
|
70
|
+
original_on_message(client, userdata, msg)
|
|
71
|
+
|
|
72
|
+
paho_client.on_message = _raw_logging_wrapper
|
|
73
|
+
mqtt_client._raw_logging_enabled = True # type: ignore[attr-defined] # noqa: SLF001
|
|
74
|
+
|
|
75
|
+
|
|
34
76
|
def _load_token_file(path: str | None) -> dict[str, Any] | None:
|
|
35
77
|
if not path:
|
|
36
78
|
return None
|
|
@@ -40,7 +82,7 @@ def _load_token_file(path: str | None) -> dict[str, Any] | None:
|
|
|
40
82
|
try:
|
|
41
83
|
return cast(dict[str, Any], json.loads(p.read_text(encoding="utf-8")))
|
|
42
84
|
except (OSError, json.JSONDecodeError):
|
|
43
|
-
|
|
85
|
+
_LOGGER.warning("Failed to read token file: %s", p)
|
|
44
86
|
return None
|
|
45
87
|
|
|
46
88
|
|
|
@@ -50,9 +92,9 @@ def _save_token_file(path: str | None, token: dict[str, Any]) -> None:
|
|
|
50
92
|
p = Path(path)
|
|
51
93
|
try:
|
|
52
94
|
p.write_text(json.dumps(token, indent=2), encoding="utf-8")
|
|
53
|
-
|
|
95
|
+
_LOGGER.info("Saved token to %s", p)
|
|
54
96
|
except OSError:
|
|
55
|
-
|
|
97
|
+
_LOGGER.warning("Failed to save token file: %s", p)
|
|
56
98
|
|
|
57
99
|
|
|
58
100
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -87,7 +129,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
87
129
|
|
|
88
130
|
# If no token and missing username/password, prompt interactively
|
|
89
131
|
if not token and (not username or not password):
|
|
90
|
-
|
|
132
|
+
_LOGGER.info("No token found. Please enter Ezviz credentials")
|
|
91
133
|
if not username:
|
|
92
134
|
username = input("Username: ")
|
|
93
135
|
if not password:
|
|
@@ -107,29 +149,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
107
149
|
code_int = None
|
|
108
150
|
client.login(sms_code=code_int)
|
|
109
151
|
except PyEzvizError as exp:
|
|
110
|
-
|
|
152
|
+
_LOGGER.error("Login failed: %s", exp)
|
|
111
153
|
return 1
|
|
112
154
|
|
|
113
155
|
# Start MQTT client
|
|
114
156
|
mqtt_client = client.get_mqtt_client(on_message_callback=message_handler)
|
|
115
157
|
mqtt_client.connect()
|
|
158
|
+
_enable_raw_logging(mqtt_client)
|
|
116
159
|
|
|
117
160
|
try:
|
|
118
|
-
|
|
161
|
+
_LOGGER.info("Listening for MQTT messages... (Ctrl+C to quit)")
|
|
119
162
|
while True:
|
|
120
163
|
time.sleep(1)
|
|
121
164
|
except KeyboardInterrupt:
|
|
122
|
-
|
|
165
|
+
_LOGGER.info("Stopping listener (keyboard interrupt)")
|
|
123
166
|
finally:
|
|
124
167
|
mqtt_client.stop()
|
|
125
|
-
|
|
168
|
+
_LOGGER.info("Listener stopped")
|
|
126
169
|
|
|
127
170
|
if args.save_token and args.token_file:
|
|
128
|
-
_save_token_file(args.token_file,
|
|
171
|
+
_save_token_file(args.token_file, client.export_token())
|
|
129
172
|
|
|
130
173
|
return 0
|
|
131
174
|
|
|
132
175
|
|
|
133
176
|
if __name__ == "__main__":
|
|
134
177
|
sys.exit(main())
|
|
135
|
-
# ruff: noqa: T201
|