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/camera.py CHANGED
@@ -3,112 +3,335 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import datetime
6
- from typing import TYPE_CHECKING, Any
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
- """Initialize Ezviz camera object."""
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, client: EzvizClient, serial: str, device_obj: dict | None = None
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._device = (
30
- device_obj if device_obj else self._client.get_device_infos(self._serial)
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
- switch["type"]: switch["enable"] for switch in self._device["SWITCH"]
35
- }
36
-
37
- def fetch_key(self, keys: list, default_value: Any = None) -> Any:
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
- if fetch_nested_value(_alarmlist, ["page", "totalResults"], 0) > 0:
46
- self._last_alarm = _alarmlist["alarms"][0]
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) -> Any:
152
+ def _local_ip(self) -> str:
50
153
  """Fix empty ip value for certain cameras."""
51
- if (
52
- self.fetch_key(["WIFI", "address"])
53
- and self._device["WIFI"]["address"] != "0.0.0.0"
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
- if (
59
- self.fetch_key(["CONNECTION", "localIp"])
60
- and self._device["CONNECTION"]["localIp"] != "0.0.0.0"
61
- ):
62
- return self._device["CONNECTION"]["localIp"]
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
- _today_date = datetime.date.today()
72
- _now = datetime.datetime.now().replace(microsecond=0)
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
- _last_alarm_time = datetime.datetime.strptime(
75
- self._last_alarm["alarmStartTimeStr"].replace("Today", str(_today_date)),
76
- "%Y-%m-%d %H:%M:%S",
178
+ # Prefer epoch fields if available
179
+ epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
180
+ "alarmTime"
77
181
  )
78
-
79
- # returns a timedelta object
80
- timepassed = _now - _last_alarm_time
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": timepassed.total_seconds(),
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
- _alarm_schedules = [
90
- item for item in self._device["TIME_PLAN"] if item.get("type") == 2
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
- if _alarm_schedules:
94
- return bool(_alarm_schedules[0].get("enable"))
282
+ def status(self, refresh: bool = True) -> CameraStatus:
283
+ """Return the status of the camera.
95
284
 
96
- return False
285
+ refresh: if True, updates alarm info via network before composing status.
97
286
 
98
- def status(self) -> dict[Any, Any]:
99
- """Return the status of the camera."""
100
- self._alarm_list()
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
- return {
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": self.fetch_key(["deviceInfos", "name"]),
105
- "version": self.fetch_key(["deviceInfos", "version"]),
327
+ "name": name,
328
+ "version": version,
106
329
  "upgrade_available": bool(
107
330
  self.fetch_key(["UPGRADE", "isNeedUpgrade"]) == 3
108
331
  ),
109
- "status": self.fetch_key(["deviceInfos", "status"]),
110
- "device_category": self.fetch_key(["deviceInfos", "deviceCategory"]),
111
- "device_sub_category": self.fetch_key(["deviceInfos", "deviceSubCategory"]),
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": self.fetch_key(["CONNECTION", "netIp"]),
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": self.fetch_key(["CONNECTION", "localRtspPort"], "554")
130
- if self.fetch_key(["CONNECTION", "localRtspPort"], "554") != 0
131
- else "554",
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
- def move(self, direction: str, speed: int = 5) -> bool:
176
- """Move camera."""
177
- if direction not in ["right", "left", "down", "up"]:
178
- raise PyEzvizError(f"Invalid direction: {direction} ")
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(str(direction).upper(), self._serial, "START", speed)
438
+ self._client.ptz_control(dir_up, self._serial, "START", speed)
182
439
  # launch the stop command
183
- self._client.ptz_control(str(direction).upper(), self._serial, "STOP", speed)
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
- return self._client.remote_unlock(self._serial, 2)
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
- return self._client.remote_unlock(self._serial, 1)
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: int) -> bool:
200
- """Enable/Disable camera notification when movement is detected."""
201
- return self._client.set_camera_defence(self._serial, enable)
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: int) -> bool:
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
- return self._client.do_not_disturb(self._serial, enable)
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 | str:
220
- """Enable/Disable camera sound when movement is detected."""
221
- # we force enable = 1 , to make sound...
222
- return self._client.detection_sensibility(self._serial, sensibility, type_value)
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: int = 0) -> bool:
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
- def switch_device_state_led(self, enable: int = 0) -> bool:
231
- """Switch led status on a device."""
232
- return self._client.switch_status(
233
- self._serial, DeviceSwitchType.LIGHT.value, enable
234
- )
569
+ Raises:
570
+ PyEzvizError, InvalidURL, HTTPError
571
+ """
572
+ return self.set_switch(DeviceSwitchType.SOUND, enable)
235
573
 
236
- def switch_device_ir_led(self, enable: int = 0) -> bool:
237
- """Switch ir status on a device."""
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
- def switch_privacy_mode(self, enable: int = 0) -> bool:
243
- """Switch privacy mode on a device."""
244
- return self._client.switch_status(
245
- self._serial, DeviceSwitchType.PRIVACY.value, enable
246
- )
577
+ Raises:
578
+ PyEzvizError, InvalidURL, HTTPError
579
+ """
580
+ return self.set_switch(DeviceSwitchType.LIGHT, enable)
247
581
 
248
- def switch_sleep_mode(self, enable: int = 0) -> bool:
249
- """Switch sleep mode on a device."""
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
- def switch_follow_move(self, enable: int = 0) -> bool:
255
- """Switch follow move."""
256
- return self._client.switch_status(
257
- self._serial, DeviceSwitchType.MOBILE_TRACKING.value, enable
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
- def switch_sound_alarm(self, enable: int = 0) -> bool:
261
- """Sound alarm on a device."""
262
- return self._client.sound_alarm(self._serial, enable)
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)