pyezvizapi 1.0.1.7__py3-none-any.whl → 1.0.1.9__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/__init__.py +11 -1
- pyezvizapi/__main__.py +406 -283
- pyezvizapi/camera.py +494 -118
- pyezvizapi/cas.py +36 -43
- pyezvizapi/client.py +785 -1345
- pyezvizapi/constants.py +6 -1
- pyezvizapi/exceptions.py +9 -9
- pyezvizapi/light_bulb.py +80 -31
- pyezvizapi/models.py +103 -0
- pyezvizapi/mqtt.py +42 -14
- pyezvizapi/test_cam_rtsp.py +95 -109
- pyezvizapi/test_mqtt.py +101 -30
- pyezvizapi/utils.py +0 -1
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/METADATA +2 -2
- pyezvizapi-1.0.1.9.dist-info/RECORD +21 -0
- pyezvizapi-1.0.1.7.dist-info/RECORD +0 -20
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/WHEEL +0 -0
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/entry_points.txt +0 -0
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/licenses/LICENSE +0 -0
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/licenses/LICENSE.md +0 -0
- {pyezvizapi-1.0.1.7.dist-info → pyezvizapi-1.0.1.9.dist-info}/top_level.txt +0 -0
pyezvizapi/camera.py
CHANGED
|
@@ -3,112 +3,335 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
|
-
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
|
9
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
7
10
|
|
|
8
11
|
from .constants import BatteryCameraWorkMode, DeviceSwitchType, SoundMode
|
|
9
12
|
from .exceptions import PyEzvizError
|
|
13
|
+
from .models import EzvizDeviceRecord
|
|
10
14
|
from .utils import fetch_nested_value, string_to_list
|
|
11
15
|
|
|
12
16
|
if TYPE_CHECKING:
|
|
13
17
|
from .client import EzvizClient
|
|
14
18
|
|
|
15
19
|
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CameraStatus(TypedDict, total=False):
|
|
24
|
+
"""Typed mapping for Ezviz camera status payload."""
|
|
25
|
+
|
|
26
|
+
serial: str
|
|
27
|
+
name: str | None
|
|
28
|
+
version: str | None
|
|
29
|
+
upgrade_available: bool
|
|
30
|
+
status: int | None
|
|
31
|
+
device_category: str | None
|
|
32
|
+
device_sub_category: str | None
|
|
33
|
+
upgrade_percent: Any
|
|
34
|
+
upgrade_in_progress: bool
|
|
35
|
+
latest_firmware_info: Any
|
|
36
|
+
alarm_notify: bool
|
|
37
|
+
alarm_schedules_enabled: bool
|
|
38
|
+
alarm_sound_mod: str
|
|
39
|
+
encrypted: bool
|
|
40
|
+
encrypted_pwd_hash: Any
|
|
41
|
+
local_ip: str
|
|
42
|
+
wan_ip: Any
|
|
43
|
+
mac_address: Any
|
|
44
|
+
offline_notify: bool
|
|
45
|
+
last_offline_time: Any
|
|
46
|
+
local_rtsp_port: str
|
|
47
|
+
supported_channels: Any
|
|
48
|
+
battery_level: Any
|
|
49
|
+
PIR_Status: Any
|
|
50
|
+
Motion_Trigger: bool
|
|
51
|
+
Seconds_Last_Trigger: Any
|
|
52
|
+
last_alarm_time: Any
|
|
53
|
+
last_alarm_pic: str
|
|
54
|
+
last_alarm_type_code: str
|
|
55
|
+
last_alarm_type_name: str
|
|
56
|
+
cam_timezone: Any
|
|
57
|
+
push_notify_alarm: bool
|
|
58
|
+
push_notify_call: bool
|
|
59
|
+
alarm_light_luminance: Any
|
|
60
|
+
Alarm_DetectHumanCar: Any
|
|
61
|
+
diskCapacity: Any
|
|
62
|
+
NightVision_Model: Any
|
|
63
|
+
battery_camera_work_mode: Any
|
|
64
|
+
Alarm_AdvancedDetect: Any
|
|
65
|
+
resouceid: Any
|
|
66
|
+
supportExt: Any
|
|
67
|
+
# Backwards-compatibility aliases used by integration layers
|
|
68
|
+
optionals: Any
|
|
69
|
+
switches: dict[int, bool]
|
|
70
|
+
# Note: Top-level pagelist keys like 'WIFI', 'SWITCH', 'STATUS', etc. are
|
|
71
|
+
# merged into the returned dict dynamically in status() to allow consumers
|
|
72
|
+
# to access new data without library changes. We intentionally avoid adding
|
|
73
|
+
# parallel curated aliases like 'wifiInfos', 'switches', or 'optionals'.
|
|
74
|
+
|
|
75
|
+
|
|
16
76
|
class EzvizCamera:
|
|
17
|
-
"""
|
|
77
|
+
"""Representation of an Ezviz camera device.
|
|
78
|
+
|
|
79
|
+
Wraps the Ezviz pagelist/device mapping and surfaces a stable API
|
|
80
|
+
to query status and perform common actions (PTZ, switches, alarm
|
|
81
|
+
settings, etc.). Designed for use in Home Assistant and scripts.
|
|
82
|
+
"""
|
|
18
83
|
|
|
19
84
|
def __init__(
|
|
20
|
-
self,
|
|
85
|
+
self,
|
|
86
|
+
client: EzvizClient,
|
|
87
|
+
serial: str,
|
|
88
|
+
device_obj: EzvizDeviceRecord | dict | None = None,
|
|
21
89
|
) -> None:
|
|
22
|
-
"""Initialize the camera object.
|
|
90
|
+
"""Initialize the camera object.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
InvalidURL: If the API endpoint/connection is invalid when fetching device info.
|
|
94
|
+
HTTPError: If the API returns a non-success HTTP status while fetching device info.
|
|
95
|
+
PyEzvizError: On Ezviz API contract errors or decoding failures.
|
|
96
|
+
"""
|
|
23
97
|
self._client = client
|
|
24
98
|
self._serial = serial
|
|
25
99
|
self._alarmmotiontrigger: dict[str, Any] = {
|
|
26
100
|
"alarm_trigger_active": False,
|
|
27
101
|
"timepassed": None,
|
|
28
102
|
}
|
|
29
|
-
self.
|
|
30
|
-
|
|
31
|
-
|
|
103
|
+
self._record: EzvizDeviceRecord | None = None
|
|
104
|
+
|
|
105
|
+
if device_obj is None:
|
|
106
|
+
self._device = self._client.get_device_infos(self._serial)
|
|
107
|
+
elif isinstance(device_obj, EzvizDeviceRecord):
|
|
108
|
+
# Accept either a typed record or the original dict
|
|
109
|
+
self._record = device_obj
|
|
110
|
+
self._device = dict(device_obj.raw)
|
|
111
|
+
else:
|
|
112
|
+
self._device = device_obj or {}
|
|
32
113
|
self._last_alarm: dict[str, Any] = {}
|
|
33
|
-
self._switch: dict[int, bool] = {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
114
|
+
self._switch: dict[int, bool] = {}
|
|
115
|
+
if self._record and getattr(self._record, "switches", None):
|
|
116
|
+
self._switch = {int(k): bool(v) for k, v in self._record.switches.items()}
|
|
117
|
+
else:
|
|
118
|
+
switches = self._device.get("SWITCH") or []
|
|
119
|
+
if isinstance(switches, list):
|
|
120
|
+
for item in switches:
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
continue
|
|
123
|
+
t = item.get("type")
|
|
124
|
+
en = item.get("enable")
|
|
125
|
+
if isinstance(t, int) and isinstance(en, (bool, int)):
|
|
126
|
+
self._switch[t] = bool(en)
|
|
127
|
+
|
|
128
|
+
def fetch_key(self, keys: list[Any], default_value: Any = None) -> Any:
|
|
38
129
|
"""Fetch dictionary key."""
|
|
39
130
|
return fetch_nested_value(self._device, keys, default_value)
|
|
40
131
|
|
|
41
132
|
def _alarm_list(self) -> None:
|
|
42
|
-
"""Get last alarm info for this camera's self._serial.
|
|
133
|
+
"""Get last alarm info for this camera's self._serial.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
137
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
138
|
+
PyEzvizError: On Ezviz API contract errors or decoding failures.
|
|
139
|
+
"""
|
|
43
140
|
_alarmlist = self._client.get_alarminfo(self._serial)
|
|
44
141
|
|
|
45
|
-
|
|
46
|
-
|
|
142
|
+
total = fetch_nested_value(_alarmlist, ["page", "totalResults"], 0)
|
|
143
|
+
if total and total > 0:
|
|
144
|
+
self._last_alarm = _alarmlist.get("alarms", [{}])[0]
|
|
145
|
+
_LOGGER.debug(
|
|
146
|
+
"Fetched last alarm for %s: %s", self._serial, self._last_alarm
|
|
147
|
+
)
|
|
47
148
|
self._motion_trigger()
|
|
149
|
+
else:
|
|
150
|
+
_LOGGER.debug("No alarms found for %s", self._serial)
|
|
48
151
|
|
|
49
|
-
def _local_ip(self) ->
|
|
152
|
+
def _local_ip(self) -> str:
|
|
50
153
|
"""Fix empty ip value for certain cameras."""
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return self._device["WIFI"]["address"]
|
|
154
|
+
wifi = (self._record.wifi if self._record else self._device.get("WIFI")) or {}
|
|
155
|
+
addr = wifi.get("address")
|
|
156
|
+
if isinstance(addr, str) and addr != "0.0.0.0":
|
|
157
|
+
return addr
|
|
56
158
|
|
|
57
159
|
# Seems to return none or 0.0.0.0 on some.
|
|
58
|
-
|
|
59
|
-
self.
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
160
|
+
conn = (
|
|
161
|
+
self._record.connection if self._record else self._device.get("CONNECTION")
|
|
162
|
+
) or {}
|
|
163
|
+
local_ip = conn.get("localIp")
|
|
164
|
+
if isinstance(local_ip, str) and local_ip != "0.0.0.0":
|
|
165
|
+
return local_ip
|
|
63
166
|
|
|
64
167
|
return "0.0.0.0"
|
|
65
168
|
|
|
66
169
|
def _motion_trigger(self) -> None:
|
|
67
|
-
"""Create motion sensor based on last alarm time.
|
|
68
|
-
if not self._last_alarm.get("alarmStartTimeStr"):
|
|
69
|
-
return
|
|
170
|
+
"""Create motion sensor based on last alarm time.
|
|
70
171
|
|
|
71
|
-
|
|
72
|
-
|
|
172
|
+
Prefer numeric epoch fields if available to avoid parsing localized strings.
|
|
173
|
+
"""
|
|
174
|
+
# Use timezone-aware datetimes based on camera or local timezone.
|
|
175
|
+
tzinfo = self._get_tzinfo()
|
|
176
|
+
now = datetime.datetime.now(tz=tzinfo).replace(microsecond=0)
|
|
73
177
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
178
|
+
# Prefer epoch fields if available
|
|
179
|
+
epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
|
|
180
|
+
"alarmTime"
|
|
77
181
|
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
182
|
+
last_alarm_dt: datetime.datetime | None = None
|
|
183
|
+
if epoch is not None:
|
|
184
|
+
try:
|
|
185
|
+
# Accept int/float/str; auto-detect ms vs s
|
|
186
|
+
if isinstance(epoch, str):
|
|
187
|
+
epoch = float(epoch)
|
|
188
|
+
ts = float(epoch)
|
|
189
|
+
if ts > 1e11: # very likely milliseconds
|
|
190
|
+
ts = ts / 1000.0
|
|
191
|
+
last_alarm_dt = datetime.datetime.fromtimestamp(ts, tz=tzinfo)
|
|
192
|
+
except (
|
|
193
|
+
TypeError,
|
|
194
|
+
ValueError,
|
|
195
|
+
OSError,
|
|
196
|
+
): # fall back to string parsing below
|
|
197
|
+
last_alarm_dt = None
|
|
198
|
+
|
|
199
|
+
if last_alarm_dt is None:
|
|
200
|
+
# Fall back to string parsing
|
|
201
|
+
raw = str(
|
|
202
|
+
self._last_alarm.get("alarmStartTimeStr")
|
|
203
|
+
or self._last_alarm.get("alarmTimeStr")
|
|
204
|
+
or ""
|
|
205
|
+
)
|
|
206
|
+
if not raw:
|
|
207
|
+
return
|
|
208
|
+
if "Today" in raw:
|
|
209
|
+
raw = raw.replace("Today", str(now.date()))
|
|
210
|
+
try:
|
|
211
|
+
last_alarm_dt = datetime.datetime.strptime(
|
|
212
|
+
raw, "%Y-%m-%d %H:%M:%S"
|
|
213
|
+
).replace(tzinfo=tzinfo)
|
|
214
|
+
except ValueError: # Unrecognized format; give up gracefully
|
|
215
|
+
_LOGGER.debug(
|
|
216
|
+
"Unrecognized alarm time format for %s: %s", self._serial, raw
|
|
217
|
+
)
|
|
218
|
+
self._alarmmotiontrigger = {
|
|
219
|
+
"alarm_trigger_active": False,
|
|
220
|
+
"timepassed": None,
|
|
221
|
+
}
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
timepassed = now - last_alarm_dt
|
|
225
|
+
seconds = max(0.0, timepassed.total_seconds()) if timepassed else None
|
|
81
226
|
|
|
82
227
|
self._alarmmotiontrigger = {
|
|
83
228
|
"alarm_trigger_active": bool(timepassed < datetime.timedelta(seconds=60)),
|
|
84
|
-
"timepassed":
|
|
229
|
+
"timepassed": seconds,
|
|
85
230
|
}
|
|
86
231
|
|
|
232
|
+
def _get_tzinfo(self) -> datetime.tzinfo:
|
|
233
|
+
"""Return tzinfo from camera setting if recognizable, else local tzinfo.
|
|
234
|
+
|
|
235
|
+
Attempts to parse common formats like 'UTC+02:00', 'GMT+8', '+0530', or IANA names.
|
|
236
|
+
Falls back to local timezone.
|
|
237
|
+
"""
|
|
238
|
+
tz_val = self.fetch_key(["STATUS", "optionals", "timeZone"])
|
|
239
|
+
# IANA zone name
|
|
240
|
+
if isinstance(tz_val, str) and "/" in tz_val:
|
|
241
|
+
try:
|
|
242
|
+
return ZoneInfo(tz_val)
|
|
243
|
+
except ZoneInfoNotFoundError:
|
|
244
|
+
pass
|
|
245
|
+
# Offset formats
|
|
246
|
+
offset_minutes: int | None = None
|
|
247
|
+
if isinstance(tz_val, int):
|
|
248
|
+
# Heuristic: treat small absolute values as hours, large as minutes/seconds
|
|
249
|
+
if -14 <= tz_val <= 14:
|
|
250
|
+
offset_minutes = tz_val * 60
|
|
251
|
+
elif -24 * 60 <= tz_val <= 24 * 60:
|
|
252
|
+
offset_minutes = tz_val
|
|
253
|
+
elif -24 * 3600 <= tz_val <= 24 * 3600:
|
|
254
|
+
offset_minutes = int(tz_val / 60)
|
|
255
|
+
elif isinstance(tz_val, str):
|
|
256
|
+
s = tz_val.strip().upper().replace("UTC", "").replace("GMT", "")
|
|
257
|
+
# Normalize formats like '+02:00', '+0200', '+2'
|
|
258
|
+
m = re.match(r"^([+-]?)(\d{1,2})(?::?(\d{2}))?$", s)
|
|
259
|
+
if m:
|
|
260
|
+
sign = -1 if m.group(1) == "-" else 1
|
|
261
|
+
hours = int(m.group(2))
|
|
262
|
+
minutes = int(m.group(3)) if m.group(3) else 0
|
|
263
|
+
offset_minutes = sign * (hours * 60 + minutes)
|
|
264
|
+
if offset_minutes is not None:
|
|
265
|
+
return datetime.timezone(datetime.timedelta(minutes=offset_minutes))
|
|
266
|
+
# Fallback to local timezone
|
|
267
|
+
return datetime.datetime.now().astimezone().tzinfo or datetime.UTC
|
|
268
|
+
|
|
87
269
|
def _is_alarm_schedules_enabled(self) -> bool:
|
|
88
270
|
"""Check if alarm schedules enabled."""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
271
|
+
plans = self.fetch_key(["TIME_PLAN"], []) or []
|
|
272
|
+
sched = next(
|
|
273
|
+
(
|
|
274
|
+
item
|
|
275
|
+
for item in plans
|
|
276
|
+
if isinstance(item, dict) and item.get("type") == 2
|
|
277
|
+
),
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
return bool(sched and sched.get("enable"))
|
|
92
281
|
|
|
93
|
-
|
|
94
|
-
|
|
282
|
+
def status(self, refresh: bool = True) -> CameraStatus:
|
|
283
|
+
"""Return the status of the camera.
|
|
95
284
|
|
|
96
|
-
|
|
285
|
+
refresh: if True, updates alarm info via network before composing status.
|
|
97
286
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
287
|
+
Raises:
|
|
288
|
+
InvalidURL: If the API endpoint/connection is invalid while refreshing.
|
|
289
|
+
HTTPError: If the API returns a non-success HTTP status while refreshing.
|
|
290
|
+
PyEzvizError: On Ezviz API contract errors or decoding failures.
|
|
291
|
+
"""
|
|
292
|
+
if refresh:
|
|
293
|
+
self._alarm_list()
|
|
101
294
|
|
|
102
|
-
|
|
295
|
+
name = (
|
|
296
|
+
self._record.name
|
|
297
|
+
if self._record
|
|
298
|
+
else self.fetch_key(["deviceInfos", "name"])
|
|
299
|
+
)
|
|
300
|
+
version = (
|
|
301
|
+
self._record.version
|
|
302
|
+
if self._record
|
|
303
|
+
else self.fetch_key(["deviceInfos", "version"])
|
|
304
|
+
)
|
|
305
|
+
dev_status = (
|
|
306
|
+
self._record.status
|
|
307
|
+
if self._record
|
|
308
|
+
else self.fetch_key(["deviceInfos", "status"])
|
|
309
|
+
)
|
|
310
|
+
device_category = (
|
|
311
|
+
self._record.device_category
|
|
312
|
+
if self._record
|
|
313
|
+
else self.fetch_key(["deviceInfos", "deviceCategory"])
|
|
314
|
+
)
|
|
315
|
+
device_sub_category = (
|
|
316
|
+
self._record.device_sub_category
|
|
317
|
+
if self._record
|
|
318
|
+
else self.fetch_key(["deviceInfos", "deviceSubCategory"])
|
|
319
|
+
)
|
|
320
|
+
conn = (
|
|
321
|
+
self._record.connection if self._record else self._device.get("CONNECTION")
|
|
322
|
+
) or {}
|
|
323
|
+
wan_ip = conn.get("netIp") or self.fetch_key(["CONNECTION", "netIp"])
|
|
324
|
+
|
|
325
|
+
data: dict[str, Any] = {
|
|
103
326
|
"serial": self._serial,
|
|
104
|
-
"name":
|
|
105
|
-
"version":
|
|
327
|
+
"name": name,
|
|
328
|
+
"version": version,
|
|
106
329
|
"upgrade_available": bool(
|
|
107
330
|
self.fetch_key(["UPGRADE", "isNeedUpgrade"]) == 3
|
|
108
331
|
),
|
|
109
|
-
"status":
|
|
110
|
-
"device_category":
|
|
111
|
-
"device_sub_category":
|
|
332
|
+
"status": dev_status,
|
|
333
|
+
"device_category": device_category,
|
|
334
|
+
"device_sub_category": device_sub_category,
|
|
112
335
|
"upgrade_percent": self.fetch_key(["STATUS", "upgradeProcess"]),
|
|
113
336
|
"upgrade_in_progress": bool(
|
|
114
337
|
self.fetch_key(["STATUS", "upgradeStatus"]) == 0
|
|
@@ -122,13 +345,26 @@ class EzvizCamera:
|
|
|
122
345
|
"encrypted": bool(self.fetch_key(["STATUS", "isEncrypt"])),
|
|
123
346
|
"encrypted_pwd_hash": self.fetch_key(["STATUS", "encryptPwd"]),
|
|
124
347
|
"local_ip": self._local_ip(),
|
|
125
|
-
"wan_ip":
|
|
348
|
+
"wan_ip": wan_ip,
|
|
349
|
+
"supportExt": (
|
|
350
|
+
self._record.support_ext
|
|
351
|
+
if self._record
|
|
352
|
+
else self.fetch_key(
|
|
353
|
+
["deviceInfos", "supportExt"]
|
|
354
|
+
) # convenience top-level
|
|
355
|
+
),
|
|
356
|
+
# Backwards-compatibility aliases
|
|
357
|
+
"optionals": self.fetch_key(["STATUS", "optionals"]),
|
|
358
|
+
"switches": self._switch,
|
|
126
359
|
"mac_address": self.fetch_key(["deviceInfos", "mac"]),
|
|
127
360
|
"offline_notify": bool(self.fetch_key(["deviceInfos", "offlineNotify"])),
|
|
128
361
|
"last_offline_time": self.fetch_key(["deviceInfos", "offlineTime"]),
|
|
129
|
-
"local_rtsp_port":
|
|
130
|
-
|
|
131
|
-
|
|
362
|
+
"local_rtsp_port": (
|
|
363
|
+
"554"
|
|
364
|
+
if (port := self.fetch_key(["CONNECTION", "localRtspPort"], "554"))
|
|
365
|
+
in (0, "0", None)
|
|
366
|
+
else str(port)
|
|
367
|
+
),
|
|
132
368
|
"supported_channels": self.fetch_key(["deviceInfos", "channelNumber"]),
|
|
133
369
|
"battery_level": self.fetch_key(["STATUS", "optionals", "powerRemaining"]),
|
|
134
370
|
"PIR_Status": self.fetch_key(["STATUS", "pirStatus"]),
|
|
@@ -165,106 +401,246 @@ class EzvizCamera:
|
|
|
165
401
|
["STATUS", "optionals", "Alarm_AdvancedDetect", "type"]
|
|
166
402
|
),
|
|
167
403
|
"resouceid": self.fetch_key(["resourceInfos", 0, "resourceId"]),
|
|
168
|
-
"wifiInfos": self._device["WIFI"],
|
|
169
|
-
"switches": self._switch,
|
|
170
|
-
"optionals": self.fetch_key(["STATUS", "optionals"]),
|
|
171
|
-
"supportExt": self._device["deviceInfos"]["supportExt"],
|
|
172
|
-
"ezDeviceCapability": self.fetch_key(["deviceInfos", "ezDeviceCapability"]),
|
|
173
404
|
}
|
|
174
405
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
406
|
+
# Include all top-level keys from the pagelist/device mapping to allow
|
|
407
|
+
# consumers to access new fields without library updates. We do not
|
|
408
|
+
# overwrite curated keys above if there is a name collision.
|
|
409
|
+
source_map = dict(self._record.raw) if self._record else dict(self._device)
|
|
410
|
+
for key, value in source_map.items():
|
|
411
|
+
if key not in data:
|
|
412
|
+
data[key] = value
|
|
413
|
+
|
|
414
|
+
return cast(CameraStatus, data)
|
|
415
|
+
|
|
416
|
+
# essential_status() was removed in favor of including all top-level
|
|
417
|
+
# pagelist keys directly in status().
|
|
179
418
|
|
|
419
|
+
def move(
|
|
420
|
+
self, direction: Literal["right", "left", "down", "up"], speed: int = 5
|
|
421
|
+
) -> bool:
|
|
422
|
+
"""Move camera in a given direction.
|
|
423
|
+
|
|
424
|
+
direction: one of "right", "left", "down", "up".
|
|
425
|
+
speed: movement speed, expected range 1..10 (inclusive).
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
PyEzvizError: On invalid parameters or API failures.
|
|
429
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
430
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
431
|
+
"""
|
|
432
|
+
if speed < 1 or speed > 10:
|
|
433
|
+
raise PyEzvizError(f"Invalid speed: {speed}. Expected 1..10")
|
|
434
|
+
|
|
435
|
+
dir_up = direction.upper()
|
|
436
|
+
_LOGGER.debug("PTZ %s at speed %s for %s", dir_up, speed, self._serial)
|
|
180
437
|
# launch the start command
|
|
181
|
-
self._client.ptz_control(
|
|
438
|
+
self._client.ptz_control(dir_up, self._serial, "START", speed)
|
|
182
439
|
# launch the stop command
|
|
183
|
-
self._client.ptz_control(
|
|
440
|
+
self._client.ptz_control(dir_up, self._serial, "STOP", speed)
|
|
184
441
|
|
|
185
442
|
return True
|
|
186
443
|
|
|
444
|
+
# Public helper to refresh alarms without calling status()
|
|
445
|
+
def refresh_alarms(self) -> None:
|
|
446
|
+
"""Refresh last alarm information from the API."""
|
|
447
|
+
self._alarm_list()
|
|
448
|
+
|
|
187
449
|
def move_coordinates(self, x_axis: float, y_axis: float) -> bool:
|
|
188
|
-
"""Move camera to specified coordinates.
|
|
450
|
+
"""Move camera to specified coordinates.
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
PyEzvizError: On API failures.
|
|
454
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
455
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
456
|
+
"""
|
|
457
|
+
_LOGGER.debug(
|
|
458
|
+
"PTZ move to coordinates x=%s y=%s for %s", x_axis, y_axis, self._serial
|
|
459
|
+
)
|
|
189
460
|
return self._client.ptz_control_coordinates(self._serial, x_axis, y_axis)
|
|
190
461
|
|
|
191
462
|
def door_unlock(self) -> bool:
|
|
192
|
-
"""Unlock the door lock.
|
|
193
|
-
|
|
463
|
+
"""Unlock the door lock.
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
PyEzvizError: On API failures.
|
|
467
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
468
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
469
|
+
"""
|
|
470
|
+
_LOGGER.debug("Remote door unlock for %s", self._serial)
|
|
471
|
+
user = str(getattr(self._client, "_token", {}).get("username", ""))
|
|
472
|
+
return self._client.remote_unlock(self._serial, user, 2)
|
|
194
473
|
|
|
195
474
|
def gate_unlock(self) -> bool:
|
|
196
|
-
"""Unlock the gate lock.
|
|
197
|
-
|
|
475
|
+
"""Unlock the gate lock.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
PyEzvizError: On API failures.
|
|
479
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
480
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
481
|
+
"""
|
|
482
|
+
_LOGGER.debug("Remote gate unlock for %s", self._serial)
|
|
483
|
+
user = str(getattr(self._client, "_token", {}).get("username", ""))
|
|
484
|
+
return self._client.remote_unlock(self._serial, user, 1)
|
|
198
485
|
|
|
199
|
-
def alarm_notify(self, enable:
|
|
200
|
-
"""Enable/Disable camera notification when movement is detected.
|
|
201
|
-
|
|
486
|
+
def alarm_notify(self, enable: bool) -> bool:
|
|
487
|
+
"""Enable/Disable camera notification when movement is detected.
|
|
488
|
+
|
|
489
|
+
Raises:
|
|
490
|
+
PyEzvizError: On API failures.
|
|
491
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
492
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
493
|
+
"""
|
|
494
|
+
_LOGGER.debug("Set alarm notify=%s for %s", enable, self._serial)
|
|
495
|
+
return self._client.set_camera_defence(self._serial, int(enable))
|
|
202
496
|
|
|
203
497
|
def alarm_sound(self, sound_type: int) -> bool:
|
|
204
|
-
"""Enable/Disable camera sound when movement is detected.
|
|
498
|
+
"""Enable/Disable camera sound when movement is detected.
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
PyEzvizError: On API failures.
|
|
502
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
503
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
504
|
+
"""
|
|
205
505
|
# we force enable = 1 , to make sound...
|
|
506
|
+
_LOGGER.debug("Trigger alarm sound type=%s for %s", sound_type, self._serial)
|
|
206
507
|
return self._client.alarm_sound(self._serial, sound_type, 1)
|
|
207
508
|
|
|
208
|
-
def do_not_disturb(self, enable:
|
|
509
|
+
def do_not_disturb(self, enable: bool) -> bool:
|
|
209
510
|
"""Enable/Disable do not disturb.
|
|
210
511
|
|
|
211
512
|
if motion triggers are normally sent to your device as a
|
|
212
513
|
notification, then enabling this feature stops these notification being sent.
|
|
213
514
|
The alarm event is still recorded in the EzViz app as normal.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
PyEzvizError: On API failures.
|
|
518
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
519
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
214
520
|
"""
|
|
215
|
-
|
|
521
|
+
_LOGGER.debug("Set do_not_disturb=%s for %s", enable, self._serial)
|
|
522
|
+
return self._client.do_not_disturb(self._serial, int(enable))
|
|
523
|
+
|
|
524
|
+
def alarm_detection_sensitivity(
|
|
525
|
+
self, sensitivity: int, type_value: int = 0
|
|
526
|
+
) -> bool:
|
|
527
|
+
"""Set motion detection sensitivity.
|
|
216
528
|
|
|
529
|
+
sensitivity: device-specific integer scale.
|
|
530
|
+
type_value: optional type selector for devices supporting multiple types.
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
PyEzvizError: On API failures.
|
|
534
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
535
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
536
|
+
"""
|
|
537
|
+
_LOGGER.debug(
|
|
538
|
+
"Set detection sensitivity=%s type=%s for %s",
|
|
539
|
+
sensitivity,
|
|
540
|
+
type_value,
|
|
541
|
+
self._serial,
|
|
542
|
+
)
|
|
543
|
+
return bool(
|
|
544
|
+
self._client.detection_sensibility(self._serial, sensitivity, type_value)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Backwards-compatible alias (deprecated)
|
|
217
548
|
def alarm_detection_sensibility(
|
|
218
549
|
self, sensibility: int, type_value: int = 0
|
|
219
|
-
) -> bool
|
|
220
|
-
"""
|
|
221
|
-
|
|
222
|
-
|
|
550
|
+
) -> bool:
|
|
551
|
+
"""Deprecated: use alarm_detection_sensitivity()."""
|
|
552
|
+
return self.alarm_detection_sensitivity(sensibility, type_value)
|
|
553
|
+
|
|
554
|
+
# Generic switch helper
|
|
555
|
+
def set_switch(self, switch: DeviceSwitchType, enable: bool = False) -> bool:
|
|
556
|
+
"""Set a device switch to enabled/disabled.
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
PyEzvizError: On API failures.
|
|
560
|
+
InvalidURL: If the API endpoint/connection is invalid.
|
|
561
|
+
HTTPError: If the API returns a non-success HTTP status.
|
|
562
|
+
"""
|
|
563
|
+
_LOGGER.debug("Set switch %s=%s for %s", switch.name, enable, self._serial)
|
|
564
|
+
return self._client.switch_status(self._serial, switch.value, int(enable))
|
|
223
565
|
|
|
224
|
-
def switch_device_audio(self, enable:
|
|
225
|
-
"""Switch audio status on a device.
|
|
226
|
-
return self._client.switch_status(
|
|
227
|
-
self._serial, DeviceSwitchType.SOUND.value, enable
|
|
228
|
-
)
|
|
566
|
+
def switch_device_audio(self, enable: bool = False) -> bool:
|
|
567
|
+
"""Switch audio status on a device.
|
|
229
568
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
)
|
|
569
|
+
Raises:
|
|
570
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
571
|
+
"""
|
|
572
|
+
return self.set_switch(DeviceSwitchType.SOUND, enable)
|
|
235
573
|
|
|
236
|
-
def
|
|
237
|
-
"""Switch
|
|
238
|
-
return self._client.switch_status(
|
|
239
|
-
self._serial, DeviceSwitchType.INFRARED_LIGHT.value, enable
|
|
240
|
-
)
|
|
574
|
+
def switch_device_state_led(self, enable: bool = False) -> bool:
|
|
575
|
+
"""Switch led status on a device.
|
|
241
576
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
577
|
+
Raises:
|
|
578
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
579
|
+
"""
|
|
580
|
+
return self.set_switch(DeviceSwitchType.LIGHT, enable)
|
|
247
581
|
|
|
248
|
-
def
|
|
249
|
-
"""Switch
|
|
250
|
-
return self._client.switch_status(
|
|
251
|
-
self._serial, DeviceSwitchType.SLEEP.value, enable
|
|
252
|
-
)
|
|
582
|
+
def switch_device_ir_led(self, enable: bool = False) -> bool:
|
|
583
|
+
"""Switch ir status on a device.
|
|
253
584
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
585
|
+
Raises:
|
|
586
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
587
|
+
"""
|
|
588
|
+
return self.set_switch(DeviceSwitchType.INFRARED_LIGHT, enable)
|
|
589
|
+
|
|
590
|
+
def switch_privacy_mode(self, enable: bool = False) -> bool:
|
|
591
|
+
"""Switch privacy mode on a device.
|
|
259
592
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
593
|
+
Raises:
|
|
594
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
595
|
+
"""
|
|
596
|
+
return self.set_switch(DeviceSwitchType.PRIVACY, enable)
|
|
597
|
+
|
|
598
|
+
def switch_sleep_mode(self, enable: bool = False) -> bool:
|
|
599
|
+
"""Switch sleep mode on a device.
|
|
600
|
+
|
|
601
|
+
Raises:
|
|
602
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
603
|
+
"""
|
|
604
|
+
return self.set_switch(DeviceSwitchType.SLEEP, enable)
|
|
605
|
+
|
|
606
|
+
def switch_follow_move(self, enable: bool = False) -> bool:
|
|
607
|
+
"""Switch follow move.
|
|
608
|
+
|
|
609
|
+
Raises:
|
|
610
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
611
|
+
"""
|
|
612
|
+
return self.set_switch(DeviceSwitchType.MOBILE_TRACKING, enable)
|
|
613
|
+
|
|
614
|
+
def switch_sound_alarm(self, enable: int | bool = False) -> bool:
|
|
615
|
+
"""Sound alarm on a device.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
619
|
+
"""
|
|
620
|
+
_LOGGER.debug("Set sound alarm enable=%s for %s", enable, self._serial)
|
|
621
|
+
return self._client.sound_alarm(self._serial, int(enable))
|
|
263
622
|
|
|
264
623
|
def change_defence_schedule(self, schedule: str, enable: int = 0) -> bool:
|
|
265
|
-
"""Change defence schedule. Requires json formatted schedules.
|
|
624
|
+
"""Change defence schedule. Requires json formatted schedules.
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
628
|
+
"""
|
|
629
|
+
_LOGGER.debug(
|
|
630
|
+
"Change defence schedule enable=%s for %s payload_len=%s",
|
|
631
|
+
enable,
|
|
632
|
+
self._serial,
|
|
633
|
+
len(schedule) if isinstance(schedule, str) else None,
|
|
634
|
+
)
|
|
266
635
|
return self._client.api_set_defence_schedule(self._serial, schedule, enable)
|
|
267
636
|
|
|
268
637
|
def set_battery_camera_work_mode(self, work_mode: BatteryCameraWorkMode) -> bool:
|
|
269
|
-
"""Change work mode for battery powered camera device.
|
|
638
|
+
"""Change work mode for battery powered camera device.
|
|
639
|
+
|
|
640
|
+
Raises:
|
|
641
|
+
PyEzvizError, InvalidURL, HTTPError
|
|
642
|
+
"""
|
|
643
|
+
_LOGGER.debug(
|
|
644
|
+
"Set battery camera work mode=%s for %s", work_mode.name, self._serial
|
|
645
|
+
)
|
|
270
646
|
return self._client.set_battery_camera_work_mode(self._serial, work_mode.value)
|