pyezvizapi 1.0.1.7__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/camera.py CHANGED
@@ -3,112 +3,332 @@
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
+ # 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
- """Initialize Ezviz camera object."""
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, client: EzvizClient, serial: str, device_obj: dict | None = None
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._device = (
30
- device_obj if device_obj else self._client.get_device_infos(self._serial)
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
- switch["type"]: switch["enable"] for switch in self._device["SWITCH"]
35
- }
36
-
37
- def fetch_key(self, keys: list, default_value: Any = None) -> Any:
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
- if fetch_nested_value(_alarmlist, ["page", "totalResults"], 0) > 0:
46
- self._last_alarm = _alarmlist["alarms"][0]
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) -> Any:
149
+ def _local_ip(self) -> str:
50
150
  """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"]
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
- if (
59
- self.fetch_key(["CONNECTION", "localIp"])
60
- and self._device["CONNECTION"]["localIp"] != "0.0.0.0"
61
- ):
62
- return self._device["CONNECTION"]["localIp"]
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
- _today_date = datetime.date.today()
72
- _now = datetime.datetime.now().replace(microsecond=0)
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
- _last_alarm_time = datetime.datetime.strptime(
75
- self._last_alarm["alarmStartTimeStr"].replace("Today", str(_today_date)),
76
- "%Y-%m-%d %H:%M:%S",
175
+ # Prefer epoch fields if available
176
+ epoch = self._last_alarm.get("alarmStartTime") or self._last_alarm.get(
177
+ "alarmTime"
77
178
  )
78
-
79
- # returns a timedelta object
80
- timepassed = _now - _last_alarm_time
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": timepassed.total_seconds(),
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
- _alarm_schedules = [
90
- item for item in self._device["TIME_PLAN"] if item.get("type") == 2
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
- if _alarm_schedules:
94
- return bool(_alarm_schedules[0].get("enable"))
279
+ def status(self, refresh: bool = True) -> CameraStatus:
280
+ """Return the status of the camera.
95
281
 
96
- return False
282
+ refresh: if True, updates alarm info via network before composing status.
97
283
 
98
- def status(self) -> dict[Any, Any]:
99
- """Return the status of the camera."""
100
- self._alarm_list()
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
- return {
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": self.fetch_key(["deviceInfos", "name"]),
105
- "version": self.fetch_key(["deviceInfos", "version"]),
324
+ "name": name,
325
+ "version": version,
106
326
  "upgrade_available": bool(
107
327
  self.fetch_key(["UPGRADE", "isNeedUpgrade"]) == 3
108
328
  ),
109
- "status": self.fetch_key(["deviceInfos", "status"]),
110
- "device_category": self.fetch_key(["deviceInfos", "deviceCategory"]),
111
- "device_sub_category": self.fetch_key(["deviceInfos", "deviceSubCategory"]),
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": self.fetch_key(["CONNECTION", "netIp"]),
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": self.fetch_key(["CONNECTION", "localRtspPort"], "554")
130
- if self.fetch_key(["CONNECTION", "localRtspPort"], "554") != 0
131
- else "554",
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
- 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} ")
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(str(direction).upper(), self._serial, "START", speed)
432
+ self._client.ptz_control(dir_up, self._serial, "START", speed)
182
433
  # launch the stop command
183
- self._client.ptz_control(str(direction).upper(), self._serial, "STOP", speed)
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
- return self._client.remote_unlock(self._serial, 2)
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
- 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)
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: int) -> bool:
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
- return self._client.do_not_disturb(self._serial, enable)
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 | 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)
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: int = 0) -> bool:
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
- 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
- )
563
+ Raises:
564
+ PyEzvizError, InvalidURL, HTTPError
565
+ """
566
+ return self.set_switch(DeviceSwitchType.SOUND, enable)
235
567
 
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
- )
568
+ def switch_device_state_led(self, enable: bool = False) -> bool:
569
+ """Switch led status on a device.
241
570
 
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
- )
571
+ Raises:
572
+ PyEzvizError, InvalidURL, HTTPError
573
+ """
574
+ return self.set_switch(DeviceSwitchType.LIGHT, enable)
247
575
 
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
- )
576
+ def switch_device_ir_led(self, enable: bool = False) -> bool:
577
+ """Switch ir status on a device.
253
578
 
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
- )
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
- def switch_sound_alarm(self, enable: int = 0) -> bool:
261
- """Sound alarm on a device."""
262
- return self._client.sound_alarm(self._serial, enable)
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)