pyvesync 3.0.0__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.
Files changed (50) hide show
  1. pyvesync/__init__.py +5 -0
  2. pyvesync/base_devices/__init__.py +35 -0
  3. pyvesync/base_devices/bulb_base.py +330 -0
  4. pyvesync/base_devices/fan_base.py +392 -0
  5. pyvesync/base_devices/fryer_base.py +68 -0
  6. pyvesync/base_devices/humidifier_base.py +462 -0
  7. pyvesync/base_devices/outlet_base.py +327 -0
  8. pyvesync/base_devices/purifier_base.py +525 -0
  9. pyvesync/base_devices/switch_base.py +271 -0
  10. pyvesync/base_devices/thermostat_base.py +283 -0
  11. pyvesync/base_devices/vesyncbasedevice.py +572 -0
  12. pyvesync/const.py +825 -0
  13. pyvesync/device_container.py +373 -0
  14. pyvesync/device_map.py +1172 -0
  15. pyvesync/devices/__init__.py +1 -0
  16. pyvesync/devices/vesyncbulb.py +1126 -0
  17. pyvesync/devices/vesyncfan.py +233 -0
  18. pyvesync/devices/vesynchumidifier.py +808 -0
  19. pyvesync/devices/vesynckitchen.py +651 -0
  20. pyvesync/devices/vesyncoutlet.py +954 -0
  21. pyvesync/devices/vesyncpurifier.py +990 -0
  22. pyvesync/devices/vesyncswitch.py +415 -0
  23. pyvesync/devices/vesyncthermostat.py +171 -0
  24. pyvesync/models/__init__.py +11 -0
  25. pyvesync/models/base_models.py +129 -0
  26. pyvesync/models/bulb_models.py +197 -0
  27. pyvesync/models/bypass_models.py +289 -0
  28. pyvesync/models/fan_models.py +47 -0
  29. pyvesync/models/fryer_models.py +34 -0
  30. pyvesync/models/home_models.py +169 -0
  31. pyvesync/models/humidifier_models.py +202 -0
  32. pyvesync/models/outlet_models.py +220 -0
  33. pyvesync/models/purifier_models.py +261 -0
  34. pyvesync/models/switch_models.py +101 -0
  35. pyvesync/models/thermostat_models.py +134 -0
  36. pyvesync/models/vesync_models.py +439 -0
  37. pyvesync/utils/__init__.py +1 -0
  38. pyvesync/utils/colors.py +250 -0
  39. pyvesync/utils/device_mixins.py +362 -0
  40. pyvesync/utils/enum_utils.py +26 -0
  41. pyvesync/utils/errors.py +867 -0
  42. pyvesync/utils/helpers.py +700 -0
  43. pyvesync/utils/logs.py +525 -0
  44. pyvesync/vesync.py +638 -0
  45. pyvesync/vesynchome.py +152 -0
  46. pyvesync-3.0.0.dist-info/METADATA +567 -0
  47. pyvesync-3.0.0.dist-info/RECORD +50 -0
  48. pyvesync-3.0.0.dist-info/WHEEL +5 -0
  49. pyvesync-3.0.0.dist-info/licenses/LICENSE +21 -0
  50. pyvesync-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,990 @@
1
+ """VeSync API for controlling air purifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from typing_extensions import deprecated
9
+
10
+ from pyvesync.base_devices.purifier_base import VeSyncPurifier
11
+ from pyvesync.const import (
12
+ ConnectionStatus,
13
+ DeviceStatus,
14
+ PurifierAutoPreference,
15
+ PurifierModes,
16
+ )
17
+ from pyvesync.models.bypass_models import (
18
+ ResultV2GetTimer,
19
+ ResultV2SetTimer,
20
+ )
21
+ from pyvesync.models.purifier_models import (
22
+ InnerPurifierBaseResult,
23
+ Purifier131Result,
24
+ PurifierCoreDetailsResult,
25
+ PurifierSproutResult,
26
+ PurifierV2EventTiming,
27
+ PurifierV2TimerActionItems,
28
+ PurifierV2TimerPayloadData,
29
+ PurifierVitalDetailsResult,
30
+ RequestPurifier131,
31
+ RequestPurifier131Level,
32
+ RequestPurifier131Mode,
33
+ )
34
+ from pyvesync.utils.device_mixins import (
35
+ BypassV1Mixin,
36
+ BypassV2Mixin,
37
+ process_bypassv2_result,
38
+ )
39
+ from pyvesync.utils.helpers import Helpers, Timer
40
+
41
+ if TYPE_CHECKING:
42
+ from pyvesync import VeSync
43
+ from pyvesync.device_map import PurifierMap
44
+ from pyvesync.models.vesync_models import ResponseDeviceDetailsModel
45
+
46
+
47
+ _LOGGER = logging.getLogger(__name__)
48
+
49
+
50
+ class VeSyncAirBypass(BypassV2Mixin, VeSyncPurifier):
51
+ """Initialize air purifier devices.
52
+
53
+ Instantiated by VeSync manager object. Inherits from
54
+ VeSyncBaseDevice class. This is the primary class for most
55
+ air purifiers, using the Bypass V2 API, except the original LV-PUR131S.
56
+
57
+ Parameters:
58
+ details (dict): Dictionary of device details
59
+ manager (VeSync): Instantiated VeSync object used to make API calls
60
+ feature_map (PurifierMap): Device map template
61
+
62
+ Attributes:
63
+ state (PurifierState): State of the device.
64
+ last_response (ResponseInfo): Last response from API call.
65
+ manager (VeSync): Manager object for API calls.
66
+ device_name (str): Name of device.
67
+ device_image (str): URL for device image.
68
+ cid (str): Device ID.
69
+ connection_type (str): Connection type of device.
70
+ device_type (str): Type of device.
71
+ type (str): Type of device.
72
+ uuid (str): UUID of device, not always present.
73
+ config_module (str): Configuration module of device.
74
+ mac_id (str): MAC ID of device.
75
+ current_firm_version (str): Current firmware version of device.
76
+ device_region (str): Region of device. (US, EU, etc.)
77
+ pid (str): Product ID of device, pulled by some devices on update.
78
+ sub_device_no (int): Sub-device number of device.
79
+ product_type (str): Product type of device.
80
+ features (dict): Features of device.
81
+ modes (list[str]): List of modes supported by the device.
82
+ fan_levels (list[int]): List of fan levels supported by the device.
83
+ nightlight_modes (list[str]): List of nightlight modes supported by the device.
84
+ auto_preferences (list[str]): List of auto preferences supported by the device.
85
+
86
+ Notes:
87
+ The `details` attribute holds device information that is updated when
88
+ the `update()` method is called. An example of the `details` attribute:
89
+ ```python
90
+ json.dumps(self.details, indent=4)
91
+ {
92
+ 'filter_life': 0,
93
+ 'mode': 'manual',
94
+ 'level': 0,
95
+ 'display': False,
96
+ 'child_lock': False,
97
+ 'night_light': 'off',
98
+ 'air_quality': 0 # air quality level
99
+ 'air_quality_value': 0, # PM2.5 value from device,
100
+ 'display_forever': False
101
+ }
102
+ ```
103
+ """
104
+
105
+ __slots__ = ()
106
+
107
+ def __init__(
108
+ self,
109
+ details: ResponseDeviceDetailsModel,
110
+ manager: VeSync,
111
+ feature_map: PurifierMap,
112
+ ) -> None:
113
+ """Initialize VeSync Air Purifier Bypass Base Class."""
114
+ super().__init__(details, manager, feature_map)
115
+
116
+ def _set_purifier_state(self, result: PurifierCoreDetailsResult) -> None:
117
+ """Populate PurifierState with details from API response.
118
+
119
+ Populates `self.state` and instance variables with device details.
120
+
121
+ Args:
122
+ result (InnerPurifierResult): Data model for inner result in purifier
123
+ details response.
124
+ """
125
+ self.state.device_status = DeviceStatus.ON if result.enabled else DeviceStatus.OFF
126
+ self.state.filter_life = result.filter_life or 0
127
+ self.state.mode = result.mode
128
+ self.state.fan_level = result.level or 0
129
+ self.state.fan_set_level = result.levelNew or 0
130
+ self.state.display_status = (
131
+ DeviceStatus.ON if result.display else DeviceStatus.OFF
132
+ )
133
+ self.state.child_lock = result.child_lock or False
134
+ config = result.configuration
135
+ if config is not None:
136
+ self.state.display_set_status = (
137
+ DeviceStatus.ON if config.display else DeviceStatus.OFF
138
+ )
139
+ self.state.display_forever = config.display_forever
140
+ if config.auto_preference is not None:
141
+ self.state.auto_preference_type = config.auto_preference.type
142
+ self.state.auto_room_size = config.auto_preference.room_size
143
+ if self.supports_air_quality is True:
144
+ self.state.pm25 = result.air_quality_value
145
+ self.state.set_air_quality_level(result.air_quality)
146
+ if result.night_light is not None:
147
+ self.state.nightlight_status = DeviceStatus(result.night_light)
148
+
149
+ async def get_details(self) -> None:
150
+ r_dict = await self.call_bypassv2_api('getPurifierStatus')
151
+ resp_model = process_bypassv2_result(
152
+ self, _LOGGER, 'get_details', r_dict, PurifierCoreDetailsResult
153
+ )
154
+ if resp_model is None:
155
+ return
156
+ self._set_purifier_state(resp_model)
157
+
158
+ async def get_timer(self) -> Timer | None:
159
+ r_bytes = await self.call_bypassv2_api('getTimer')
160
+ resp_model = process_bypassv2_result(
161
+ self, _LOGGER, 'get_timer', r_bytes, ResultV2GetTimer
162
+ )
163
+ if resp_model is None:
164
+ return None
165
+ timers = resp_model.timers
166
+ if not timers:
167
+ _LOGGER.debug('No timers found')
168
+ self.state.timer = None
169
+ return None
170
+ timer = timers[0]
171
+ self.state.timer = Timer(
172
+ timer_duration=timer.total,
173
+ action=timer.action,
174
+ id=timer.id,
175
+ remaining=timer.remain,
176
+ )
177
+ self.state.device_status = DeviceStatus.ON
178
+ self.state.connection_status = ConnectionStatus.ONLINE
179
+ _LOGGER.debug('Timer found: %s', str(self.state.timer))
180
+ return self.state.timer
181
+
182
+ async def set_timer(self, duration: int, action: str | None = None) -> bool:
183
+ action = DeviceStatus.OFF # No other actions available for this device
184
+ if self.state.device_status != DeviceStatus.ON:
185
+ _LOGGER.debug("Can't set timer when device is off")
186
+ payload_data = {'action': str(action), 'total': duration}
187
+ r_dict = await self.call_bypassv2_api('addTimer', payload_data)
188
+ resp_model = process_bypassv2_result(
189
+ self, _LOGGER, 'set_timer', r_dict, ResultV2SetTimer
190
+ )
191
+ if resp_model is None:
192
+ return False
193
+ self.state.timer = Timer(timer_duration=duration, action='off', id=resp_model.id)
194
+ self.state.device_status = DeviceStatus.ON
195
+ self.state.connection_status = ConnectionStatus.ONLINE
196
+ return True
197
+
198
+ async def clear_timer(self) -> bool:
199
+ if self.state.timer is None:
200
+ _LOGGER.debug('No timer to clear, run `get_timer()` to get active timer')
201
+ return False
202
+ payload_data = {'id': self.state.timer.id}
203
+
204
+ r_dict = await self.call_bypassv2_api('delTimer', payload_data)
205
+ r = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict)
206
+ if r is None:
207
+ return False
208
+
209
+ _LOGGER.debug('Timer cleared')
210
+ self.state.timer = None
211
+ self.state.device_status = DeviceStatus.ON
212
+ self.state.connection_status = ConnectionStatus.ONLINE
213
+ return True
214
+
215
+ async def set_auto_preference(self, preference: str, room_size: int = 800) -> bool:
216
+ if self.state.device_status != DeviceStatus.ON:
217
+ _LOGGER.debug("Can't set auto preference when device is off")
218
+ payload_data = {'type': preference, 'room_size': room_size}
219
+ r_dict = await self.call_bypassv2_api('setAutoPreference', payload_data)
220
+ r = Helpers.process_dev_response(_LOGGER, 'set_auto_preference', self, r_dict)
221
+ if r is None:
222
+ return False
223
+ self.state.connection_status = ConnectionStatus.ONLINE
224
+ self.state.auto_preference_type = preference
225
+ self.state.auto_room_size = room_size
226
+ return True
227
+
228
+ async def set_fan_speed(self, speed: None | int = None) -> bool:
229
+ speeds: list = self.fan_levels
230
+ current_speed = self.state.fan_level
231
+
232
+ if speed is not None:
233
+ if speed not in speeds:
234
+ _LOGGER.debug(
235
+ '%s is invalid speed - valid speeds are %s', speed, str(speeds)
236
+ )
237
+ return False
238
+ new_speed = speed
239
+ else:
240
+ new_speed = Helpers.bump_level(current_speed, self.fan_levels)
241
+
242
+ data = {
243
+ 'id': 0,
244
+ 'level': new_speed,
245
+ 'type': 'wind',
246
+ }
247
+ r_dict = await self.call_bypassv2_api('setLevel', data)
248
+ r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict)
249
+ if r is None:
250
+ return False
251
+
252
+ self.state.fan_level = new_speed
253
+ self.state.fan_set_level = new_speed
254
+ self.state.mode = PurifierModes.MANUAL # Set mode to manual to set fan speed
255
+ self.state.device_status = DeviceStatus.ON
256
+ self.state.connection_status = ConnectionStatus.ONLINE
257
+ return True
258
+
259
+ async def toggle_child_lock(self, toggle: bool | None = None) -> bool:
260
+ """Toggle child lock.
261
+
262
+ Set child lock to on or off. Internal method used by `turn_on_child_lock` and
263
+ `turn_off_child_lock`.
264
+
265
+ Args:
266
+ toggle (bool): True to turn child lock on, False to turn off
267
+
268
+ Returns:
269
+ bool : True if child lock is set, False if not
270
+ """
271
+ if toggle is None:
272
+ toggle = self.state.child_lock is False
273
+ data = {'child_lock': toggle}
274
+
275
+ r_dict = await self.call_bypassv2_api('setChildLock', data)
276
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_child_lock', self, r_dict)
277
+ if r is None:
278
+ return False
279
+
280
+ self.state.child_lock = toggle
281
+ self.state.connection_status = ConnectionStatus.ONLINE
282
+ return True
283
+
284
+ async def reset_filter(self) -> bool:
285
+ """Reset filter to 100%.
286
+
287
+ Returns:
288
+ bool : True if filter is reset, False if not
289
+ """
290
+ r_dict = await self.call_bypassv2_api('resetFilter')
291
+ r = Helpers.process_dev_response(_LOGGER, 'reset_filter', self, r_dict)
292
+ return bool(r)
293
+
294
+ @deprecated('Use set_mode(mode: str) instead.')
295
+ async def mode_toggle(self, mode: str) -> bool:
296
+ """Deprecated - Set purifier mode."""
297
+ return await self.set_mode(mode)
298
+
299
+ async def set_mode(self, mode: str) -> bool:
300
+ if mode.lower() not in self.modes:
301
+ _LOGGER.debug('Invalid purifier mode used - %s', mode)
302
+ return False
303
+
304
+ if mode.lower() == PurifierModes.MANUAL:
305
+ return await self.set_fan_speed(self.state.fan_level or 1)
306
+
307
+ data = {
308
+ 'mode': mode,
309
+ }
310
+ r_dict = await self.call_bypassv2_api('setPurifierMode', data)
311
+
312
+ r = Helpers.process_dev_response(_LOGGER, 'set_mode', self, r_dict)
313
+ if r is None:
314
+ return False
315
+
316
+ self.state.mode = mode
317
+ self.state.device_status = DeviceStatus.ON
318
+ self.state.connection_status = ConnectionStatus.ONLINE
319
+ return True
320
+
321
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
322
+ if toggle is None:
323
+ toggle = self.state.device_status != DeviceStatus.ON
324
+ if not isinstance(toggle, bool):
325
+ _LOGGER.debug('Invalid toggle value for purifier switch')
326
+ return False
327
+
328
+ data = {'enabled': toggle, 'id': 0}
329
+ r_dict = await self.call_bypassv2_api('setSwitch', data)
330
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict)
331
+ if r is None:
332
+ return False
333
+
334
+ self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF
335
+ self.state.connection_status = ConnectionStatus.ONLINE
336
+ return True
337
+
338
+ async def toggle_display(self, mode: bool) -> bool:
339
+ if not isinstance(mode, bool):
340
+ _LOGGER.debug('Mode must be True or False')
341
+ return False
342
+
343
+ data = {'state': mode}
344
+ r_dict = await self.call_bypassv2_api('setDisplay', data)
345
+ r = Helpers.process_dev_response(_LOGGER, 'set_display', self, r_dict)
346
+ if r is None:
347
+ return False
348
+ self.state.connection_status = ConnectionStatus.ONLINE
349
+ self.state.display_set_status = DeviceStatus.from_bool(mode)
350
+ return True
351
+
352
+ async def set_nightlight_mode(self, mode: str) -> bool:
353
+ if not self.supports_nightlight:
354
+ _LOGGER.debug('Device does not support night light')
355
+ return False
356
+ if mode.lower() not in self.nightlight_modes:
357
+ _LOGGER.warning('Invalid nightlight mode used (on, off or dim)- %s', mode)
358
+ return False
359
+
360
+ r_dict = await self.call_bypassv2_api(
361
+ 'setNightLight', {'night_light': mode.lower()}
362
+ )
363
+ r = Helpers.process_dev_response(_LOGGER, 'set_night_light', self, r_dict)
364
+ if r is None:
365
+ return False
366
+
367
+ self.state.nightlight_status = mode
368
+ return True
369
+
370
+ @property
371
+ @deprecated('Use self.state.air_quality instead.')
372
+ def air_quality(self) -> int | None:
373
+ """Get air quality value PM2.5 (ug/m3)."""
374
+ return self.state.pm25
375
+
376
+ @property
377
+ @deprecated('Use self.state.fan_level instead.')
378
+ def fan_level(self) -> int | None:
379
+ """Get current fan level."""
380
+ return self.state.fan_level
381
+
382
+ @property
383
+ @deprecated('Use self.state.filter_life instead.')
384
+ def filter_life(self) -> int | None:
385
+ """Get percentage of filter life remaining."""
386
+ return self.state.filter_life
387
+
388
+ @property
389
+ @deprecated('Use self.state.display_status instead.')
390
+ def display_state(self) -> bool:
391
+ """Get display state.
392
+
393
+ See [pyvesync.VeSyncAirBypass.display_status][`self.display_status`]
394
+ """
395
+ return self.state.display_status == DeviceStatus.ON
396
+
397
+ @property
398
+ @deprecated('Use self.state.display_status instead.')
399
+ def screen_status(self) -> bool:
400
+ """Get display status.
401
+
402
+ Returns:
403
+ bool : True if display is on, False if off
404
+ """
405
+ return self.state.display_status == DeviceStatus.ON
406
+
407
+
408
+ class VeSyncAirBaseV2(VeSyncAirBypass):
409
+ """Levoit V2 Air Purifier Class.
410
+
411
+ Handles the Vital 100S/200S and Sprout Air Purifiers. The
412
+ Sprout purifier has a separate class
413
+ [VeSyncAirSprout][pyvesync.devices.vesyncpurifier.VeSyncAirSprout]
414
+ that overrides the `_set_state` method. Inherits from VeSyncAirBypass
415
+ and VeSyncBaseDevice class. For newer devices that use camel-case API calls.
416
+
417
+ Args:
418
+ details (dict): Dictionary of device details
419
+ manager (VeSync): Instantiated VeSync object
420
+ feature_map (PurifierMap): Device map template
421
+
422
+ Attributes:
423
+ state (PurifierState): State of the device.
424
+ last_response (ResponseInfo): Last response from API call.
425
+ manager (VeSync): Manager object for API calls.
426
+ device_name (str): Name of device.
427
+ device_image (str): URL for device image.
428
+ cid (str): Device ID.
429
+ connection_type (str): Connection type of device.
430
+ device_type (str): Type of device.
431
+ type (str): Type of device.
432
+ uuid (str): UUID of device, not always present.
433
+ config_module (str): Configuration module of device.
434
+ mac_id (str): MAC ID of device.
435
+ current_firm_version (str): Current firmware version of device.
436
+ device_region (str): Region of device. (US, EU, etc.)
437
+ pid (str): Product ID of device, pulled by some devices on update.
438
+ sub_device_no (int): Sub-device number of device.
439
+ product_type (str): Product type of device.
440
+ features (dict): Features of device.
441
+ modes (list[str]): List of modes supported by the device.
442
+ fan_levels (list[int]): List of fan levels supported by the device.
443
+ nightlight_modes (list[str]): List of nightlight modes supported by the device.
444
+ auto_preferences (list[str]): List of auto preferences supported by the device.
445
+ """
446
+
447
+ __slots__ = ()
448
+
449
+ def __init__(
450
+ self,
451
+ details: ResponseDeviceDetailsModel,
452
+ manager: VeSync,
453
+ feature_map: PurifierMap,
454
+ ) -> None:
455
+ """Initialize the VeSync Base API V2 Air Purifier Class."""
456
+ super().__init__(details, manager, feature_map)
457
+
458
+ def _set_state(self, details: InnerPurifierBaseResult) -> None:
459
+ """Set Purifier state from details response."""
460
+ if not isinstance(details, PurifierVitalDetailsResult):
461
+ _LOGGER.debug('Invalid details model passed to _set_state')
462
+ return
463
+ self.state.connection_status = ConnectionStatus.ONLINE
464
+ self.state.device_status = DeviceStatus.from_int(details.powerSwitch)
465
+ self.state.mode = details.workMode
466
+ self.state.filter_life = details.filterLifePercent
467
+ if details.fanSpeedLevel == 255: # noqa: PLR2004
468
+ self.state.fan_level = 0
469
+ else:
470
+ self.state.fan_level = details.fanSpeedLevel
471
+ self.state.fan_set_level = details.manualSpeedLevel
472
+ self.state.child_lock = bool(details.childLockSwitch)
473
+ self.state.air_quality_level = details.AQLevel
474
+ self.state.pm25 = details.PM25
475
+ self.state.light_detection_switch = DeviceStatus.from_int(
476
+ details.lightDetectionSwitch
477
+ )
478
+ self.state.light_detection_status = DeviceStatus.from_int(
479
+ details.environmentLightState
480
+ )
481
+ self.state.display_set_status = DeviceStatus.from_int(details.screenSwitch)
482
+ self.state.display_status = DeviceStatus.from_int(details.screenState)
483
+ auto_pref = details.autoPreference
484
+ if auto_pref is not None:
485
+ self.state.auto_preference_type = auto_pref.autoPreferenceType
486
+ self.state.auto_room_size = auto_pref.roomSize
487
+
488
+ self.state.pm1 = details.PM1
489
+ self.state.pm10 = details.PM10
490
+ self.state.aq_percent = details.AQPercent
491
+ self.state.fan_rotate_angle = details.fanRotateAngle
492
+ if details.filterOpenState is not None:
493
+ self.state.filter_open_state = bool(details.filterOpenState)
494
+ if details.timerRemain > 0:
495
+ self.state.timer = Timer(details.timerRemain, 'off')
496
+
497
+ @property
498
+ @deprecated('Use self.state.fan_set_level instead.')
499
+ def set_speed_level(self) -> int | None:
500
+ """Get set speed level."""
501
+ return self.state.fan_set_level
502
+
503
+ @property
504
+ @deprecated("Use self.state.light_detection_switch, this returns 'on' or 'off")
505
+ def light_detection(self) -> bool:
506
+ """Return true if light detection feature is enabled."""
507
+ return self.state.light_detection_switch == DeviceStatus.ON
508
+
509
+ @property
510
+ @deprecated("Use self.state.light_detection_status, this returns 'on' or 'off'")
511
+ def light_detection_state(self) -> bool:
512
+ """Return true if light is detected."""
513
+ return self.state.light_detection_status == DeviceStatus.ON
514
+
515
+ async def get_details(self) -> None:
516
+ """Build API V2 Purifier details dictionary."""
517
+ r_dict = await self.call_bypassv2_api('getPurifierStatus')
518
+ r_model = process_bypassv2_result(
519
+ self, _LOGGER, 'get_details', r_dict, PurifierVitalDetailsResult
520
+ )
521
+ if r_model is None:
522
+ return
523
+
524
+ self._set_state(r_model)
525
+
526
+ @deprecated('Use toggle_light_detection(toggle) instead.')
527
+ async def set_light_detection(self, toggle: bool) -> bool:
528
+ """Set light detection feature."""
529
+ return await self.toggle_light_detection(toggle)
530
+
531
+ async def toggle_light_detection(self, toggle: bool | None = None) -> bool:
532
+ """Enable/Disable Light Detection Feature."""
533
+ if bool(self.state.light_detection_status) == toggle:
534
+ _LOGGER.debug(
535
+ 'Light detection is already %s', self.state.light_detection_status
536
+ )
537
+ return True
538
+
539
+ if toggle is None:
540
+ toggle = not bool(self.state.light_detection_status)
541
+ payload_data = {'lightDetectionSwitch': int(toggle)}
542
+ r_dict = await self.call_bypassv2_api('setLightDetection', payload_data)
543
+ r = Helpers.process_dev_response(_LOGGER, 'set_light_detection', self, r_dict)
544
+ if r is None:
545
+ return False
546
+
547
+ self.state.light_detection_switch = DeviceStatus.from_bool(toggle)
548
+ self.state.connection_status = ConnectionStatus.ONLINE
549
+ return True
550
+
551
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
552
+ if toggle is None:
553
+ toggle = not bool(self.state.device_status)
554
+ if not isinstance(toggle, bool):
555
+ _LOGGER.debug('Invalid toggle value for purifier switch')
556
+ return False
557
+ if toggle == bool(self.state.device_status):
558
+ _LOGGER.debug('Purifier is already %s', self.state.device_status)
559
+ return True
560
+
561
+ payload_data = {'powerSwitch': int(toggle), 'switchIdx': 0}
562
+ r_dict = await self.call_bypassv2_api('setSwitch', payload_data)
563
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict)
564
+ if r is None:
565
+ return False
566
+
567
+ self.state.device_status = DeviceStatus.from_bool(toggle)
568
+ self.state.connection_status = ConnectionStatus.ONLINE
569
+ return True
570
+
571
+ async def toggle_child_lock(self, toggle: bool | None = None) -> bool:
572
+ """Levoit 100S/200S set Child Lock.
573
+
574
+ Parameters:
575
+ toggle (bool): True to turn child lock on, False to turn off
576
+
577
+ Returns:
578
+ bool : True if successful, False if not
579
+ """
580
+ if toggle is None:
581
+ toggle = not bool(self.state.child_lock)
582
+ payload_data = {'childLockSwitch': int(toggle)}
583
+ r_dict = await self.call_bypassv2_api('setChildLock', payload_data)
584
+
585
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_child_lock', self, r_dict)
586
+ if r is None:
587
+ return False
588
+
589
+ self.state.child_lock = toggle
590
+ self.state.connection_status = ConnectionStatus.ONLINE
591
+ return True
592
+
593
+ async def toggle_display(self, mode: bool) -> bool:
594
+ if bool(self.state.light_detection_status):
595
+ _LOGGER.error('Cannot set display when light detection is enabled')
596
+ return False
597
+
598
+ if bool(self.state.display_set_status) == mode:
599
+ _LOGGER.debug('Display is already %s', mode)
600
+ return True
601
+
602
+ payload_data = {'screenSwitch': int(mode)}
603
+ r_dict = await self.call_bypassv2_api('setDisplay', payload_data)
604
+
605
+ r = Helpers.process_dev_response(_LOGGER, 'set_display', self, r_dict)
606
+ if r is None:
607
+ return False
608
+
609
+ self.state.display_set_status = DeviceStatus.from_bool(mode)
610
+ self.state.connection_status = ConnectionStatus.ONLINE
611
+ return True
612
+
613
+ async def set_timer(self, duration: int, action: str | None = None) -> bool:
614
+ action = DeviceStatus.OFF # No other actions available for this device
615
+ if action not in [DeviceStatus.ON, DeviceStatus.OFF]:
616
+ _LOGGER.debug('Invalid action for timer')
617
+ return False
618
+
619
+ method = 'powerSwitch'
620
+ action_int = 1 if action == DeviceStatus.ON else 0
621
+ action_item = PurifierV2TimerActionItems(type=method, act=action_int)
622
+ timing = PurifierV2EventTiming(clkSec=duration)
623
+ payload_data = PurifierV2TimerPayloadData(
624
+ enabled=True,
625
+ startAct=[action_item],
626
+ tmgEvt=timing,
627
+ )
628
+
629
+ r_dict = await self.call_bypassv2_api('addTimerV2', payload_data.to_dict())
630
+ r = Helpers.process_dev_response(_LOGGER, 'set_timer', self, r_dict)
631
+ if r is None:
632
+ return False
633
+
634
+ r_model = ResultV2SetTimer.from_dict(r)
635
+
636
+ self.state.timer = Timer(duration, action=action, id=r_model.id)
637
+ self.state.connection_status = ConnectionStatus.ONLINE
638
+ return True
639
+
640
+ async def clear_timer(self) -> bool:
641
+ if self.state.timer is None:
642
+ _LOGGER.warning('No timer found, run get_timer() to retrieve timer.')
643
+ return False
644
+
645
+ payload_data = {'id': self.state.timer.id, 'subDeviceNo': 0}
646
+ r_dict = await self.call_bypassv2_api('delTimerV2', payload_data)
647
+ r = Helpers.process_dev_response(_LOGGER, 'clear_timer', self, r_dict)
648
+ if r is None:
649
+ return False
650
+
651
+ self.state.timer = None
652
+ self.state.connection_status = ConnectionStatus.ONLINE
653
+ return True
654
+
655
+ async def set_auto_preference(
656
+ self, preference: str = PurifierAutoPreference.DEFAULT, room_size: int = 600
657
+ ) -> bool:
658
+ """Set Levoit Vital 100S/200S auto mode.
659
+
660
+ Parameters:
661
+ preference (str | None | PurifierAutoPreference):
662
+ Preference for auto mode, default 'default' (default, efficient, quiet)
663
+ room_size (int | None):
664
+ Room size in square feet, by default 600
665
+ """
666
+ if preference not in self.auto_preferences:
667
+ _LOGGER.debug(
668
+ '%s is invalid preference -'
669
+ ' valid preferences are default, efficient, quiet',
670
+ preference,
671
+ )
672
+ return False
673
+ payload_data = {'autoPreference': preference, 'roomSize': room_size}
674
+ r_dict = await self.call_bypassv2_api('setAutoPreference', payload_data)
675
+ r = Helpers.process_dev_response(_LOGGER, 'set_auto_preference', self, r_dict)
676
+ if r is None:
677
+ return False
678
+
679
+ self.state.auto_preference_type = preference
680
+ self.state.auto_room_size = room_size
681
+ self.state.connection_status = ConnectionStatus.ONLINE
682
+ return True
683
+
684
+ async def set_fan_speed(self, speed: None | int = None) -> bool:
685
+ if speed is not None:
686
+ if speed not in self.fan_levels:
687
+ _LOGGER.debug(
688
+ '%s is invalid speed - valid speeds are %s',
689
+ speed,
690
+ str(self.fan_levels),
691
+ )
692
+ return False
693
+ new_speed = speed
694
+ elif self.state.fan_level is None:
695
+ new_speed = self.fan_levels[0]
696
+ else:
697
+ new_speed = Helpers.bump_level(self.state.fan_level, self.fan_levels)
698
+
699
+ payload_data = {'levelIdx': 0, 'manualSpeedLevel': new_speed, 'levelType': 'wind'}
700
+ r_dict = await self.call_bypassv2_api('setLevel', payload_data)
701
+ r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict)
702
+ if r is None:
703
+ return False
704
+
705
+ self.state.fan_set_level = new_speed
706
+ self.state.fan_level = new_speed
707
+ self.state.mode = PurifierModes.MANUAL
708
+ self.state.device_status = DeviceStatus.ON
709
+ self.state.connection_status = ConnectionStatus.ONLINE
710
+ return True
711
+
712
+ async def set_mode(self, mode: str) -> bool:
713
+ if mode.lower() not in self.modes:
714
+ _LOGGER.debug('Invalid purifier mode used - %s', mode)
715
+ return False
716
+
717
+ # Call change_fan_speed if mode is set to manual
718
+ if mode == PurifierModes.MANUAL:
719
+ if self.state.fan_set_level is None or self.state.fan_level == 0:
720
+ return await self.set_fan_speed(1)
721
+ return await self.set_fan_speed(self.state.fan_set_level)
722
+
723
+ payload_data = {'workMode': mode}
724
+ r_dict = await self.call_bypassv2_api('setPurifierMode', payload_data)
725
+ r = Helpers.process_dev_response(_LOGGER, 'mode_toggle', self, r_dict)
726
+ if r is None:
727
+ return False
728
+
729
+ self.state.mode = mode
730
+ self.state.connection_status = ConnectionStatus.ONLINE
731
+ self.state.device_status = DeviceStatus.ON
732
+ return True
733
+
734
+
735
+ class VeSyncAirSprout(VeSyncAirBaseV2): # pylint: disable=too-many-ancestors
736
+ """Class for the Sprout Air Purifier.
737
+
738
+ Inherits from VeSyncAirBaseV2 class and overrides
739
+ the _set_state method. See the
740
+ [VeSyncAirBaseV2][pyvesync.devices.vesyncpurifier.VeSyncAirBaseV2]
741
+ class for more information.
742
+
743
+ Args:
744
+ details (dict): Dictionary of device details
745
+ manager (VeSync): Instantiated VeSync object
746
+ feature_map (PurifierMap): Device map template
747
+
748
+ Attributes:
749
+ state (PurifierState): State of the device.
750
+ last_response (ResponseInfo): Last response from API call.
751
+ manager (VeSync): Manager object for API calls.
752
+ device_name (str): Name of device.
753
+ device_image (str): URL for device image.
754
+ cid (str): Device ID.
755
+ connection_type (str): Connection type of device.
756
+ device_type (str): Type of device.
757
+ type (str): Type of device.
758
+ uuid (str): UUID of device, not always present.
759
+ config_module (str): Configuration module of device.
760
+ mac_id (str): MAC ID of device.
761
+ current_firm_version (str): Current firmware version of device.
762
+ device_region (str): Region of device. (US, EU, etc.)
763
+ pid (str): Product ID of device, pulled by some devices on update.
764
+ sub_device_no (int): Sub-device number of device.
765
+ product_type (str): Product type of device.
766
+ features (dict): Features of device.
767
+ modes (list[str]): List of modes supported by the device.
768
+ fan_levels (list[int]): List of fan levels supported by the device.
769
+ nightlight_modes (list[str]): List of nightlight modes supported by the device.
770
+ auto_preferences (list[str]): List of auto preferences supported by the device.
771
+ """
772
+
773
+ def __init__(
774
+ self,
775
+ details: ResponseDeviceDetailsModel,
776
+ manager: VeSync,
777
+ feature_map: PurifierMap,
778
+ ) -> None:
779
+ """Initialize air purifier class."""
780
+ super().__init__(details, manager, feature_map)
781
+
782
+ def _set_state(self, details: InnerPurifierBaseResult) -> None:
783
+ """Set Purifier state from details response."""
784
+ if not isinstance(details, PurifierSproutResult):
785
+ _LOGGER.debug('Invalid details model passed to _set_state')
786
+ return
787
+ self.state.connection_status = ConnectionStatus.ONLINE
788
+ self.state.device_status = DeviceStatus.from_int(details.powerSwitch)
789
+ self.state.mode = details.workMode
790
+ if details.fanSpeedLevel == 255: # noqa: PLR2004
791
+ self.state.fan_level = 0
792
+ else:
793
+ self.state.fan_level = details.fanSpeedLevel
794
+ self.state.fan_set_level = details.manualSpeedLevel
795
+ self.state.child_lock = bool(details.childLockSwitch)
796
+ self.state.air_quality_level = details.AQLevel
797
+ self.state.pm25 = details.PM25
798
+ self.state.pm1 = details.PM1
799
+ self.state.pm10 = details.PM10
800
+ self.state.aq_percent = details.AQI
801
+ self.state.display_set_status = DeviceStatus.from_int(details.screenSwitch)
802
+ self.state.display_status = DeviceStatus.from_int(details.screenState)
803
+ auto_pref = details.autoPreference
804
+ if auto_pref is not None:
805
+ self.state.auto_preference_type = auto_pref.autoPreferenceType
806
+ self.state.auto_room_size = auto_pref.roomSize
807
+ self.state.humidity = details.humidity
808
+ self.state.temperature = int((details.temperature or 0) / 10)
809
+ self.state.pm1 = details.PM1
810
+ self.state.pm10 = details.PM10
811
+ self.state.pm25 = details.PM25
812
+ self.state.voc = details.VOC
813
+ self.state.co2 = details.CO2
814
+ if details.nightlight is not None:
815
+ self.state.nightlight_status = DeviceStatus.from_int(
816
+ details.nightlight.nightLightSwitch
817
+ )
818
+ self.state.nightlight_brightness = details.nightlight.brightness
819
+
820
+ async def get_details(self) -> None:
821
+ """Build API V2 Purifier details dictionary."""
822
+ r_dict = await self.call_bypassv2_api('getPurifierStatus')
823
+ r_model = process_bypassv2_result(
824
+ self, _LOGGER, 'get_details', r_dict, PurifierSproutResult
825
+ )
826
+ if r_model is None:
827
+ return
828
+
829
+ self._set_state(r_model)
830
+
831
+
832
+ class VeSyncAir131(BypassV1Mixin, VeSyncPurifier):
833
+ """Levoit Air Purifier Class.
834
+
835
+ Class for LV-PUR131S, using BypassV1 API.
836
+
837
+ Attributes:
838
+ state (PurifierState): State of the device.
839
+ last_response (ResponseInfo): Last response from API call.
840
+ manager (VeSync): Manager object for API calls.
841
+ device_name (str): Name of device.
842
+ device_image (str): URL for device image.
843
+ cid (str): Device ID.
844
+ connection_type (str): Connection type of device.
845
+ device_type (str): Type of device.
846
+ type (str): Type of device.
847
+ uuid (str): UUID of device, not always present.
848
+ config_module (str): Configuration module of device.
849
+ mac_id (str): MAC ID of device.
850
+ current_firm_version (str): Current firmware version of device.
851
+ device_region (str): Region of device. (US, EU, etc.)
852
+ pid (str): Product ID of device, pulled by some devices on update.
853
+ sub_device_no (int): Sub-device number of device.
854
+ product_type (str): Product type of device.
855
+ features (dict): Features of device.
856
+ modes (list[str]): List of modes supported by the device.
857
+ fan_levels (list[int]): List of fan levels supported by the device.
858
+ nightlight_modes (list[str]): List of nightlight modes supported by the device.
859
+ auto_preferences (list[str]): List of auto preferences supported by the device.
860
+ """
861
+
862
+ __slots__ = ()
863
+
864
+ def __init__(
865
+ self,
866
+ details: ResponseDeviceDetailsModel,
867
+ manager: VeSync,
868
+ feature_map: PurifierMap,
869
+ ) -> None:
870
+ """Initialize air purifier class."""
871
+ super().__init__(details, manager, feature_map)
872
+
873
+ def _set_state(self, details: Purifier131Result) -> None:
874
+ """Set state from purifier API get_details() response."""
875
+ self.state.device_status = details.deviceStatus
876
+ self.state.connection_status = details.connectionStatus
877
+ self.state.active_time = details.activeTime
878
+ self.state.filter_life = details.filterLife.percent
879
+ self.state.display_status = DeviceStatus(details.screenStatus)
880
+ self.state.display_set_status = details.screenStatus
881
+ self.state.child_lock = bool(DeviceStatus(details.childLock))
882
+ self.state.mode = details.mode
883
+ self.state.fan_level = details.level or 0
884
+ self.state.fan_set_level = details.level or 0
885
+ self.state.set_air_quality_level(details.airQuality)
886
+
887
+ async def get_details(self) -> None:
888
+ r_dict = await self.call_bypassv1_api(
889
+ RequestPurifier131, method='deviceDetail', endpoint='deviceDetail'
890
+ )
891
+ r = Helpers.process_dev_response(_LOGGER, 'get_details', self, r_dict)
892
+ if r is None:
893
+ return
894
+
895
+ r_model = Purifier131Result.from_dict(r.get('result', {}))
896
+ self._set_state(r_model)
897
+
898
+ async def toggle_display(self, mode: bool) -> bool:
899
+ update_dict = {'status': 'on' if mode else 'off'}
900
+ r_dict = await self.call_bypassv1_api(
901
+ RequestPurifier131,
902
+ method='airPurifierScreenCtl',
903
+ endpoint='airPurifierScreenCtl',
904
+ update_dict=update_dict,
905
+ )
906
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_display', self, r_dict)
907
+ if r is None:
908
+ return False
909
+
910
+ self.state.display_set_status = DeviceStatus.from_bool(mode)
911
+ self.state.display_status = DeviceStatus.from_bool(mode)
912
+ self.state.connection_status = ConnectionStatus.ONLINE
913
+ return True
914
+
915
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
916
+ if toggle is None:
917
+ toggle = self.state.device_status != DeviceStatus.ON
918
+
919
+ update_dict = {'status': DeviceStatus.from_bool(toggle).value}
920
+ r_dict = await self.call_bypassv1_api(
921
+ RequestPurifier131,
922
+ method='airPurifierPowerSwitchCtl',
923
+ endpoint='airPurifierPowerSwitchCtl',
924
+ update_dict=update_dict,
925
+ )
926
+ r = Helpers.process_dev_response(_LOGGER, 'toggle_switch', self, r_dict)
927
+ if r is None:
928
+ return False
929
+
930
+ self.state.device_status = DeviceStatus.from_bool(toggle)
931
+ self.state.connection_status = ConnectionStatus.ONLINE
932
+ return True
933
+
934
+ async def set_fan_speed(self, speed: int | None = None) -> bool:
935
+ current_speed = self.state.fan_set_level or 0
936
+
937
+ if speed is not None:
938
+ if speed not in self.fan_levels:
939
+ _LOGGER.debug(
940
+ '%s is invalid speed - valid speeds are %s',
941
+ speed,
942
+ str(self.fan_levels),
943
+ )
944
+ return False
945
+ new_speed = speed
946
+ else:
947
+ new_speed = Helpers.bump_level(current_speed, self.fan_levels)
948
+
949
+ update_dict = {'level': new_speed}
950
+ r_dict = await self.call_bypassv1_api(
951
+ RequestPurifier131Level,
952
+ method='airPurifierSpeedCtl',
953
+ endpoint='airPurifierSpeedCtl',
954
+ update_dict=update_dict,
955
+ )
956
+ r = Helpers.process_dev_response(_LOGGER, 'set_fan_speed', self, r_dict)
957
+ if r is None:
958
+ return False
959
+
960
+ self.state.fan_level = new_speed
961
+ self.state.fan_set_level = new_speed
962
+ self.state.connection_status = 'online'
963
+ self.state.mode = PurifierModes.MANUAL
964
+ return True
965
+
966
+ async def set_mode(self, mode: str) -> bool:
967
+ if mode not in self.modes:
968
+ _LOGGER.debug('Invalid purifier mode used - %s', mode)
969
+ return False
970
+
971
+ if mode == PurifierModes.MANUAL:
972
+ set_level = (
973
+ 1 if self.state.fan_set_level in [0, None] else self.state.fan_set_level
974
+ )
975
+ return await self.set_fan_speed(set_level)
976
+
977
+ update_dict = {'mode': mode}
978
+ r_dict = await self.call_bypassv1_api(
979
+ RequestPurifier131Mode,
980
+ method='airPurifierRunModeCtl',
981
+ endpoint='airPurifierRunModeCtl',
982
+ update_dict=update_dict,
983
+ )
984
+ r = Helpers.process_dev_response(_LOGGER, 'mode_toggle', self, r_dict)
985
+ if r is None:
986
+ return False
987
+
988
+ self.state.mode = mode
989
+ self.state.connection_status = ConnectionStatus.ONLINE
990
+ return True