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,1126 @@
1
+ """Etekcity/Valceno Smart Light Bulbs.
2
+
3
+ This module provides classes for the following Etekcity/Valceno smart lights:
4
+
5
+ 1. ESL100: Dimmable Bulb
6
+ 2. ESL100CW: Tunable White Bulb
7
+ 3. XYD0001: RGB Bulb
8
+ 4. ESL100MC: Multi-Color Bulb
9
+
10
+ The classes all inherit from VeSyncBulb, which is a subclass of VeSyncBaseDevice and
11
+ [VeSyncToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice].
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Mapping
18
+ from typing import TYPE_CHECKING
19
+
20
+ from typing_extensions import deprecated
21
+
22
+ from pyvesync.base_devices import VeSyncBulb
23
+ from pyvesync.const import ConnectionStatus, DeviceStatus
24
+ from pyvesync.models import bulb_models
25
+ from pyvesync.models.base_models import DefaultValues
26
+ from pyvesync.models.bypass_models import TimerModels
27
+ from pyvesync.utils.colors import Color
28
+ from pyvesync.utils.device_mixins import (
29
+ BypassV1Mixin,
30
+ BypassV2Mixin,
31
+ process_bypassv1_result,
32
+ process_bypassv2_result,
33
+ )
34
+ from pyvesync.utils.helpers import Helpers, Timer, Validators
35
+
36
+ if TYPE_CHECKING:
37
+ from pyvesync import VeSync
38
+ from pyvesync.device_map import BulbMap
39
+ from pyvesync.models.vesync_models import ResponseDeviceDetailsModel
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ NUMERIC_OPT = int | float | str | None
44
+ NUMERIC_STRICT = float | int | str
45
+
46
+
47
+ class VeSyncBulbESL100MC(BypassV2Mixin, VeSyncBulb):
48
+ """Etekcity ESL100 Multi Color Bulb device.
49
+
50
+ Inherits from [VeSyncBulb][pyvesync.base_devices.bulb_base.VeSyncBulb]
51
+ and [VeSyncBaseDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice].
52
+
53
+ The state of the bulb is stored in the `state` attribute, which is an of
54
+ [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute
55
+ contains all settable states for the bulb.
56
+
57
+ Args:
58
+ details (dict): Dictionary of bulb state details.
59
+ manager (VeSync): Manager class used to make API calls.
60
+ feature_map (BulbMap): Device configuration map.
61
+
62
+ Attributes:
63
+ state (BulbState): Device state object
64
+ Each device has a separate state base class in the base_devices module.
65
+ last_response (ResponseInfo): Last response from API call.
66
+ manager (VeSync): Manager object for API calls.
67
+ device_name (str): Name of device.
68
+ device_image (str): URL for device image.
69
+ cid (str): Device ID.
70
+ connection_type (str): Connection type of device.
71
+ device_type (str): Type of device.
72
+ type (str): Type of device.
73
+ uuid (str): UUID of device, not always present.
74
+ config_module (str): Configuration module of device.
75
+ mac_id (str): MAC ID of device.
76
+ current_firm_version (str): Current firmware version of device.
77
+ device_region (str): Region of device. (US, EU, etc.)
78
+ pid (str): Product ID of device, pulled by some devices on update.
79
+ sub_device_no (int): Sub-device number of device.
80
+ product_type (str): Product type of device.
81
+ features (dict): Features of device.
82
+
83
+ Notes:
84
+ The details dictionary contains the device information retreived by the
85
+ `update()` method:
86
+ ```python
87
+ details = {
88
+ 'brightness': 50,
89
+ 'colorMode': 'rgb',
90
+ 'color' : Color(red=0, green=0, blue=0)
91
+ }
92
+ ```
93
+ See pyvesync.helpers.color.Color for more information on the Color dataclass.
94
+ """
95
+
96
+ __slots__ = ()
97
+
98
+ def __init__(
99
+ self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap
100
+ ) -> None:
101
+ """Instantiate ESL100MC Multicolor Bulb."""
102
+ super().__init__(details, manager, feature_map)
103
+
104
+ async def get_details(self) -> None:
105
+ r_dict = await self.call_bypassv2_api('getLightStatus')
106
+
107
+ result_model = process_bypassv2_result(
108
+ self, logger, 'get_details', r_dict, bulb_models.ResponseESL100MCResult
109
+ )
110
+ if result_model is None:
111
+ return
112
+ self._set_state(result_model)
113
+ return
114
+
115
+ def _set_state(self, response: bulb_models.ResponseESL100MCResult) -> None:
116
+ """Build detail dictionary from response."""
117
+ self.state.brightness = response.brightness
118
+ self.state.color_mode = response.colorMode
119
+ self.state.color = Color.from_rgb(
120
+ red=response.red, green=response.green, blue=response.blue
121
+ )
122
+
123
+ async def set_brightness(self, brightness: int) -> bool:
124
+ return await self.set_status(brightness=brightness)
125
+
126
+ async def set_rgb(self, red: float, green: float, blue: float) -> bool:
127
+ return await self.set_status(red=red, green=green, blue=blue)
128
+
129
+ async def set_hsv(self, hue: float, saturation: float, value: float) -> bool:
130
+ hsv = Color.from_hsv(hue=hue, saturation=saturation, value=value)
131
+ if hsv is not None:
132
+ return await self.set_status(
133
+ red=hsv.rgb.red, green=hsv.rgb.green, blue=hsv.rgb.blue
134
+ )
135
+ logger.debug('Invalid HSV values')
136
+ return False
137
+
138
+ async def set_white_mode(self) -> bool:
139
+ return await self.set_status(brightness=100)
140
+
141
+ async def set_status(
142
+ self,
143
+ brightness: float | None = None,
144
+ red: float | None = None,
145
+ green: float | None = None,
146
+ blue: float | None = None,
147
+ ) -> bool:
148
+ """Set color of VeSync ESL100MC.
149
+
150
+ Brightness or RGB values must be provided. If RGB values are provided,
151
+ brightness is ignored.
152
+
153
+ Args:
154
+ brightness (float | None): Brightness of bulb (0-100).
155
+ red (float | None): Red value of RGB color, 0-255.
156
+ green (float | None): Green value of RGB color, 0-255.
157
+ blue (float | None): Blue value of RGB color, 0-255.
158
+
159
+ Returns:
160
+ bool: True if successful, False otherwise.
161
+ """
162
+ brightness_update = 100
163
+ if red is not None and green is not None and blue is not None:
164
+ new_color = Color.from_rgb(red, green, blue)
165
+ color_mode = 'color'
166
+ if (
167
+ self.state.device_status == DeviceStatus.ON
168
+ and new_color == self.state.color
169
+ ):
170
+ logger.debug('New color is same as current color')
171
+ return True
172
+ else:
173
+ logger.debug('RGB Values not provided')
174
+ new_color = None
175
+ if brightness is not None:
176
+ if Validators.validate_zero_to_hundred(brightness):
177
+ brightness_update = int(brightness)
178
+ else:
179
+ logger.debug('Invalid brightness value')
180
+ return False
181
+ if (
182
+ self.state.device_status == DeviceStatus.ON
183
+ and brightness_update == self.state.brightness
184
+ ):
185
+ logger.debug('Brightness already set to %s', brightness)
186
+ return True
187
+ color_mode = 'white'
188
+ else:
189
+ logger.debug('Brightness and RGB values are not set')
190
+ return False
191
+
192
+ data = {
193
+ 'action': DeviceStatus.ON,
194
+ 'speed': 0,
195
+ 'brightness': brightness_update,
196
+ 'red': 0 if new_color is None else int(new_color.rgb.red),
197
+ 'green': 0 if new_color is None else int(new_color.rgb.green),
198
+ 'blue': 0 if new_color is None else int(new_color.rgb.blue),
199
+ 'colorMode': 'color' if new_color is not None else 'white',
200
+ }
201
+ r_dict = await self.call_bypassv2_api('setLightStatus', data)
202
+
203
+ r = Helpers.process_dev_response(logger, 'set_status', self, r_dict)
204
+ if r is None:
205
+ return False
206
+
207
+ if color_mode == 'color' and new_color is not None:
208
+ self.state.color_mode = 'color'
209
+ self.state.color = new_color
210
+ elif brightness is not None:
211
+ self.state.brightness = int(brightness_update)
212
+ self.state.color_mode = 'white'
213
+
214
+ self.state.device_status = DeviceStatus.ON
215
+ self.state.connection_status = ConnectionStatus.ONLINE
216
+ return True
217
+
218
+ @deprecated(
219
+ 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead'
220
+ )
221
+ async def toggle(self, status: str) -> bool:
222
+ """Toggle switch of VeSync ESL100MC."""
223
+ status_bool = status == DeviceStatus.ON
224
+ return await self.toggle_switch(status_bool)
225
+
226
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
227
+ if toggle is None:
228
+ toggle = self.state.device_status == DeviceStatus.OFF
229
+ data = {'id': 0, 'enabled': toggle}
230
+
231
+ r_dict = await self.call_bypassv2_api('setSwitch', data)
232
+
233
+ r = Helpers.process_dev_response(logger, 'toggle', self, r_dict)
234
+ if r is None:
235
+ return False
236
+
237
+ self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF
238
+ return True
239
+
240
+
241
+ class VeSyncBulbESL100(BypassV1Mixin, VeSyncBulb):
242
+ """Object to hold VeSync ESL100 light bulb.
243
+
244
+ Device state is held in the `state` attribute, which is an instance of
245
+ [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute
246
+ contains all settable states for the bulb.
247
+
248
+ This bulb only has the dimmable feature. Inherits from
249
+ [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and
250
+ [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice].
251
+
252
+ Args:
253
+ details (dict): Dictionary of bulb state details.
254
+ manager (VeSync): Manager class used to make API calls
255
+ feature_map (BulbMap): Device configuration map.
256
+
257
+ Attributes:
258
+ state (BulbState): Device state object
259
+ Each device has a separate state base class in the base_devices module.
260
+ last_response (ResponseInfo): Last response from API call.
261
+ manager (VeSync): Manager object for API calls.
262
+ device_name (str): Name of device.
263
+ device_image (str): URL for device image.
264
+ cid (str): Device ID.
265
+ connection_type (str): Connection type of device.
266
+ device_type (str): Type of device.
267
+ type (str): Type of device.
268
+ uuid (str): UUID of device, not always present.
269
+ config_module (str): Configuration module of device.
270
+ mac_id (str): MAC ID of device.
271
+ current_firm_version (str): Current firmware version of device.
272
+ device_region (str): Region of device. (US, EU, etc.)
273
+ pid (str): Product ID of device, pulled by some devices on update.
274
+ sub_device_no (int): Sub-device number of device.
275
+ product_type (str): Product type of device.
276
+ features (dict): Features of device.
277
+ """
278
+
279
+ __slots__ = ()
280
+
281
+ def __init__(
282
+ self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap
283
+ ) -> None:
284
+ """Initialize Etekcity ESL100 Dimmable Bulb."""
285
+ super().__init__(details, manager, feature_map)
286
+
287
+ async def get_details(self) -> None:
288
+ method_dict = {
289
+ 'method': 'deviceDetail',
290
+ }
291
+
292
+ r_dict = await self.call_bypassv1_api(
293
+ bulb_models.RequestESL100Detail, method_dict, 'deviceDetail', 'deviceDetail'
294
+ )
295
+ model = process_bypassv1_result(
296
+ self, logger, 'get_details', r_dict, bulb_models.ResponseESL100DetailResult
297
+ )
298
+
299
+ if model is None:
300
+ self.state.connection_status = ConnectionStatus.OFFLINE
301
+ return
302
+ self.state.brightness = model.brightness
303
+ self.state.device_status = model.deviceStatus
304
+ self.state.connection_status = model.connectionStatus
305
+
306
+ @deprecated(
307
+ 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead'
308
+ )
309
+ async def toggle(self, status: str) -> bool:
310
+ """Toggle switch of ESL100 bulb."""
311
+ status_bool = status != DeviceStatus.ON
312
+ return await self.toggle_switch(status_bool)
313
+
314
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
315
+ if toggle is None:
316
+ toggle = self.state.device_status != DeviceStatus.ON
317
+ status = DeviceStatus.ON if toggle else DeviceStatus.OFF
318
+ method_dict = {
319
+ 'status': status,
320
+ }
321
+ r_dict = await self.call_bypassv1_api(
322
+ bulb_models.RequestESL100Status,
323
+ method_dict,
324
+ 'smartBulbPowerSwitchCtl',
325
+ 'smartBulbPowerSwitchCtl',
326
+ )
327
+
328
+ r = Helpers.process_dev_response(logger, 'toggle', self, r_dict)
329
+ if r is None:
330
+ return False
331
+
332
+ self.state.device_status = status
333
+ return True
334
+
335
+ @deprecated('Use set_brightness() instead')
336
+ async def set_status(self, brightness: int) -> bool:
337
+ """Set brightness of dimmable bulb.
338
+
339
+ Args:
340
+ brightness (int): Brightness of bulb (0-100).
341
+
342
+ Returns:
343
+ bool: True if successful, False otherwise.
344
+ """
345
+ return await self.set_brightness(brightness=brightness)
346
+
347
+ async def set_brightness(self, brightness: int) -> bool:
348
+ if not self.supports_brightness:
349
+ logger.warning('%s is not dimmable', self.device_name)
350
+ return False
351
+ if not Validators.validate_zero_to_hundred(brightness):
352
+ logger.debug('Invalid brightness value')
353
+ return False
354
+ brightness_update = brightness
355
+ if (
356
+ self.state.device_status == DeviceStatus.ON
357
+ and brightness_update == self.supports_brightness
358
+ ):
359
+ logger.debug('Device already in requested state')
360
+ return True
361
+
362
+ method_dict = {
363
+ 'brightNess': str(brightness_update),
364
+ 'status': 'on',
365
+ }
366
+ r_dict = await self.call_bypassv1_api(
367
+ bulb_models.RequestESL100Brightness,
368
+ method_dict,
369
+ 'smartBulbBrightnessCtl',
370
+ 'smartBulbBrightnessCtl',
371
+ )
372
+
373
+ r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict)
374
+ if r is None:
375
+ return False
376
+
377
+ self.state.brightness = brightness_update
378
+ self.state.device_status = DeviceStatus.ON
379
+ self.state.connection_status = ConnectionStatus.ONLINE
380
+ return True
381
+
382
+ async def get_timer(self) -> None:
383
+ r_dict = await self.call_bypassv1_api(
384
+ TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers'
385
+ )
386
+ result_model = process_bypassv1_result(
387
+ self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer
388
+ )
389
+ if result_model is None:
390
+ return
391
+ if not isinstance(result_model.timers, list) or not result_model.timers:
392
+ self.state.timer = None
393
+ logger.debug('No timers found')
394
+ return
395
+ timer = result_model.timers
396
+ if not isinstance(timer, TimerModels.TimerItemV1):
397
+ logger.debug('Invalid timer item type')
398
+ return
399
+ self.state.timer = Timer(
400
+ int(timer.counterTimer),
401
+ timer.action,
402
+ int(timer.timerID),
403
+ )
404
+
405
+ async def set_timer(self, duration: int, action: str | None = None) -> bool:
406
+ if action is None:
407
+ action = (
408
+ DeviceStatus.ON
409
+ if self.state.device_status == DeviceStatus.OFF
410
+ else DeviceStatus.OFF
411
+ )
412
+ if action not in [DeviceStatus.ON, DeviceStatus.OFF]:
413
+ logger.debug("Invalid action value - must be 'on' or 'off'")
414
+ return False
415
+ update_dict = {
416
+ 'action': action,
417
+ 'counterTime': str(duration),
418
+ 'status': '1',
419
+ }
420
+ r_dict = await self.call_bypassv1_api(
421
+ TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer'
422
+ )
423
+ result_model = process_bypassv1_result(
424
+ self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer
425
+ )
426
+ if result_model is None:
427
+ return False
428
+ self.state.timer = Timer(duration, action, int(result_model.timerID))
429
+ return True
430
+
431
+ async def clear_timer(self) -> bool:
432
+ if self.state.timer is None:
433
+ logger.debug('No timer set - run get_timer() first')
434
+ return False
435
+ timer = self.state.timer
436
+ r_dict = await self.call_bypassv1_api(
437
+ TimerModels.RequestV1ClearTimer,
438
+ {'timerId': str(timer.id), 'status': '1'},
439
+ 'deleteTimer',
440
+ 'timer/deleteTimer',
441
+ )
442
+ result = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict)
443
+ if result is None:
444
+ return False
445
+ self.state.timer = None
446
+ return True
447
+
448
+
449
+ class VeSyncBulbESL100CW(BypassV1Mixin, VeSyncBulb):
450
+ """VeSync Tunable and Dimmable White Bulb.
451
+
452
+ This bulb only has the dimmable feature. Inherits from
453
+ [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and
454
+ [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice].
455
+
456
+ Device state is held in the `state` attribute, which is an instance of
457
+ [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute
458
+ contains all settable states for the bulb.
459
+
460
+ Args:
461
+ details (dict): Dictionary of bulb state details.
462
+ manager (VeSync): Manager class used to make API calls
463
+ feature_map (BulbMap): Device configuration map.
464
+
465
+ Attributes:
466
+ state (BulbState): Device state object
467
+ Each device has a separate state base class in the base_devices module.
468
+ last_response (ResponseInfo): Last response from API call.
469
+ manager (VeSync): Manager object for API calls.
470
+ device_name (str): Name of device.
471
+ device_image (str): URL for device image.
472
+ cid (str): Device ID.
473
+ connection_type (str): Connection type of device.
474
+ device_type (str): Type of device.
475
+ type (str): Type of device.
476
+ uuid (str): UUID of device, not always present.
477
+ config_module (str): Configuration module of device.
478
+ mac_id (str): MAC ID of device.
479
+ current_firm_version (str): Current firmware version of device.
480
+ device_region (str): Region of device. (US, EU, etc.)
481
+ pid (str): Product ID of device, pulled by some devices on update.
482
+ sub_device_no (int): Sub-device number of device.
483
+ product_type (str): Product type of device.
484
+ features (dict): Features of device.
485
+ """
486
+
487
+ __slots__ = ()
488
+
489
+ def __init__(
490
+ self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap
491
+ ) -> None:
492
+ """Initialize Etekcity Tunable white bulb."""
493
+ super().__init__(details, manager, feature_map)
494
+
495
+ async def get_details(self) -> None:
496
+ r_dict = await self.call_bypassv1_api(
497
+ bulb_models.RequestESL100CWBase,
498
+ {'jsonCmd': {'getLightStatus': 'get'}},
499
+ )
500
+
501
+ light_resp = process_bypassv1_result(
502
+ self, logger, 'get_details', r_dict, bulb_models.ResponseESL100CWDetailResult
503
+ )
504
+ if light_resp is None:
505
+ self.state.connection_status = ConnectionStatus.OFFLINE
506
+ return
507
+ self._interpret_apicall_result(light_resp)
508
+
509
+ def _interpret_apicall_result(
510
+ self, response: bulb_models.ResponseESL100CWDetailResult
511
+ ) -> None:
512
+ self.state.connection_status = ConnectionStatus.ONLINE
513
+ result = response.light
514
+ self.state.device_status = result.action
515
+ self.state.brightness = result.brightness
516
+ self.state.color_temp = result.colorTempe
517
+
518
+ @deprecated(
519
+ 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead'
520
+ )
521
+ async def toggle(self, status: str) -> bool:
522
+ """Deprecated - use toggle_switch()."""
523
+ status_bool = status == DeviceStatus.ON
524
+ return await self.toggle_switch(status_bool)
525
+
526
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
527
+ if toggle is None:
528
+ toggle = self.state.device_status == DeviceStatus.OFF
529
+ status = DeviceStatus.ON if toggle else DeviceStatus.OFF
530
+
531
+ r_dict = await self.call_bypassv1_api(
532
+ bulb_models.RequestESL100CWBase,
533
+ {'jsonCmd': {'light': {'action': status}}},
534
+ 'bypass',
535
+ 'bypass',
536
+ )
537
+
538
+ r = Helpers.process_dev_response(logger, 'toggle', self, r_dict)
539
+ if r is None:
540
+ logger.debug('%s offline', self.device_name)
541
+ return False
542
+ self.state.device_status = status
543
+ return True
544
+
545
+ async def set_brightness(self, brightness: int) -> bool:
546
+ """Set brightness of tunable bulb."""
547
+ return await self.set_status(brightness=brightness)
548
+
549
+ async def set_status(
550
+ self, /, brightness: int | None = None, color_temp: int | None = None
551
+ ) -> bool:
552
+ """Set status of tunable bulb."""
553
+ if brightness is not None:
554
+ if Validators.validate_zero_to_hundred(brightness):
555
+ brightness_update = int(brightness)
556
+ else:
557
+ logger.debug('Invalid brightness value')
558
+ return False
559
+ elif self.state.brightness is not None:
560
+ brightness_update = self.state.brightness
561
+ else:
562
+ brightness_update = 100
563
+ if color_temp is not None:
564
+ if Validators.validate_zero_to_hundred(color_temp):
565
+ color_temp_update = color_temp
566
+ else:
567
+ logger.debug('Invalid color temperature value')
568
+ return False
569
+ elif self.state.color_temp is not None:
570
+ color_temp_update = self.state.color_temp
571
+ else:
572
+ color_temp_update = 100
573
+ if (
574
+ self.state.device_status == DeviceStatus.ON
575
+ and brightness_update == self.state.brightness
576
+ and color_temp_update == self.state.color_temp
577
+ ):
578
+ logger.debug('Device already in requested state')
579
+ return True
580
+ light_dict: dict[str, NUMERIC_OPT | str] = {
581
+ 'colorTempe': color_temp_update,
582
+ 'brightness': brightness_update,
583
+ 'action': DeviceStatus.ON,
584
+ }
585
+
586
+ r_dict = await self.call_bypassv1_api(
587
+ bulb_models.RequestESL100CWBase,
588
+ {'jsonCmd': {'light': light_dict}},
589
+ 'bypass',
590
+ 'bypass',
591
+ )
592
+
593
+ r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict)
594
+ if r is None:
595
+ return False
596
+
597
+ self.state.brightness = brightness_update
598
+ self.state.color_temp = color_temp_update
599
+ self.state.device_status = DeviceStatus.ON
600
+ self.state.connection_status = ConnectionStatus.ONLINE
601
+ return True
602
+
603
+ async def set_color_temp(self, color_temp: int) -> bool:
604
+ return await self.set_status(color_temp=color_temp)
605
+
606
+ async def get_timer(self) -> None:
607
+ r_dict = await self.call_bypassv1_api(
608
+ TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers'
609
+ )
610
+ result_model = process_bypassv1_result(
611
+ self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer
612
+ )
613
+ if result_model is None:
614
+ return
615
+ if not isinstance(result_model.timers, list) or not result_model.timers:
616
+ logger.debug('No timers found')
617
+ return
618
+ timers = result_model.timers
619
+ if len(timers) > 1:
620
+ logger.debug('Multiple timers found, returning first timer')
621
+ timer = timers[0]
622
+ if not isinstance(timer, TimerModels.TimeItemV1):
623
+ logger.debug('Invalid timer item type')
624
+ return
625
+ self.state.timer = Timer(
626
+ int(timer.counterTime),
627
+ timer.action,
628
+ int(timer.timerID),
629
+ )
630
+
631
+ async def set_timer(self, duration: int, action: str | None = None) -> bool:
632
+ if action is None:
633
+ action = (
634
+ DeviceStatus.ON
635
+ if self.state.device_status == DeviceStatus.OFF
636
+ else DeviceStatus.OFF
637
+ )
638
+ if action not in [DeviceStatus.ON, DeviceStatus.OFF]:
639
+ logger.debug("Invalid action value - must be 'on' or 'off'")
640
+ return False
641
+ update_dict = {
642
+ 'action': action,
643
+ 'counterTime': str(duration),
644
+ 'status': '1',
645
+ }
646
+ r_dict = await self.call_bypassv1_api(
647
+ TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer'
648
+ )
649
+ result_model = process_bypassv1_result(
650
+ self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer
651
+ )
652
+ if result_model is None:
653
+ return False
654
+ self.state.timer = Timer(duration, action, int(result_model.timerID))
655
+ return True
656
+
657
+ async def clear_timer(self) -> bool:
658
+ if self.state.timer is None:
659
+ logger.debug('No timer set - run get_timer() first')
660
+ return False
661
+ timer = self.state.timer
662
+ r_dict = await self.call_bypassv1_api(
663
+ TimerModels.RequestV1ClearTimer,
664
+ {'timerId': str(timer.id), 'status': '1'},
665
+ 'deleteTimer',
666
+ 'timer/deleteTimer',
667
+ )
668
+ r = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict)
669
+ if r is None:
670
+ return False
671
+ self.state.timer = None
672
+ return True
673
+
674
+
675
+ class VeSyncBulbValcenoA19MC(VeSyncBulb):
676
+ """VeSync Multicolor Bulb.
677
+
678
+ This bulb only has the dimmable feature. Inherits from
679
+ [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and
680
+ [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice].
681
+
682
+ Device state is held in the `state` attribute, which is an instance of
683
+ [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute
684
+ contains all settable states for the bulb.
685
+
686
+ Args:
687
+ details (dict): Dictionary of bulb state details.
688
+ manager (VeSync): Manager class used to make API calls
689
+ feature_map (BulbMap): Device configuration map.
690
+
691
+ Attributes:
692
+ state (BulbState): Device state object
693
+ Each device has a separate state base class in the base_devices module.
694
+ last_response (ResponseInfo): Last response from API call.
695
+ manager (VeSync): Manager object for API calls.
696
+ device_name (str): Name of device.
697
+ device_image (str): URL for device image.
698
+ cid (str): Device ID.
699
+ connection_type (str): Connection type of device.
700
+ device_type (str): Type of device.
701
+ type (str): Type of device.
702
+ uuid (str): UUID of device, not always present.
703
+ config_module (str): Configuration module of device.
704
+ mac_id (str): MAC ID of device.
705
+ current_firm_version (str): Current firmware version of device.
706
+ device_region (str): Region of device. (US, EU, etc.)
707
+ pid (str): Product ID of device, pulled by some devices on update.
708
+ sub_device_no (int): Sub-device number of device.
709
+ product_type (str): Product type of device.
710
+ features (dict): Features of device.
711
+ """
712
+
713
+ __slots__ = ()
714
+
715
+ def __init__(
716
+ self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap
717
+ ) -> None:
718
+ """Initialize Multicolor bulb."""
719
+ super().__init__(details, manager, feature_map)
720
+ self.request_keys = [
721
+ 'acceptLanguage',
722
+ 'accountID',
723
+ 'appVersion',
724
+ 'cid',
725
+ 'configModule',
726
+ 'deviceRegion',
727
+ 'debugMode',
728
+ 'phoneBrand',
729
+ 'phoneOS',
730
+ 'timeZone',
731
+ 'token',
732
+ 'traceId',
733
+ ]
734
+
735
+ def _payload_base(self) -> bulb_models.ValcenoStatusPayload:
736
+ """Return the payload base for the set status request.
737
+
738
+ Avoid duplicating code and ensure that the payload dict is
739
+ reset to its default state before each request.
740
+ """
741
+ payload_dict: bulb_models.ValcenoStatusPayload = {
742
+ 'force': 0,
743
+ 'brightness': '',
744
+ 'colorTemp': '',
745
+ 'colorMode': '',
746
+ 'hue': '',
747
+ 'saturation': '',
748
+ 'value': '',
749
+ }
750
+ return payload_dict
751
+
752
+ def _build_request(self, payload: dict) -> dict:
753
+ """Build request for Valceno Smart Bulb.
754
+
755
+ The request is built from the keys in `self.request_keys` and the
756
+ `payload` dict. The payload is then added to the request body.
757
+
758
+ Args:
759
+ method (str): The method to use for the request.
760
+ payload (dict): The data to use for the payload.
761
+
762
+ Returns:
763
+ dict: The request body.
764
+
765
+ Note:
766
+ The `payload` argument is the value of request_body['payload'].
767
+ The request structure is:
768
+ ```json
769
+ {
770
+ "acceptLanguage": "en",
771
+ "accountID": "1234567890",
772
+ "appVersion": "1.0.0",
773
+ "cid": "1234567890",
774
+ "configModule": "default",
775
+ "deviceRegion": "US",
776
+ "debugMode": False,
777
+ "phoneBrand": "Apple",
778
+ "phoneOS": "iOS",
779
+ "timeZone": "New_York",
780
+ "token": "",
781
+ "traceId": "",
782
+ "payload": {
783
+ "method": "getLightStatusV2",
784
+ "source": "APP",
785
+ "data": {}
786
+ }
787
+ }
788
+ ```
789
+ """
790
+ default_dict = Helpers.get_class_attributes(DefaultValues, self.request_keys)
791
+ default_dict.update(Helpers.get_class_attributes(self, self.request_keys))
792
+ default_dict.update(Helpers.get_class_attributes(self.manager, self.request_keys))
793
+ default_dict['method'] = 'bypassV2'
794
+ default_dict['payload'] = payload
795
+ return default_dict
796
+
797
+ async def _call_valceno_api(
798
+ self, payload_method: str, payload_data: Mapping
799
+ ) -> dict | None:
800
+ """Make API call to Valceno Smart Bulb.
801
+
802
+ Args:
803
+ payload_method (str): The method to use for the request.
804
+ payload_data (dict): The payload to use for the request.
805
+
806
+ Returns:
807
+ tuple[bytes, Any]: The response from the API call.
808
+
809
+ Note:
810
+ The request is built by the `_build_request` method and the payload is
811
+ added to the request body.
812
+ The payload structure is:
813
+ ```json
814
+ {
815
+ "method": "getLightStatusV2",
816
+ "source": "APP",
817
+ "data": {}
818
+ }
819
+ """
820
+ payload = {'method': payload_method, 'source': 'APP', 'data': payload_data}
821
+ request_body = self._build_request(payload)
822
+ r_dict, _ = await self.manager.async_call_api(
823
+ '/cloud/v2/deviceManaged/bypassV2',
824
+ 'post',
825
+ headers=Helpers.req_header_bypass(),
826
+ json_object=request_body,
827
+ )
828
+ resp = Helpers.process_dev_response(logger, payload_method, self, r_dict)
829
+ if resp is None:
830
+ return None
831
+ return r_dict
832
+
833
+ async def get_details(self) -> None:
834
+ r_dict = await self._call_valceno_api('getLightStatusV2', {})
835
+
836
+ if r_dict is None:
837
+ return
838
+
839
+ status = bulb_models.ResponseValcenoStatus.from_dict(r_dict)
840
+ self._interpret_apicall_result(status)
841
+
842
+ def _interpret_apicall_result(
843
+ self, response: bulb_models.ResponseValcenoStatus
844
+ ) -> None:
845
+ """Process API response with device status.
846
+
847
+ Assigns the values to the state attributes of the device.
848
+
849
+ Args:
850
+ response (bulb_models.ResponseValcenoStatus): The response from the API call.
851
+ """
852
+ result = response.result.result
853
+ self.state.connection_status = ConnectionStatus.ONLINE
854
+ self.state.device_status = result.enabled
855
+ self.state.brightness = result.brightness
856
+ self.state.color_temp = result.colorTemp
857
+ self.state.color_mode = result.colorMode
858
+ hue = float(round(result.hue / 250 * 9, 2))
859
+ sat = float(result.saturation / 100)
860
+ val = float(result.value)
861
+ self.state.color = Color.from_hsv(hue=hue, saturation=sat, value=val)
862
+
863
+ @deprecated(
864
+ 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead'
865
+ )
866
+ async def toggle(self, status: str) -> bool:
867
+ """Deprecated - use toggle_switch()."""
868
+ status_bool = status == DeviceStatus.ON
869
+ return await self.toggle_switch(status_bool)
870
+
871
+ async def toggle_switch(self, toggle: bool | None = None) -> bool:
872
+ if toggle is None:
873
+ toggle = self.state.device_status == DeviceStatus.OFF
874
+
875
+ if toggle == self.state.device_status:
876
+ logger.debug('Device already in requested state')
877
+ return True
878
+
879
+ payload_data = {
880
+ 'id': 0,
881
+ 'enabled': toggle,
882
+ }
883
+ method = 'setSwitch'
884
+
885
+ r_dict = await self._call_valceno_api(method, payload_data)
886
+
887
+ if r_dict is None:
888
+ self.state.device_status = DeviceStatus.OFF
889
+ return False
890
+ self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF
891
+ self.state.connection_status = ConnectionStatus.ONLINE
892
+ return True
893
+
894
+ async def set_rgb(self, red: float, green: float, blue: float) -> bool:
895
+ new_color = Color.from_rgb(red=red, green=green, blue=blue)
896
+ if new_color is None:
897
+ logger.debug('Invalid RGB values')
898
+ return False
899
+
900
+ return await self.set_hsv(
901
+ hue=new_color.hsv.hue,
902
+ saturation=new_color.hsv.saturation,
903
+ value=new_color.hsv.value,
904
+ )
905
+
906
+ async def set_brightness(self, brightness: int) -> bool:
907
+ """Set brightness of multicolor bulb."""
908
+ return await self.set_status(brightness=brightness)
909
+
910
+ async def set_color_temp(self, color_temp: int) -> bool:
911
+ """Set White Temperature of Bulb in pct (0 - 100)."""
912
+ return await self.set_status(color_temp=color_temp)
913
+
914
+ async def set_color_hue(self, color_hue: float) -> bool:
915
+ """Set Color Hue of Bulb (0 - 360)."""
916
+ return await self.set_status(color_hue=color_hue)
917
+
918
+ async def set_color_saturation(self, color_saturation: float) -> bool:
919
+ """Set Color Saturation of Bulb in pct (1 - 100)."""
920
+ return await self.set_status(color_saturation=color_saturation)
921
+
922
+ async def set_color_value(self, color_value: float) -> bool:
923
+ """Set Value of multicolor bulb in pct (1 - 100)."""
924
+ # Equivalent to brightness level, when in color mode.
925
+ return await self.set_status(color_value=color_value)
926
+
927
+ async def set_color_mode(self, color_mode: str) -> bool:
928
+ """Set Color Mode of Bulb (white / hsv)."""
929
+ return await self.set_status(color_mode=color_mode)
930
+
931
+ async def set_hsv(self, hue: float, saturation: float, value: float) -> bool:
932
+ new_color = Color.from_hsv(hue=hue, saturation=saturation, value=value)
933
+ if new_color is None:
934
+ logger.warning('Invalid HSV values')
935
+ return False
936
+
937
+ # the api expects the hsv Value in the brightness parameter
938
+ payload_data = self._build_status_payload(
939
+ hue=hue,
940
+ saturation=saturation,
941
+ value=value,
942
+ )
943
+ if payload_data is None:
944
+ return False
945
+ resp = await self._call_valceno_api('setLightStatusV2', payload_data)
946
+ if resp is None:
947
+ return False
948
+
949
+ r_dict = Helpers.process_dev_response(logger, 'set_hsv', self, resp)
950
+ if r_dict is None:
951
+ return False
952
+
953
+ status = bulb_models.ResponseValcenoStatus.from_dict(r_dict)
954
+ self._interpret_apicall_result(status)
955
+ return True
956
+
957
+ async def set_white_mode(self) -> bool:
958
+ return await self.set_status(color_mode='white')
959
+
960
+ async def set_status(
961
+ self,
962
+ *,
963
+ brightness: float | None = None,
964
+ color_temp: float | None = None,
965
+ color_hue: float | None = None,
966
+ color_saturation: float | None = None,
967
+ color_value: float | None = None,
968
+ color_mode: str | None = None,
969
+ ) -> bool:
970
+ """Set multicolor bulb parameters.
971
+
972
+ No arguments turns bulb on. **Kwargs only**
973
+
974
+ Args:
975
+ brightness (int, optional): brightness between 0 and 100
976
+ color_temp (int, optional): color temperature between 0 and 100
977
+ color_mode (int, optional): color mode hsv or white
978
+ color_hue (float, optional): color hue between 0 and 360
979
+ color_saturation (float, optional): color saturation between 0 and 100
980
+ color_value (int, optional): color value between 0 and 100
981
+
982
+ Returns:
983
+ bool : True if call was successful, False otherwise
984
+ """
985
+ payload_data = self._build_status_payload(
986
+ brightness=brightness,
987
+ color_temp=color_temp,
988
+ hue=color_hue,
989
+ saturation=color_saturation,
990
+ value=color_value,
991
+ color_mode=color_mode,
992
+ )
993
+ if payload_data == self._payload_base():
994
+ logger.debug('No state change.')
995
+ return False
996
+ if payload_data is None:
997
+ logger.debug('Invalid payload data')
998
+ return False
999
+
1000
+ r_dict = await self._call_valceno_api('setLightStatusV2', payload_data)
1001
+ if r_dict is None:
1002
+ return False
1003
+
1004
+ r_dict = Helpers.process_dev_response(logger, 'set_status', self, r_dict)
1005
+ if r_dict is None:
1006
+ return False
1007
+
1008
+ r_model = bulb_models.ResponseValcenoStatus.from_dict(r_dict)
1009
+ self._interpret_apicall_result(r_model)
1010
+ return True
1011
+
1012
+ def _build_color_payload(
1013
+ self,
1014
+ color_hue: float | None = None,
1015
+ color_saturation: float | None = None,
1016
+ color_value: float | None = None,
1017
+ ) -> bulb_models.ValcenoStatusPayload | None:
1018
+ """Create color payload for Valceno Bulbs.
1019
+
1020
+ This is called by `_build_status_payload` if any of the HSV values are set.
1021
+ This should not be called directly.
1022
+
1023
+ Args:
1024
+ color_hue (float, optional): Color hue of bulb (0-360).
1025
+ color_saturation (float, optional): Color saturation of bulb (0-100).
1026
+ color_value (float, optional): Color value of bulb (0-100).
1027
+ """
1028
+ payload_dict = self._payload_base()
1029
+ new_color = Color.from_hsv(
1030
+ hue=color_hue, saturation=color_saturation, value=color_value
1031
+ )
1032
+ if new_color is None:
1033
+ logger.warning('Invalid HSV values')
1034
+ return None
1035
+ payload_dict['hue'] = int(new_color.hsv.hue * 250 / 9)
1036
+ payload_dict['saturation'] = int(new_color.hsv.saturation * 100)
1037
+ payload_dict['value'] = int(new_color.hsv.value)
1038
+ payload_dict['colorMode'] = 'hsv'
1039
+ payload_dict['force'] = 1
1040
+ return payload_dict
1041
+
1042
+ def _build_status_payload(
1043
+ self,
1044
+ brightness: float | None = None,
1045
+ color_temp: float | None = None,
1046
+ hue: float | None = None,
1047
+ saturation: float | None = None,
1048
+ value: float | None = None,
1049
+ color_mode: str | None = None,
1050
+ ) -> bulb_models.ValcenoStatusPayload | None:
1051
+ """Create status payload data for Valceno Bulbs.
1052
+
1053
+ If color_mode is set, hue, saturation and/or value must be set as well.
1054
+ If color_mode is not set, brightness and/or color_temp must be set.
1055
+
1056
+ This builds the `request_body['payload']['data']` dict for api calls that
1057
+ set teh status of the bulb.
1058
+
1059
+ Args:
1060
+ brightness (float, optional): Brightness of bulb (0-100).
1061
+ color_temp (float, optional): Color temperature of bulb (0-100).
1062
+ hue (float, optional): Color hue of bulb (0-360).
1063
+ saturation (float, optional): Color saturation of bulb (0-100).
1064
+ value (float, optional): Color value of bulb (0-100).
1065
+ color_mode (str, optional): Color mode of bulb ('white', 'hsv', 'color').
1066
+
1067
+ Returns:
1068
+ dict | None: Payload dictionary to be sent to the API.
1069
+ """
1070
+ payload_dict = self._payload_base()
1071
+ if all(itm is not None for itm in [hue, saturation, value]):
1072
+ color_dict = self._build_color_payload(
1073
+ color_hue=hue, color_saturation=saturation, color_value=value
1074
+ )
1075
+ if color_dict is not None:
1076
+ return color_dict
1077
+ # Return None if HSV values are same as current state
1078
+ if self._check_color_state(hue=hue, saturation=saturation, value=value):
1079
+ return None
1080
+ elif color_mode in ['color', 'hsv', 'rgb']:
1081
+ logger.debug('HSV values must be provided when setting color mode.')
1082
+ else:
1083
+ if color_mode == 'white' and not Validators.validate_zero_to_hundred(
1084
+ color_temp
1085
+ ):
1086
+ payload_dict['colorMode'] = 'white'
1087
+ if color_temp is not None and Validators.validate_zero_to_hundred(color_temp):
1088
+ payload_dict['colorTemp'] = int(color_temp)
1089
+ payload_dict['colorMode'] = 'white'
1090
+ if brightness is not None and Validators.validate_zero_to_hundred(brightness):
1091
+ payload_dict['brightness'] = int(brightness)
1092
+ force_keys = ['colorTemp', 'saturation', 'hue', 'colorMode', 'value']
1093
+ for key in force_keys:
1094
+ if payload_dict.get(key) != '':
1095
+ payload_dict['force'] = 1
1096
+ return payload_dict
1097
+
1098
+ def _check_color_state(
1099
+ self,
1100
+ hue: float | None = None,
1101
+ saturation: float | None = None,
1102
+ value: float | None = None,
1103
+ ) -> bool:
1104
+ """Check if color state is already set.
1105
+
1106
+ Returns True if color is already set, False otherwise.
1107
+
1108
+ Args:
1109
+ hue (float, optional): Color hue.
1110
+ saturation (float, optional): Color saturation.
1111
+ value (float, optional): Color value.
1112
+ """
1113
+ if self.state.color is not None:
1114
+ set_hue = hue or self.state.color.hsv.hue
1115
+ set_saturation = saturation or self.state.color.hsv.saturation
1116
+ set_value = value or self.state.color.hsv.value
1117
+ set_color = Color.from_hsv(
1118
+ hue=set_hue, saturation=set_saturation, value=set_value
1119
+ )
1120
+ if (
1121
+ self.state.device_status == DeviceStatus.ON
1122
+ and set_color == self.state.color
1123
+ ):
1124
+ logger.debug('Device already set to requested color')
1125
+ return True
1126
+ return False