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