pyezvizapi 1.0.2.3__py3-none-any.whl → 1.0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyezvizapi/constants.py CHANGED
@@ -6,13 +6,25 @@ the Ezviz API to descriptive names.
6
6
  """
7
7
 
8
8
  from enum import Enum, unique
9
+ from hashlib import md5
10
+ import uuid
9
11
 
10
- from .utils import generate_unique_code
11
12
 
12
- FEATURE_CODE = generate_unique_code()
13
+ def _generate_unique_code() -> str:
14
+ """Generate a deterministic unique code for this host."""
15
+
16
+ mac_int = uuid.getnode()
17
+ mac_str = ":".join(f"{(mac_int >> i) & 0xFF:02x}" for i in range(40, -1, -8))
18
+ return md5(mac_str.encode("utf-8")).hexdigest()
19
+
20
+
21
+ FEATURE_CODE = _generate_unique_code()
13
22
  XOR_KEY = b"\x0c\x0eJ^X\x15@Rr"
14
23
  DEFAULT_TIMEOUT = 25
15
24
  MAX_RETRIES = 3
25
+ # Unified message API default subtype that returns all alarm categories.
26
+ DEFAULT_UNIFIEDMSG_STYPE = "92"
27
+ HIK_ENCRYPTION_HEADER = b"hikencodepicture"
16
28
  REQUEST_HEADER = {
17
29
  "featureCode": FEATURE_CODE,
18
30
  "clientType": "3",
@@ -34,7 +46,7 @@ APP_SECRET = "17454517-cc1c-42b3-a845-99b4a15dd3e6"
34
46
 
35
47
  @unique
36
48
  class MessageFilterType(Enum):
37
- """Message filter types for unified list."""
49
+ """Fine-grained message filters used by the unified list API."""
38
50
 
39
51
  FILTER_TYPE_MOTION = 2402
40
52
  FILTER_TYPE_PERSON = 2403
@@ -44,6 +56,16 @@ class MessageFilterType(Enum):
44
56
  FILTER_TYPE_SYSTEM_MESSAGE = 2101
45
57
 
46
58
 
59
+ @unique
60
+ class UnifiedMessageSubtype(str, Enum):
61
+ """High-level subtype bundles supported by the Ezviz mobile app."""
62
+
63
+ # Equivalent to the "All alarm" chip in the official app UI.
64
+ ALL_ALARMS = "92"
65
+ # Same comma-separated bundle returned by msgDefaultSubtype() inside the app.
66
+ DEFAULT_APP_SUBTYPE = "9904,2701"
67
+
68
+
47
69
  @unique
48
70
  class DeviceSwitchType(Enum):
49
71
  """Device switch name and number."""
@@ -84,11 +106,14 @@ class DeviceSwitchType(Enum):
84
106
  TAMPER_ALARM = 306
85
107
  DETECTION_TYPE = 451
86
108
  OUTLET_RECOVER = 600
109
+ WIDE_DYNAMIC_RANGE = 604
87
110
  CHIME_INDICATOR_LIGHT = 611
111
+ DISTORTION_CORRECTION = 617
88
112
  TRACKING = 650
89
113
  CRUISE_TRACKING = 651
90
114
  PARTIAL_IMAGE_OPTIMIZE = 700
91
115
  FEATURE_TRACKING = 701
116
+ LOGO_WATERMARK = 702
92
117
 
93
118
 
94
119
  @unique
@@ -173,6 +198,7 @@ class SupportExt(Enum):
173
198
  SupportDisk = 4
174
199
  SupportDiskBlackList = 367
175
200
  SupportDistributionNetworkBetweenDevice = 420
201
+ SupportDistortionCorrection = 490
176
202
  SupportDisturbMode = 217
177
203
  SupportDisturbNewMode = 292
178
204
  SupportDoorCallPlayBack = 545
@@ -221,6 +247,7 @@ class SupportExt(Enum):
221
247
  SupportLightRelate = 297
222
248
  SupportLocalConnect = 507
223
249
  SupportLocalLockGate = 662
250
+ SupportLogoWatermark = 632
224
251
  SupportLockConfigWay = 679
225
252
  SupportMessage = 6
226
253
  SupportMicroVolumnSet = 77
@@ -233,6 +260,7 @@ class SupportExt(Enum):
233
260
  SupportMultiChannelFlip = 732
234
261
  SupportMultiChannelSharedService = 720
235
262
  SupportMultiChannelType = 719
263
+ SupportAdvancedDetectType = 793
236
264
  SupportMultiScreen = 17
237
265
  SupportMultiSubsys = 255
238
266
  SupportMultilensPlay = 665
@@ -328,6 +356,7 @@ class SupportExt(Enum):
328
356
  SupportSleep = 62
329
357
  SupportSmartBodyDetect = 244
330
358
  SupportSmartNightVision = 274
359
+ SupportWideDynamicRange = 273
331
360
  SupportSoundLightAlarm = 214
332
361
  SupportSsl = 25
333
362
  SupportStopRecordVideo = 219
@@ -464,4 +493,4 @@ class DeviceCatagories(Enum):
464
493
  BASE_STATION_DEVICE_CATEGORY = "XVR"
465
494
  CAT_EYE_CATEGORY = "CatEye"
466
495
  LIGHTING = "lighting"
467
- W2H_BASE_STATION_DEVICE_CATEGORY = "IGateWay" # @emeric699 Adding support for W2H Base Station
496
+ W2H_BASE_STATION_DEVICE_CATEGORY = "IGateWay"
pyezvizapi/feature.py ADDED
@@ -0,0 +1,536 @@
1
+ """Helpers for working with Ezviz feature metadata payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator, Mapping, MutableMapping
6
+ from typing import Any, cast
7
+
8
+ from .utils import WILDCARD_STEP, coerce_int, decode_json, first_nested
9
+
10
+
11
+ def _feature_video_section(camera_data: Mapping[str, Any]) -> dict[str, Any]:
12
+ """Return the nested Video feature section from feature info payload."""
13
+
14
+ video = first_nested(
15
+ camera_data,
16
+ ("FEATURE_INFO", WILDCARD_STEP, "Video"),
17
+ )
18
+ if isinstance(video, MutableMapping):
19
+ return cast(dict[str, Any], video)
20
+ return {}
21
+
22
+
23
+ def supplement_light_params(camera_data: Mapping[str, Any]) -> dict[str, Any]:
24
+ """Return SupplementLightMgr parameters if present."""
25
+
26
+ video = _feature_video_section(camera_data)
27
+ if not video:
28
+ return {}
29
+
30
+ manager: Any = video.get("SupplementLightMgr")
31
+ manager = decode_json(manager)
32
+ if not isinstance(manager, Mapping):
33
+ return {}
34
+
35
+ params: Any = manager.get("ImageSupplementLightModeSwitchParams")
36
+ params = decode_json(params)
37
+ return dict(params) if isinstance(params, Mapping) else {}
38
+
39
+
40
+ def supplement_light_enabled(camera_data: Mapping[str, Any]) -> bool:
41
+ """Return True when intelligent fill light is enabled."""
42
+
43
+ params = supplement_light_params(camera_data)
44
+ if not params:
45
+ return False
46
+
47
+ enabled = params.get("enabled")
48
+ if isinstance(enabled, bool):
49
+ return enabled
50
+ if isinstance(enabled, str):
51
+ lowered = enabled.strip().lower()
52
+ if lowered in {"true", "1", "yes", "on"}:
53
+ return True
54
+ if lowered in {"false", "0", "no", "off"}:
55
+ return False
56
+ return bool(enabled)
57
+
58
+
59
+ def supplement_light_available(camera_data: Mapping[str, Any]) -> bool:
60
+ """Return True when intelligent fill light parameters are present."""
61
+
62
+ return bool(supplement_light_params(camera_data))
63
+
64
+
65
+ def lens_defog_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
66
+ """Return the LensCleaning defog configuration if present."""
67
+
68
+ video = _feature_video_section(camera_data)
69
+ lens = video.get("LensCleaning") if isinstance(video, Mapping) else None
70
+ if not isinstance(lens, MutableMapping):
71
+ return {}
72
+
73
+ config = lens.get("DefogCfg")
74
+ if isinstance(config, MutableMapping):
75
+ return cast(dict[str, Any], config)
76
+ return {}
77
+
78
+
79
+ def lens_defog_value(camera_data: Mapping[str, Any]) -> int:
80
+ """Return canonical defogging mode (0=auto,1=on,2=off)."""
81
+
82
+ cfg = lens_defog_config(camera_data)
83
+ if not cfg:
84
+ return 0
85
+
86
+ enabled = bool(cfg.get("enabled"))
87
+ mode = str(cfg.get("defogMode") or "").lower()
88
+
89
+ if not enabled:
90
+ return 2
91
+
92
+ if mode == "open":
93
+ return 1
94
+
95
+ return 0
96
+
97
+
98
+ def optionals_mapping(camera_data: Mapping[str, Any]) -> dict[str, Any]:
99
+ """Return decoded optionals mapping from the camera payload."""
100
+
101
+ status_info = camera_data.get("statusInfo")
102
+ optionals: Any = None
103
+ if isinstance(status_info, Mapping):
104
+ optionals = status_info.get("optionals")
105
+
106
+ optionals = decode_json(optionals)
107
+
108
+ if not isinstance(optionals, Mapping):
109
+ optionals = decode_json(camera_data.get("optionals"))
110
+
111
+ if not isinstance(optionals, Mapping):
112
+ status = camera_data.get("STATUS")
113
+ if isinstance(status, Mapping):
114
+ optionals = decode_json(status.get("optionals"))
115
+
116
+ return dict(optionals) if isinstance(optionals, Mapping) else {}
117
+
118
+
119
+ def optionals_dict(camera_data: Mapping[str, Any]) -> dict[str, Any]:
120
+ """Return convenience wrapper for optionals mapping."""
121
+
122
+ return optionals_mapping(camera_data)
123
+
124
+
125
+ def custom_voice_volume_config(camera_data: Mapping[str, Any]) -> dict[str, int] | None:
126
+ """Return current CustomVoice volume configuration."""
127
+
128
+ optionals = optionals_mapping(camera_data)
129
+ config = optionals.get("CustomVoice_Volume")
130
+ config = decode_json(config)
131
+ if not isinstance(config, Mapping):
132
+ return None
133
+
134
+ volume = coerce_int(config.get("volume"))
135
+ mic = coerce_int(config.get("microphone_volume"))
136
+ result: dict[str, int] = {}
137
+ if isinstance(volume, int):
138
+ result["volume"] = volume
139
+ if isinstance(mic, int):
140
+ result["microphone_volume"] = mic
141
+ return result or None
142
+
143
+
144
+ def iter_algorithm_entries(camera_data: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
145
+ """Yield entries from the AlgorithmInfo optionals list."""
146
+
147
+ entries = optionals_dict(camera_data).get("AlgorithmInfo")
148
+ if not isinstance(entries, Iterable):
149
+ return
150
+ for entry in entries:
151
+ if isinstance(entry, Mapping):
152
+ yield dict(entry)
153
+
154
+
155
+ def iter_channel_algorithm_entries(
156
+ camera_data: Mapping[str, Any], channel: int
157
+ ) -> Iterator[dict[str, Any]]:
158
+ """Yield AlgorithmInfo entries filtered by channel."""
159
+
160
+ for entry in iter_algorithm_entries(camera_data):
161
+ entry_channel = coerce_int(entry.get("channel")) or 1
162
+ if entry_channel == channel:
163
+ yield entry
164
+
165
+
166
+ def get_algorithm_value(
167
+ camera_data: Mapping[str, Any], subtype: str, channel: int
168
+ ) -> int | None:
169
+ """Return AlgorithmInfo value for provided subtype/channel."""
170
+
171
+ for entry in iter_channel_algorithm_entries(camera_data, channel):
172
+ if entry.get("SubType") != subtype:
173
+ continue
174
+ return coerce_int(entry.get("Value"))
175
+ return None
176
+
177
+
178
+ def has_algorithm_subtype(
179
+ camera_data: Mapping[str, Any], subtype: str, channel: int = 1
180
+ ) -> bool:
181
+ """Return True when AlgorithmInfo contains subtype for channel."""
182
+
183
+ return get_algorithm_value(camera_data, subtype, channel) is not None
184
+
185
+
186
+ def support_ext_value(camera_data: Mapping[str, Any], ext_key: str) -> str | None:
187
+ """Fetch a supportExt entry as a string when present."""
188
+
189
+ raw = camera_data.get("supportExt")
190
+ if not isinstance(raw, Mapping):
191
+ device_infos = camera_data.get("deviceInfos")
192
+ if isinstance(device_infos, Mapping):
193
+ raw = device_infos.get("supportExt")
194
+
195
+ if not isinstance(raw, Mapping):
196
+ return None
197
+
198
+ value = raw.get(ext_key)
199
+ return str(value) if value is not None else None
200
+
201
+
202
+ def _normalize_port_list(value: Any) -> list[dict[str, Any]] | None:
203
+ """Decode a list of port-security entries."""
204
+
205
+ value = decode_json(value)
206
+ if not isinstance(value, Iterable):
207
+ return None
208
+
209
+ normalized: list[dict[str, Any]] = []
210
+ for raw_entry in value:
211
+ entry = decode_json(raw_entry)
212
+ if not isinstance(entry, Mapping):
213
+ return None
214
+ port = coerce_int(entry.get("portNo"))
215
+ if port is None:
216
+ continue
217
+ normalized.append({"portNo": port, "enabled": bool(entry.get("enabled"))})
218
+
219
+ return normalized
220
+
221
+
222
+ def normalize_port_security(payload: Any) -> dict[str, Any]:
223
+ """Normalize IoT port-security payloads."""
224
+
225
+ seen: set[int] = set()
226
+
227
+ def _apply_hint(
228
+ candidate: dict[str, Any] | None, hint_value: bool | None
229
+ ) -> dict[str, Any] | None:
230
+ if (
231
+ candidate is not None
232
+ and "enabled" not in candidate
233
+ and isinstance(hint_value, bool)
234
+ ):
235
+ candidate["enabled"] = hint_value
236
+ return candidate
237
+
238
+ def _walk_mapping(obj: Mapping[str, Any], hint: bool | None) -> dict[str, Any] | None:
239
+ obj_id = id(obj)
240
+ if obj_id in seen:
241
+ return None
242
+ seen.add(obj_id)
243
+
244
+ enabled_local = obj.get("enabled")
245
+ if isinstance(enabled_local, bool):
246
+ hint = enabled_local
247
+
248
+ ports = _normalize_port_list(obj.get("portSecurityList"))
249
+ if ports is not None:
250
+ return {
251
+ "portSecurityList": ports,
252
+ "enabled": bool(enabled_local)
253
+ if isinstance(enabled_local, bool)
254
+ else bool(hint)
255
+ if isinstance(hint, bool)
256
+ else True,
257
+ }
258
+
259
+ for key in ("PortSecurity", "value", "data", "NetworkSecurityProtection"):
260
+ if key in obj:
261
+ candidate = _apply_hint(_walk(obj[key], hint), hint)
262
+ if candidate:
263
+ return candidate
264
+
265
+ for value in obj.values():
266
+ candidate = _apply_hint(_walk(value, hint), hint)
267
+ if candidate:
268
+ return candidate
269
+
270
+ return None
271
+
272
+ def _walk_iterable(values: Iterable[Any], hint: bool | None) -> dict[str, Any] | None:
273
+ for item in values:
274
+ candidate = _walk(item, hint)
275
+ if candidate:
276
+ return candidate
277
+ return None
278
+
279
+ def _walk(obj: Any, hint: bool | None = None) -> dict[str, Any] | None:
280
+ obj = decode_json(obj)
281
+ if obj is None:
282
+ return None
283
+
284
+ if isinstance(obj, Mapping):
285
+ return _walk_mapping(obj, hint)
286
+
287
+ if isinstance(obj, Iterable) and not isinstance(obj, (str, bytes, bytearray)):
288
+ return _walk_iterable(obj, hint)
289
+
290
+ return None
291
+
292
+ normalized = _walk(payload)
293
+ if isinstance(normalized, dict):
294
+ normalized.setdefault("enabled", True)
295
+ return normalized
296
+ return {}
297
+
298
+
299
+ def port_security_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
300
+ """Return the normalized port-security mapping for a camera payload."""
301
+
302
+ direct = camera_data.get("NetworkSecurityProtection")
303
+ normalized = normalize_port_security(direct)
304
+ if normalized:
305
+ return normalized
306
+
307
+ feature = camera_data.get("FEATURE_INFO")
308
+ if isinstance(feature, Mapping):
309
+ normalized = normalize_port_security(feature)
310
+ if normalized:
311
+ return normalized
312
+
313
+ return {}
314
+
315
+
316
+ def port_security_has_port(camera_data: Mapping[str, Any], port: int) -> bool:
317
+ """Return True if the normalized config contains the port."""
318
+
319
+ ports = port_security_config(camera_data).get("portSecurityList")
320
+ if not isinstance(ports, Iterable):
321
+ return False
322
+ return any(
323
+ isinstance(entry, Mapping) and coerce_int(entry.get("portNo")) == port
324
+ for entry in ports
325
+ )
326
+
327
+
328
+ def port_security_port_enabled(camera_data: Mapping[str, Any], port: int) -> bool:
329
+ """Return True if the specific port is enabled."""
330
+
331
+ ports = port_security_config(camera_data).get("portSecurityList")
332
+ if not isinstance(ports, Iterable):
333
+ return False
334
+ for entry in ports:
335
+ if isinstance(entry, Mapping) and coerce_int(entry.get("portNo")) == port:
336
+ return bool(entry.get("enabled"))
337
+ return False
338
+
339
+
340
+ def display_mode_value(camera_data: Mapping[str, Any]) -> int:
341
+ """Return display mode value (1..3) from camera data."""
342
+
343
+ optionals = optionals_mapping(camera_data)
344
+ display_mode = optionals.get("display_mode")
345
+ display_mode = decode_json(display_mode)
346
+
347
+ mode = (
348
+ display_mode.get("mode") if isinstance(display_mode, Mapping) else display_mode
349
+ )
350
+
351
+ if isinstance(mode, int) and mode in (1, 2, 3):
352
+ return mode
353
+
354
+ return 1
355
+
356
+
357
+ def blc_current_value(camera_data: Mapping[str, Any]) -> int:
358
+ """Return BLC position (0..5) from camera data. 0 = Off."""
359
+ optionals = optionals_mapping(camera_data)
360
+ inverse_mode = optionals.get("inverse_mode")
361
+ inverse_mode = decode_json(inverse_mode)
362
+
363
+ # Expected: {"mode": int, "enable": 0|1, "position": 0..5}
364
+ if isinstance(inverse_mode, Mapping):
365
+ enable = inverse_mode.get("enable", 0)
366
+ position = inverse_mode.get("position", 0)
367
+ if (
368
+ isinstance(enable, int)
369
+ and enable == 1
370
+ and isinstance(position, int)
371
+ and position in (1, 2, 3, 4, 5)
372
+ ):
373
+ return position
374
+ return 0
375
+
376
+ # Fallbacks if backend ever returns a bare int (position) instead of the object
377
+ if isinstance(inverse_mode, int) and inverse_mode in (0, 1, 2, 3, 4, 5):
378
+ return inverse_mode
379
+
380
+ # Default to Off
381
+ return 0
382
+
383
+
384
+ def device_icr_dss_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
385
+ """Decode and return the device_ICR_DSS configuration."""
386
+
387
+ optionals = optionals_mapping(camera_data)
388
+ icr = decode_json(optionals.get("device_ICR_DSS"))
389
+
390
+ return dict(icr) if isinstance(icr, Mapping) else {}
391
+
392
+
393
+ def day_night_mode_value(camera_data: Mapping[str, Any]) -> int:
394
+ """Return current day/night mode (0=auto,1=day,2=night)."""
395
+
396
+ config = device_icr_dss_config(camera_data)
397
+ mode = config.get("mode")
398
+ if isinstance(mode, int) and mode in (0, 1, 2):
399
+ return mode
400
+ return 0
401
+
402
+
403
+ def day_night_sensitivity_value(camera_data: Mapping[str, Any]) -> int:
404
+ """Return current day/night sensitivity value (1..3)."""
405
+
406
+ config = device_icr_dss_config(camera_data)
407
+ sensitivity = config.get("sensitivity")
408
+ if isinstance(sensitivity, int) and sensitivity in (1, 2, 3):
409
+ return sensitivity
410
+ return 2
411
+
412
+
413
+ def resolve_channel(camera_data: Mapping[str, Any]) -> int:
414
+ """Return the channel number to use for devconfig operations."""
415
+
416
+ candidate = camera_data.get("channelNo") or camera_data.get("channel_no")
417
+ if isinstance(candidate, int):
418
+ return candidate
419
+ if isinstance(candidate, str) and candidate.isdigit():
420
+ return int(candidate)
421
+ return 1
422
+
423
+
424
+ def night_vision_config(camera_data: Mapping[str, Any]) -> dict[str, Any]:
425
+ """Return decoded NightVision_Model configuration mapping."""
426
+
427
+ optionals = optionals_mapping(camera_data)
428
+ config: Any = optionals.get("NightVision_Model")
429
+ if config is None:
430
+ config = camera_data.get("NightVision_Model")
431
+
432
+ config = decode_json(config)
433
+
434
+ return dict(config) if isinstance(config, Mapping) else {}
435
+
436
+
437
+ def night_vision_mode_value(camera_data: Mapping[str, Any]) -> int:
438
+ """Return current night vision mode (0=BW,1=colour,2=smart,5=super)."""
439
+
440
+ config = night_vision_config(camera_data)
441
+ mode = coerce_int(config.get("graphicType"))
442
+ if mode is None:
443
+ return 0
444
+ return mode if mode in (0, 1, 2, 5) else 0
445
+
446
+
447
+ def night_vision_luminance_value(camera_data: Mapping[str, Any]) -> int:
448
+ """Return the configured night vision luminance (default 40)."""
449
+
450
+ config = night_vision_config(camera_data)
451
+ value = coerce_int(config.get("luminance"))
452
+ if value is None:
453
+ value = 40
454
+ return max(0, value)
455
+
456
+
457
+ def night_vision_duration_value(camera_data: Mapping[str, Any]) -> int:
458
+ """Return the configured smart night vision duration (default 60)."""
459
+
460
+ config = night_vision_config(camera_data)
461
+ value = coerce_int(config.get("duration"))
462
+ return value if value is not None else 60
463
+
464
+
465
+ def night_vision_payload(
466
+ camera_data: Mapping[str, Any],
467
+ *,
468
+ mode: int | None = None,
469
+ luminance: int | None = None,
470
+ duration: int | None = None,
471
+ ) -> dict[str, Any]:
472
+ """Return a sanitized NightVision_Model payload for updates."""
473
+
474
+ config = dict(night_vision_config(camera_data))
475
+
476
+ resolved_mode = (
477
+ int(mode)
478
+ if mode is not None
479
+ else int(config.get("graphicType") or night_vision_mode_value(camera_data))
480
+ )
481
+ config["graphicType"] = resolved_mode
482
+
483
+ if luminance is None:
484
+ luminance_value = night_vision_luminance_value(camera_data)
485
+ else:
486
+ coerced_luminance = coerce_int(luminance)
487
+ luminance_value = (
488
+ coerced_luminance
489
+ if coerced_luminance is not None
490
+ else night_vision_luminance_value(camera_data)
491
+ )
492
+ if resolved_mode == 1:
493
+ config["luminance"] = 0 if luminance_value <= 0 else max(20, luminance_value)
494
+ elif resolved_mode == 2:
495
+ config["luminance"] = max(
496
+ 20,
497
+ luminance_value if luminance_value > 0 else 40,
498
+ )
499
+ else:
500
+ config["luminance"] = max(0, luminance_value)
501
+
502
+ if duration is None:
503
+ duration_value = night_vision_duration_value(camera_data)
504
+ else:
505
+ coerced_duration = coerce_int(duration)
506
+ duration_value = (
507
+ coerced_duration
508
+ if coerced_duration is not None
509
+ else night_vision_duration_value(camera_data)
510
+ )
511
+ if resolved_mode == 2:
512
+ config["duration"] = max(15, min(120, duration_value))
513
+ else:
514
+ config.pop("duration", None)
515
+
516
+ return config
517
+
518
+
519
+ def has_osd_overlay(camera_data: Mapping[str, Any]) -> bool:
520
+ """Return True when the camera has an active OSD label."""
521
+
522
+ optionals = optionals_mapping(camera_data)
523
+ osd_entries = optionals.get("OSD")
524
+
525
+ if isinstance(osd_entries, Mapping):
526
+ entries: list[Mapping[str, Any]] = [osd_entries]
527
+ elif isinstance(osd_entries, list):
528
+ entries = [entry for entry in osd_entries if isinstance(entry, Mapping)]
529
+ else:
530
+ return False
531
+
532
+ for entry in entries:
533
+ name = entry.get("name")
534
+ if isinstance(name, str) and name.strip():
535
+ return True
536
+ return False
pyezvizapi/light_bulb.py CHANGED
@@ -176,7 +176,7 @@ class EzvizLightBulb:
176
176
  def set_brightness(self, value: int) -> bool:
177
177
  """Set the light bulb brightness.
178
178
 
179
- The value must be in range 1100. Returns True on success.
179
+ The value must be in range 1-100. Returns True on success.
180
180
 
181
181
  Raises:
182
182
  PyEzvizError: On API failures.