pyezvizapi 1.0.3.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 +24 -0
- pyezvizapi/__main__.py +99 -2
- pyezvizapi/api_endpoints.py +2 -1
- pyezvizapi/camera.py +196 -15
- pyezvizapi/client.py +369 -35
- pyezvizapi/constants.py +25 -3
- pyezvizapi/feature.py +299 -14
- pyezvizapi/light_bulb.py +1 -1
- pyezvizapi/mqtt.py +22 -16
- pyezvizapi/test_cam_rtsp.py +8 -10
- pyezvizapi/test_mqtt.py +53 -11
- pyezvizapi/utils.py +146 -63
- pyezvizapi-1.0.4.3.dist-info/METADATA +286 -0
- pyezvizapi-1.0.4.3.dist-info/RECORD +21 -0
- pyezvizapi-1.0.3.3.dist-info/METADATA +0 -27
- pyezvizapi-1.0.3.3.dist-info/RECORD +0 -22
- pyezvizapi-1.0.3.3.dist-info/entry_points.txt +0 -2
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/WHEEL +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/licenses/LICENSE.md +0 -0
- {pyezvizapi-1.0.3.3.dist-info → pyezvizapi-1.0.4.3.dist-info}/top_level.txt +0 -0
pyezvizapi/feature.py
CHANGED
|
@@ -2,26 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Mapping, MutableMapping
|
|
5
|
+
from collections.abc import Iterable, Iterator, Mapping, MutableMapping
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
|
|
8
|
-
from .utils import coerce_int, decode_json
|
|
8
|
+
from .utils import WILDCARD_STEP, coerce_int, decode_json, first_nested
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _feature_video_section(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
12
12
|
"""Return the nested Video feature section from feature info payload."""
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
video = first_nested(
|
|
15
|
+
camera_data,
|
|
16
|
+
("FEATURE_INFO", WILDCARD_STEP, "Video"),
|
|
17
|
+
)
|
|
18
|
+
if isinstance(video, MutableMapping):
|
|
19
|
+
return cast(dict[str, Any], video)
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def supplement_light_params(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
24
|
+
"""Return SupplementLightMgr parameters if present."""
|
|
25
|
+
|
|
26
|
+
video = _feature_video_section(camera_data)
|
|
27
|
+
if not video:
|
|
16
28
|
return {}
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return cast(dict[str, Any], video)
|
|
30
|
+
manager: Any = video.get("SupplementLightMgr")
|
|
31
|
+
manager = decode_json(manager)
|
|
32
|
+
if not isinstance(manager, Mapping):
|
|
33
|
+
return {}
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
params: Any = manager.get("ImageSupplementLightModeSwitchParams")
|
|
36
|
+
params = decode_json(params)
|
|
37
|
+
return dict(params) if isinstance(params, Mapping) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def supplement_light_enabled(camera_data: Mapping[str, Any]) -> bool:
|
|
41
|
+
"""Return True when intelligent fill light is enabled."""
|
|
42
|
+
|
|
43
|
+
params = supplement_light_params(camera_data)
|
|
44
|
+
if not params:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
enabled = params.get("enabled")
|
|
48
|
+
if isinstance(enabled, bool):
|
|
49
|
+
return enabled
|
|
50
|
+
if isinstance(enabled, str):
|
|
51
|
+
lowered = enabled.strip().lower()
|
|
52
|
+
if lowered in {"true", "1", "yes", "on"}:
|
|
53
|
+
return True
|
|
54
|
+
if lowered in {"false", "0", "no", "off"}:
|
|
55
|
+
return False
|
|
56
|
+
return bool(enabled)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def supplement_light_available(camera_data: Mapping[str, Any]) -> bool:
|
|
60
|
+
"""Return True when intelligent fill light parameters are present."""
|
|
61
|
+
|
|
62
|
+
return bool(supplement_light_params(camera_data))
|
|
25
63
|
|
|
26
64
|
|
|
27
65
|
def lens_defog_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -78,6 +116,227 @@ def optionals_mapping(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
78
116
|
return dict(optionals) if isinstance(optionals, Mapping) else {}
|
|
79
117
|
|
|
80
118
|
|
|
119
|
+
def optionals_dict(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
120
|
+
"""Return convenience wrapper for optionals mapping."""
|
|
121
|
+
|
|
122
|
+
return optionals_mapping(camera_data)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def custom_voice_volume_config(camera_data: Mapping[str, Any]) -> dict[str, int] | None:
|
|
126
|
+
"""Return current CustomVoice volume configuration."""
|
|
127
|
+
|
|
128
|
+
optionals = optionals_mapping(camera_data)
|
|
129
|
+
config = optionals.get("CustomVoice_Volume")
|
|
130
|
+
config = decode_json(config)
|
|
131
|
+
if not isinstance(config, Mapping):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
volume = coerce_int(config.get("volume"))
|
|
135
|
+
mic = coerce_int(config.get("microphone_volume"))
|
|
136
|
+
result: dict[str, int] = {}
|
|
137
|
+
if isinstance(volume, int):
|
|
138
|
+
result["volume"] = volume
|
|
139
|
+
if isinstance(mic, int):
|
|
140
|
+
result["microphone_volume"] = mic
|
|
141
|
+
return result or None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def iter_algorithm_entries(camera_data: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
|
|
145
|
+
"""Yield entries from the AlgorithmInfo optionals list."""
|
|
146
|
+
|
|
147
|
+
entries = optionals_dict(camera_data).get("AlgorithmInfo")
|
|
148
|
+
if not isinstance(entries, Iterable):
|
|
149
|
+
return
|
|
150
|
+
for entry in entries:
|
|
151
|
+
if isinstance(entry, Mapping):
|
|
152
|
+
yield dict(entry)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def iter_channel_algorithm_entries(
|
|
156
|
+
camera_data: Mapping[str, Any], channel: int
|
|
157
|
+
) -> Iterator[dict[str, Any]]:
|
|
158
|
+
"""Yield AlgorithmInfo entries filtered by channel."""
|
|
159
|
+
|
|
160
|
+
for entry in iter_algorithm_entries(camera_data):
|
|
161
|
+
entry_channel = coerce_int(entry.get("channel")) or 1
|
|
162
|
+
if entry_channel == channel:
|
|
163
|
+
yield entry
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_algorithm_value(
|
|
167
|
+
camera_data: Mapping[str, Any], subtype: str, channel: int
|
|
168
|
+
) -> int | None:
|
|
169
|
+
"""Return AlgorithmInfo value for provided subtype/channel."""
|
|
170
|
+
|
|
171
|
+
for entry in iter_channel_algorithm_entries(camera_data, channel):
|
|
172
|
+
if entry.get("SubType") != subtype:
|
|
173
|
+
continue
|
|
174
|
+
return coerce_int(entry.get("Value"))
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def has_algorithm_subtype(
|
|
179
|
+
camera_data: Mapping[str, Any], subtype: str, channel: int = 1
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Return True when AlgorithmInfo contains subtype for channel."""
|
|
182
|
+
|
|
183
|
+
return get_algorithm_value(camera_data, subtype, channel) is not None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def support_ext_value(camera_data: Mapping[str, Any], ext_key: str) -> str | None:
|
|
187
|
+
"""Fetch a supportExt entry as a string when present."""
|
|
188
|
+
|
|
189
|
+
raw = camera_data.get("supportExt")
|
|
190
|
+
if not isinstance(raw, Mapping):
|
|
191
|
+
device_infos = camera_data.get("deviceInfos")
|
|
192
|
+
if isinstance(device_infos, Mapping):
|
|
193
|
+
raw = device_infos.get("supportExt")
|
|
194
|
+
|
|
195
|
+
if not isinstance(raw, Mapping):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
value = raw.get(ext_key)
|
|
199
|
+
return str(value) if value is not None else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalize_port_list(value: Any) -> list[dict[str, Any]] | None:
|
|
203
|
+
"""Decode a list of port-security entries."""
|
|
204
|
+
|
|
205
|
+
value = decode_json(value)
|
|
206
|
+
if not isinstance(value, Iterable):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
normalized: list[dict[str, Any]] = []
|
|
210
|
+
for raw_entry in value:
|
|
211
|
+
entry = decode_json(raw_entry)
|
|
212
|
+
if not isinstance(entry, Mapping):
|
|
213
|
+
return None
|
|
214
|
+
port = coerce_int(entry.get("portNo"))
|
|
215
|
+
if port is None:
|
|
216
|
+
continue
|
|
217
|
+
normalized.append({"portNo": port, "enabled": bool(entry.get("enabled"))})
|
|
218
|
+
|
|
219
|
+
return normalized
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def normalize_port_security(payload: Any) -> dict[str, Any]:
|
|
223
|
+
"""Normalize IoT port-security payloads."""
|
|
224
|
+
|
|
225
|
+
seen: set[int] = set()
|
|
226
|
+
|
|
227
|
+
def _apply_hint(
|
|
228
|
+
candidate: dict[str, Any] | None, hint_value: bool | None
|
|
229
|
+
) -> dict[str, Any] | None:
|
|
230
|
+
if (
|
|
231
|
+
candidate is not None
|
|
232
|
+
and "enabled" not in candidate
|
|
233
|
+
and isinstance(hint_value, bool)
|
|
234
|
+
):
|
|
235
|
+
candidate["enabled"] = hint_value
|
|
236
|
+
return candidate
|
|
237
|
+
|
|
238
|
+
def _walk_mapping(obj: Mapping[str, Any], hint: bool | None) -> dict[str, Any] | None:
|
|
239
|
+
obj_id = id(obj)
|
|
240
|
+
if obj_id in seen:
|
|
241
|
+
return None
|
|
242
|
+
seen.add(obj_id)
|
|
243
|
+
|
|
244
|
+
enabled_local = obj.get("enabled")
|
|
245
|
+
if isinstance(enabled_local, bool):
|
|
246
|
+
hint = enabled_local
|
|
247
|
+
|
|
248
|
+
ports = _normalize_port_list(obj.get("portSecurityList"))
|
|
249
|
+
if ports is not None:
|
|
250
|
+
return {
|
|
251
|
+
"portSecurityList": ports,
|
|
252
|
+
"enabled": bool(enabled_local)
|
|
253
|
+
if isinstance(enabled_local, bool)
|
|
254
|
+
else bool(hint)
|
|
255
|
+
if isinstance(hint, bool)
|
|
256
|
+
else True,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for key in ("PortSecurity", "value", "data", "NetworkSecurityProtection"):
|
|
260
|
+
if key in obj:
|
|
261
|
+
candidate = _apply_hint(_walk(obj[key], hint), hint)
|
|
262
|
+
if candidate:
|
|
263
|
+
return candidate
|
|
264
|
+
|
|
265
|
+
for value in obj.values():
|
|
266
|
+
candidate = _apply_hint(_walk(value, hint), hint)
|
|
267
|
+
if candidate:
|
|
268
|
+
return candidate
|
|
269
|
+
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def _walk_iterable(values: Iterable[Any], hint: bool | None) -> dict[str, Any] | None:
|
|
273
|
+
for item in values:
|
|
274
|
+
candidate = _walk(item, hint)
|
|
275
|
+
if candidate:
|
|
276
|
+
return candidate
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def _walk(obj: Any, hint: bool | None = None) -> dict[str, Any] | None:
|
|
280
|
+
obj = decode_json(obj)
|
|
281
|
+
if obj is None:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
if isinstance(obj, Mapping):
|
|
285
|
+
return _walk_mapping(obj, hint)
|
|
286
|
+
|
|
287
|
+
if isinstance(obj, Iterable) and not isinstance(obj, (str, bytes, bytearray)):
|
|
288
|
+
return _walk_iterable(obj, hint)
|
|
289
|
+
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
normalized = _walk(payload)
|
|
293
|
+
if isinstance(normalized, dict):
|
|
294
|
+
normalized.setdefault("enabled", True)
|
|
295
|
+
return normalized
|
|
296
|
+
return {}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def port_security_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
300
|
+
"""Return the normalized port-security mapping for a camera payload."""
|
|
301
|
+
|
|
302
|
+
direct = camera_data.get("NetworkSecurityProtection")
|
|
303
|
+
normalized = normalize_port_security(direct)
|
|
304
|
+
if normalized:
|
|
305
|
+
return normalized
|
|
306
|
+
|
|
307
|
+
feature = camera_data.get("FEATURE_INFO")
|
|
308
|
+
if isinstance(feature, Mapping):
|
|
309
|
+
normalized = normalize_port_security(feature)
|
|
310
|
+
if normalized:
|
|
311
|
+
return normalized
|
|
312
|
+
|
|
313
|
+
return {}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def port_security_has_port(camera_data: Mapping[str, Any], port: int) -> bool:
|
|
317
|
+
"""Return True if the normalized config contains the port."""
|
|
318
|
+
|
|
319
|
+
ports = port_security_config(camera_data).get("portSecurityList")
|
|
320
|
+
if not isinstance(ports, Iterable):
|
|
321
|
+
return False
|
|
322
|
+
return any(
|
|
323
|
+
isinstance(entry, Mapping) and coerce_int(entry.get("portNo")) == port
|
|
324
|
+
for entry in ports
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def port_security_port_enabled(camera_data: Mapping[str, Any], port: int) -> bool:
|
|
329
|
+
"""Return True if the specific port is enabled."""
|
|
330
|
+
|
|
331
|
+
ports = port_security_config(camera_data).get("portSecurityList")
|
|
332
|
+
if not isinstance(ports, Iterable):
|
|
333
|
+
return False
|
|
334
|
+
for entry in ports:
|
|
335
|
+
if isinstance(entry, Mapping) and coerce_int(entry.get("portNo")) == port:
|
|
336
|
+
return bool(entry.get("enabled"))
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
81
340
|
def display_mode_value(camera_data: Mapping[str, Any]) -> int:
|
|
82
341
|
"""Return display mode value (1..3) from camera data."""
|
|
83
342
|
|
|
@@ -85,10 +344,9 @@ def display_mode_value(camera_data: Mapping[str, Any]) -> int:
|
|
|
85
344
|
display_mode = optionals.get("display_mode")
|
|
86
345
|
display_mode = decode_json(display_mode)
|
|
87
346
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
mode = display_mode
|
|
347
|
+
mode = (
|
|
348
|
+
display_mode.get("mode") if isinstance(display_mode, Mapping) else display_mode
|
|
349
|
+
)
|
|
92
350
|
|
|
93
351
|
if isinstance(mode, int) and mode in (1, 2, 3):
|
|
94
352
|
return mode
|
|
@@ -96,6 +354,33 @@ def display_mode_value(camera_data: Mapping[str, Any]) -> int:
|
|
|
96
354
|
return 1
|
|
97
355
|
|
|
98
356
|
|
|
357
|
+
def blc_current_value(camera_data: Mapping[str, Any]) -> int:
|
|
358
|
+
"""Return BLC position (0..5) from camera data. 0 = Off."""
|
|
359
|
+
optionals = optionals_mapping(camera_data)
|
|
360
|
+
inverse_mode = optionals.get("inverse_mode")
|
|
361
|
+
inverse_mode = decode_json(inverse_mode)
|
|
362
|
+
|
|
363
|
+
# Expected: {"mode": int, "enable": 0|1, "position": 0..5}
|
|
364
|
+
if isinstance(inverse_mode, Mapping):
|
|
365
|
+
enable = inverse_mode.get("enable", 0)
|
|
366
|
+
position = inverse_mode.get("position", 0)
|
|
367
|
+
if (
|
|
368
|
+
isinstance(enable, int)
|
|
369
|
+
and enable == 1
|
|
370
|
+
and isinstance(position, int)
|
|
371
|
+
and position in (1, 2, 3, 4, 5)
|
|
372
|
+
):
|
|
373
|
+
return position
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
# Fallbacks if backend ever returns a bare int (position) instead of the object
|
|
377
|
+
if isinstance(inverse_mode, int) and inverse_mode in (0, 1, 2, 3, 4, 5):
|
|
378
|
+
return inverse_mode
|
|
379
|
+
|
|
380
|
+
# Default to Off
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
99
384
|
def device_icr_dss_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
100
385
|
"""Decode and return the device_ICR_DSS configuration."""
|
|
101
386
|
|
pyezvizapi/light_bulb.py
CHANGED
|
@@ -176,7 +176,7 @@ class EzvizLightBulb:
|
|
|
176
176
|
def set_brightness(self, value: int) -> bool:
|
|
177
177
|
"""Set the light bulb brightness.
|
|
178
178
|
|
|
179
|
-
The value must be in range 1
|
|
179
|
+
The value must be in range 1-100. Returns True on success.
|
|
180
180
|
|
|
181
181
|
Raises:
|
|
182
182
|
PyEzvizError: On API failures.
|
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."""
|
|
@@ -110,15 +113,14 @@ class TestRTSPAuth:
|
|
|
110
113
|
describe = genmsg_describe(
|
|
111
114
|
url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
|
|
112
115
|
)
|
|
113
|
-
|
|
116
|
+
_LOGGER.debug("RTSP DESCRIBE (basic):\n%s", describe)
|
|
114
117
|
session.send(describe.encode())
|
|
115
118
|
msg1: bytes = session.recv(self._rtsp_details["bufLen"])
|
|
116
119
|
seq += 1
|
|
117
120
|
|
|
118
121
|
decoded = msg1.decode()
|
|
119
122
|
if "200 OK" in decoded:
|
|
120
|
-
|
|
121
|
-
print("Basic Auth test passed. Credentials Valid!")
|
|
123
|
+
_LOGGER.info("Basic auth result: %s", decoded)
|
|
122
124
|
return
|
|
123
125
|
|
|
124
126
|
if "Unauthorized" in decoded:
|
|
@@ -140,20 +142,16 @@ class TestRTSPAuth:
|
|
|
140
142
|
describe = genmsg_describe(
|
|
141
143
|
url, seq, self._rtsp_details["defaultUserAgent"], auth_seq
|
|
142
144
|
)
|
|
143
|
-
|
|
145
|
+
_LOGGER.debug("RTSP DESCRIBE (digest):\n%s", describe)
|
|
144
146
|
session.send(describe.encode())
|
|
145
147
|
msg1 = session.recv(self._rtsp_details["bufLen"])
|
|
146
148
|
decoded = msg1.decode()
|
|
147
|
-
|
|
149
|
+
_LOGGER.info("Digest auth result: %s", decoded)
|
|
148
150
|
|
|
149
151
|
if "200 OK" in decoded:
|
|
150
|
-
print("Digest Auth test Passed. Credentials Valid!")
|
|
151
152
|
return
|
|
152
153
|
|
|
153
154
|
if "401 Unauthorized" in decoded:
|
|
154
155
|
raise AuthTestResultFailed("Credentials not valid!!")
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# 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
|