"""Etekcity/Valceno Smart Light Bulbs. This module provides classes for the following Etekcity/Valceno smart lights: 1. ESL100: Dimmable Bulb 2. ESL100CW: Tunable White Bulb 3. XYD0001: RGB Bulb 4. ESL100MC: Multi-Color Bulb The classes all inherit from VeSyncBulb, which is a subclass of VeSyncBaseDevice and [VeSyncToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. """ from __future__ import annotations import logging from collections.abc import Mapping from typing import TYPE_CHECKING from typing_extensions import deprecated from pyvesync.base_devices import VeSyncBulb from pyvesync.const import ConnectionStatus, DeviceStatus from pyvesync.models import bulb_models from pyvesync.models.bypass_models import TimerModels from pyvesync.utils.colors import Color from pyvesync.utils.device_mixins import ( BypassV1Mixin, BypassV2Mixin, process_bypassv1_result, process_bypassv2_result, ) from pyvesync.utils.helpers import Helpers, Timer, Validators if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import BulbMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel logger = logging.getLogger(__name__) NUMERIC_OPT = int | float | str | None NUMERIC_STRICT = float | int | str class VeSyncBulbESL100MC(BypassV2Mixin, VeSyncBulb): """Etekcity ESL100 Multi Color Bulb device. Inherits from [VeSyncBulb][pyvesync.base_devices.bulb_base.VeSyncBulb] and [VeSyncBaseDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseDevice]. The state of the bulb is stored in the `state` attribute, which is an of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls. feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. Notes: The details dictionary contains the device information retrieved by the `update()` method: ```python details = { 'brightness': 50, 'colorMode': 'rgb', 'color' : Color(red=0, green=0, blue=0) } ``` See pyvesync.helpers.color.Color for more information on the Color dataclass. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Instantiate ESL100MC Multicolor Bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getLightStatus') result_model = process_bypassv2_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100MCResult ) if result_model is None: return self._set_state(result_model) return def _set_state(self, response: bulb_models.ResponseESL100MCResult) -> None: """Build detail dictionary from response.""" self.state.brightness = response.brightness self.state.color_mode = response.colorMode self.state.color = Color.from_rgb( red=response.red, green=response.green, blue=response.blue ) async def set_brightness(self, brightness: int) -> bool: return await self.set_status(brightness=brightness) async def set_rgb(self, red: float, green: float, blue: float) -> bool: return await self.set_status(red=red, green=green, blue=blue) async def set_hsv(self, hue: float, saturation: float, value: float) -> bool: hsv = Color.from_hsv(hue=hue, saturation=saturation, value=value) if hsv is not None: return await self.set_status( red=hsv.rgb.red, green=hsv.rgb.green, blue=hsv.rgb.blue ) logger.warning('Invalid HSV values') return False async def set_white_mode(self) -> bool: return await self.set_status(brightness=100) async def set_status( self, brightness: float | None = None, red: float | None = None, green: float | None = None, blue: float | None = None, ) -> bool: """Set color of VeSync ESL100MC. Brightness or RGB values must be provided. If RGB values are provided, brightness is ignored. Args: brightness (float | None): Brightness of bulb (0-100). red (float | None): Red value of RGB color, 0-255. green (float | None): Green value of RGB color, 0-255. blue (float | None): Blue value of RGB color, 0-255. Returns: bool: True if successful, False otherwise. """ brightness_update = 100 if red is not None and green is not None and blue is not None: new_color = Color.from_rgb(red, green, blue) color_mode = 'color' if ( self.state.device_status == DeviceStatus.ON and new_color == self.state.color ): logger.debug('New color is same as current color') return True else: logger.debug('RGB Values not provided') new_color = None if brightness is not None: if Validators.validate_zero_to_hundred(brightness): brightness_update = int(brightness) else: logger.warning('Invalid brightness value') return False if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.state.brightness ): logger.debug('Brightness already set to %s', brightness) return True color_mode = 'white' else: logger.warning('Brightness and RGB values are not set') return False data = { 'action': DeviceStatus.ON, 'speed': 0, 'brightness': brightness_update, 'red': 0 if new_color is None else int(new_color.rgb.red), 'green': 0 if new_color is None else int(new_color.rgb.green), 'blue': 0 if new_color is None else int(new_color.rgb.blue), 'colorMode': 'color' if new_color is not None else 'white', } r_dict = await self.call_bypassv2_api('setLightStatus', data) r = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if r is None: return False if color_mode == 'color' and new_color is not None: self.state.color_mode = 'color' self.state.color = new_color elif brightness is not None: self.state.brightness = int(brightness_update) self.state.color_mode = 'white' self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Toggle switch of VeSync ESL100MC.""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF data = {'id': 0, 'enabled': toggle} r_dict = await self.call_bypassv2_api('setSwitch', data) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF return True class VeSyncBulbESL100(BypassV1Mixin, VeSyncBulb): """Object to hold VeSync ESL100 light bulb. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Etekcity ESL100 Dimmable Bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: method_dict = { 'method': 'deviceDetail', } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Detail, method_dict, 'deviceDetail', 'deviceDetail' ) model = process_bypassv1_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100DetailResult ) if model is None: self.state.connection_status = ConnectionStatus.OFFLINE return self.state.brightness = model.brightness self.state.device_status = model.deviceStatus self.state.connection_status = model.connectionStatus @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Toggle switch of ESL100 bulb.""" status_bool = status != DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status != DeviceStatus.ON status = DeviceStatus.ON if toggle else DeviceStatus.OFF method_dict = { 'status': status, } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Status, method_dict, 'smartBulbPowerSwitchCtl', 'smartBulbPowerSwitchCtl', ) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: return False self.state.device_status = status return True @deprecated('Use set_brightness() instead') async def set_status(self, brightness: int) -> bool: """Set brightness of dimmable bulb. Args: brightness (int): Brightness of bulb (0-100). Returns: bool: True if successful, False otherwise. """ return await self.set_brightness(brightness=brightness) async def set_brightness(self, brightness: int) -> bool: if not self.supports_brightness: logger.warning('%s is not dimmable', self.device_name) return False if not Validators.validate_zero_to_hundred(brightness): logger.warning('Invalid brightness value') return False brightness_update = brightness if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.supports_brightness ): logger.debug('Device already in requested state') return True method_dict = { 'brightNess': str(brightness_update), 'status': 'on', } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100Brightness, method_dict, 'smartBulbBrightnessCtl', 'smartBulbBrightnessCtl', ) r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict) if r is None: return False self.state.brightness = brightness_update self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers' ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return if not isinstance(result_model.timers, list) or not result_model.timers: self.state.timer = None logger.debug('No timers found') return timer = result_model.timers if not isinstance(timer, TimerModels.TimerItemV1): logger.warning('Invalid timer item type') return self.state.timer = Timer( int(timer.counterTimer), timer.action, int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.warning("Invalid action value - must be 'on' or 'off'") return False update_dict = { 'action': action, 'counterTime': str(duration), 'status': '1', } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer' ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set - run get_timer() first') return False timer = self.state.timer r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(timer.id), 'status': '1'}, 'deleteTimer', 'timer/deleteTimer', ) result = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if result is None: return False self.state.timer = None return True class VeSyncBulbESL100CW(BypassV1Mixin, VeSyncBulb): """VeSync Tunable and Dimmable White Bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Etekcity Tunable white bulb.""" super().__init__(details, manager, feature_map) async def get_details(self) -> None: r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'getLightStatus': 'get'}}, ) light_resp = process_bypassv1_result( self, logger, 'get_details', r_dict, bulb_models.ResponseESL100CWDetailResult ) if light_resp is None: self.state.connection_status = ConnectionStatus.OFFLINE return self._interpret_apicall_result(light_resp) def _interpret_apicall_result( self, response: bulb_models.ResponseESL100CWDetailResult ) -> None: self.state.connection_status = ConnectionStatus.ONLINE result = response.light self.state.device_status = result.action self.state.brightness = result.brightness self.state.color_temp = result.colorTempe @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Deprecated - use toggle_switch().""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF status = DeviceStatus.ON if toggle else DeviceStatus.OFF r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'light': {'action': status}}}, 'bypass', 'bypass', ) r = Helpers.process_dev_response(logger, 'toggle', self, r_dict) if r is None: logger.info('%s offline', self.device_name) return False self.state.device_status = status return True async def set_brightness(self, brightness: int) -> bool: """Set brightness of tunable bulb.""" return await self.set_status(brightness=brightness) async def set_status( self, /, brightness: int | None = None, color_temp: int | None = None ) -> bool: """Set status of tunable bulb.""" if brightness is not None: if Validators.validate_zero_to_hundred(brightness): brightness_update = int(brightness) else: logger.warning('Invalid brightness value') return False elif self.state.brightness is not None: brightness_update = self.state.brightness else: brightness_update = 100 if color_temp is not None: if Validators.validate_zero_to_hundred(color_temp): color_temp_update = color_temp else: logger.warning('Invalid color temperature value') return False elif self.state.color_temp is not None: color_temp_update = self.state.color_temp else: color_temp_update = 100 if ( self.state.device_status == DeviceStatus.ON and brightness_update == self.state.brightness and color_temp_update == self.state.color_temp ): logger.debug('Device already in requested state') return True light_dict: dict[str, NUMERIC_OPT | str] = { 'colorTempe': color_temp_update, 'brightness': brightness_update, 'action': DeviceStatus.ON, } r_dict = await self.call_bypassv1_api( bulb_models.RequestESL100CWBase, {'jsonCmd': {'light': light_dict}}, 'bypass', 'bypass', ) r = Helpers.process_dev_response(logger, 'set_brightness', self, r_dict) if r is None: return False self.state.brightness = brightness_update self.state.color_temp = color_temp_update self.state.device_status = DeviceStatus.ON self.state.connection_status = ConnectionStatus.ONLINE return True async def set_color_temp(self, color_temp: int) -> bool: return await self.set_status(color_temp=color_temp) async def get_timer(self) -> None: r_dict = await self.call_bypassv1_api( TimerModels.RequestV1GetTimer, {}, 'getTimers', 'timer/getTimers' ) result_model = process_bypassv1_result( self, logger, 'get_timer', r_dict, TimerModels.ResultV1GetTimer ) if result_model is None: return if not isinstance(result_model.timers, list) or not result_model.timers: logger.debug('No timers found') return timers = result_model.timers if len(timers) > 1: logger.debug('Multiple timers found, returning first timer') timer = timers[0] if not isinstance(timer, TimerModels.TimeItemV1): logger.warning('Invalid timer item type') return self.state.timer = Timer( int(timer.counterTime), timer.action, int(timer.timerID), ) async def set_timer(self, duration: int, action: str | None = None) -> bool: if action is None: action = ( DeviceStatus.ON if self.state.device_status == DeviceStatus.OFF else DeviceStatus.OFF ) if action not in [DeviceStatus.ON, DeviceStatus.OFF]: logger.warning("Invalid action value - must be 'on' or 'off'") return False update_dict = { 'action': action, 'counterTime': str(duration), 'status': '1', } r_dict = await self.call_bypassv1_api( TimerModels.RequestV1SetTime, update_dict, 'addTimer', 'timer/addTimer' ) result_model = process_bypassv1_result( self, logger, 'set_timer', r_dict, TimerModels.ResultV1SetTimer ) if result_model is None: return False self.state.timer = Timer(duration, action, int(result_model.timerID)) return True async def clear_timer(self) -> bool: if self.state.timer is None: logger.debug('No timer set - run get_timer() first') return False timer = self.state.timer r_dict = await self.call_bypassv1_api( TimerModels.RequestV1ClearTimer, {'timerId': str(timer.id), 'status': '1'}, 'deleteTimer', 'timer/deleteTimer', ) r = Helpers.process_dev_response(logger, 'clear_timer', self, r_dict) if r is None: return False self.state.timer = None return True class VeSyncBulbValcenoA19MC(VeSyncBulb): """VeSync Multicolor Bulb. This bulb only has the dimmable feature. Inherits from [VeSyncBulb][pyvesync.devices.vesyncbulb.VeSyncBulb] and [VeSyncBaseToggleDevice][pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice]. Device state is held in the `state` attribute, which is an instance of [BulbState][pyvesync.base_devices.bulb_base.BulbState]. The `state` attribute contains all settable states for the bulb. Args: details (dict): Dictionary of bulb state details. manager (VeSync): Manager class used to make API calls feature_map (BulbMap): Device configuration map. Attributes: state (BulbState): Device state object Each device has a separate state base class in the base_devices module. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) pid (str): Product ID of device, pulled by some devices on update. sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. features (dict): Features of device. """ __slots__ = () def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: BulbMap ) -> None: """Initialize Multicolor bulb.""" super().__init__(details, manager, feature_map) self.request_keys = ( 'acceptLanguage', 'accountID', 'appVersion', 'cid', 'configModule', 'deviceRegion', 'debugMode', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', ) def _payload_base(self) -> bulb_models.ValcenoStatusPayload: """Return the payload base for the set status request. Avoid duplicating code and ensure that the payload dict is reset to its default state before each request. """ payload_dict: bulb_models.ValcenoStatusPayload = { 'force': 0, 'brightness': '', 'colorTemp': '', 'colorMode': '', 'hue': '', 'saturation': '', 'value': '', } return payload_dict def _build_request(self, payload: dict) -> dict: """Build request for Valceno Smart Bulb. The request is built from the keys in `self.request_keys` and the `payload` dict. The payload is then added to the request body. Args: method (str): The method to use for the request. payload (dict): The data to use for the payload. Returns: dict: The request body. Note: The `payload` argument is the value of request_body['payload']. The request structure is: ```json { "acceptLanguage": "en", "accountID": "1234567890", "appVersion": "1.0.0", "cid": "1234567890", "configModule": "default", "deviceRegion": "US", "debugMode": False, "phoneBrand": "Apple", "phoneOS": "iOS", "timeZone": "New_York", "token": "", "traceId": "", "payload": { "method": "getLightStatusV2", "source": "APP", "data": {} } } ``` """ default_dict = Helpers.get_defaultvalues_attributes(self.request_keys) default_dict.update(Helpers.get_device_attributes(self, self.request_keys)) default_dict.update( Helpers.get_manager_attributes(self.manager, self.request_keys) ) default_dict['method'] = 'bypassV2' default_dict['payload'] = payload return default_dict async def _call_valceno_api( self, payload_method: str, payload_data: Mapping ) -> dict | None: """Make API call to Valceno Smart Bulb. Args: payload_method (str): The method to use for the request. payload_data (dict): The payload to use for the request. Returns: tuple[bytes, Any]: The response from the API call. Note: The request is built by the `_build_request` method and the payload is added to the request body. The payload structure is: ```json { "method": "getLightStatusV2", "source": "APP", "data": {} } """ payload = {'method': payload_method, 'source': 'APP', 'data': payload_data} request_body = self._build_request(payload) r_dict, _ = await self.manager.async_call_api( '/cloud/v2/deviceManaged/bypassV2', 'post', headers=Helpers.req_header_bypass(), json_object=request_body, ) resp = Helpers.process_dev_response(logger, payload_method, self, r_dict) if resp is None: return None return r_dict async def get_details(self) -> None: r_dict = await self._call_valceno_api('getLightStatusV2', {}) if r_dict is None: return status = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(status) def _interpret_apicall_result( self, response: bulb_models.ResponseValcenoStatus ) -> None: """Process API response with device status. Assigns the values to the state attributes of the device. Args: response (bulb_models.ResponseValcenoStatus): The response from the API call. """ result = response.result.result self.state.connection_status = ConnectionStatus.ONLINE self.state.device_status = result.enabled self.state.brightness = result.brightness self.state.color_temp = result.colorTemp self.state.color_mode = result.colorMode hue = float(round(result.hue / 250 * 9, 2)) sat = float(result.saturation / 100) val = float(result.value) self.state.color = Color.from_hsv(hue=hue, saturation=sat, value=val) @deprecated( 'toggle() is deprecated, use toggle_switch(toggle: bool | None = None) instead' ) async def toggle(self, status: str) -> bool: """Deprecated - use toggle_switch().""" status_bool = status == DeviceStatus.ON return await self.toggle_switch(status_bool) async def toggle_switch(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.device_status == DeviceStatus.OFF if toggle == self.state.device_status: logger.warning('Device already in requested state') return True payload_data = { 'id': 0, 'enabled': toggle, } method = 'setSwitch' r_dict = await self._call_valceno_api(method, payload_data) if r_dict is None: self.state.device_status = DeviceStatus.OFF return False self.state.device_status = DeviceStatus.ON if toggle else DeviceStatus.OFF self.state.connection_status = ConnectionStatus.ONLINE return True async def set_rgb(self, red: float, green: float, blue: float) -> bool: new_color = Color.from_rgb(red=red, green=green, blue=blue) if new_color is None: logger.warning('Invalid RGB values') return False return await self.set_hsv( hue=new_color.hsv.hue, saturation=new_color.hsv.saturation, value=new_color.hsv.value, ) async def set_brightness(self, brightness: int) -> bool: """Set brightness of multicolor bulb.""" return await self.set_status(brightness=brightness) async def set_color_temp(self, color_temp: int) -> bool: """Set White Temperature of Bulb in pct (0 - 100).""" return await self.set_status(color_temp=color_temp) async def set_color_hue(self, color_hue: float) -> bool: """Set Color Hue of Bulb (0 - 360).""" return await self.set_status(color_hue=color_hue) async def set_color_saturation(self, color_saturation: float) -> bool: """Set Color Saturation of Bulb in pct (1 - 100).""" return await self.set_status(color_saturation=color_saturation) async def set_color_value(self, color_value: float) -> bool: """Set Value of multicolor bulb in pct (1 - 100).""" # Equivalent to brightness level, when in color mode. return await self.set_status(color_value=color_value) async def set_color_mode(self, color_mode: str) -> bool: """Set Color Mode of Bulb (white / hsv).""" return await self.set_status(color_mode=color_mode) async def set_hsv(self, hue: float, saturation: float, value: float) -> bool: new_color = Color.from_hsv(hue=hue, saturation=saturation, value=value) if new_color is None: logger.warning('Invalid HSV values') return False # the api expects the hsv Value in the brightness parameter payload_data = self._build_status_payload( hue=hue, saturation=saturation, value=value, ) if payload_data is None: return False resp = await self._call_valceno_api('setLightStatusV2', payload_data) if resp is None: return False r_dict = Helpers.process_dev_response(logger, 'set_hsv', self, resp) if r_dict is None: return False status = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(status) return True async def set_white_mode(self) -> bool: return await self.set_status(color_mode='white') async def set_status( self, *, brightness: float | None = None, color_temp: float | None = None, color_hue: float | None = None, color_saturation: float | None = None, color_value: float | None = None, color_mode: str | None = None, ) -> bool: """Set multicolor bulb parameters. No arguments turns bulb on. **Kwargs only** Args: brightness (int, optional): brightness between 0 and 100 color_temp (int, optional): color temperature between 0 and 100 color_mode (int, optional): color mode hsv or white color_hue (float, optional): color hue between 0 and 360 color_saturation (float, optional): color saturation between 0 and 100 color_value (int, optional): color value between 0 and 100 Returns: bool : True if call was successful, False otherwise """ payload_data = self._build_status_payload( brightness=brightness, color_temp=color_temp, hue=color_hue, saturation=color_saturation, value=color_value, color_mode=color_mode, ) if payload_data == self._payload_base(): logger.debug('No state change.') return False if payload_data is None: logger.warning( 'Invalid payload data for set_status call for %s', self.device_name ) return False r_dict = await self._call_valceno_api('setLightStatusV2', payload_data) if r_dict is None: return False r_dict = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if r_dict is None: return False r_model = bulb_models.ResponseValcenoStatus.from_dict(r_dict) self._interpret_apicall_result(r_model) return True def _build_color_payload( self, color_hue: float | None = None, color_saturation: float | None = None, color_value: float | None = None, ) -> bulb_models.ValcenoStatusPayload | None: """Create color payload for Valceno Bulbs. This is called by `_build_status_payload` if any of the HSV values are set. This should not be called directly. Args: color_hue (float, optional): Color hue of bulb (0-360). color_saturation (float, optional): Color saturation of bulb (0-100). color_value (float, optional): Color value of bulb (0-100). """ payload_dict = self._payload_base() new_color = Color.from_hsv( hue=color_hue, saturation=color_saturation, value=color_value ) if new_color is None: logger.warning('Invalid HSV values') return None payload_dict['hue'] = int(new_color.hsv.hue * 250 / 9) payload_dict['saturation'] = int(new_color.hsv.saturation * 100) payload_dict['value'] = int(new_color.hsv.value) payload_dict['colorMode'] = 'hsv' payload_dict['force'] = 1 return payload_dict def _build_status_payload( self, brightness: float | None = None, color_temp: float | None = None, hue: float | None = None, saturation: float | None = None, value: float | None = None, color_mode: str | None = None, ) -> bulb_models.ValcenoStatusPayload | None: """Create status payload data for Valceno Bulbs. If color_mode is set, hue, saturation and/or value must be set as well. If color_mode is not set, brightness and/or color_temp must be set. This builds the `request_body['payload']['data']` dict for api calls that set the status of the bulb. Args: brightness (float, optional): Brightness of bulb (0-100). color_temp (float, optional): Color temperature of bulb (0-100). hue (float, optional): Color hue of bulb (0-360). saturation (float, optional): Color saturation of bulb (0-100). value (float, optional): Color value of bulb (0-100). color_mode (str, optional): Color mode of bulb ('white', 'hsv', 'color'). Returns: dict | None: Payload dictionary to be sent to the API. """ payload_dict = self._payload_base() if all(itm is not None for itm in [hue, saturation, value]): color_dict = self._build_color_payload( color_hue=hue, color_saturation=saturation, color_value=value ) if color_dict is not None: return color_dict # Return None if HSV values are same as current state if self._check_color_state(hue=hue, saturation=saturation, value=value): return None elif color_mode in ['color', 'hsv', 'rgb']: logger.debug('HSV values must be provided when setting color mode.') else: if color_mode == 'white' and not Validators.validate_zero_to_hundred( color_temp ): payload_dict['colorMode'] = 'white' if color_temp is not None and Validators.validate_zero_to_hundred(color_temp): payload_dict['colorTemp'] = int(color_temp) payload_dict['colorMode'] = 'white' if brightness is not None and Validators.validate_zero_to_hundred(brightness): payload_dict['brightness'] = int(brightness) force_keys = ['colorTemp', 'saturation', 'hue', 'colorMode', 'value'] for key in force_keys: if payload_dict.get(key) != '': payload_dict['force'] = 1 return payload_dict def _check_color_state( self, hue: float | None = None, saturation: float | None = None, value: float | None = None, ) -> bool: """Check if color state is already set. Returns True if color is already set, False otherwise. Args: hue (float, optional): Color hue. saturation (float, optional): Color saturation. value (float, optional): Color value. """ if self.state.color is not None: set_hue = hue or self.state.color.hsv.hue set_saturation = saturation or self.state.color.hsv.saturation set_value = value or self.state.color.hsv.value set_color = Color.from_hsv( hue=set_hue, saturation=set_saturation, value=set_value ) if ( self.state.device_status == DeviceStatus.ON and set_color == self.state.color ): logger.debug('Device already set to requested color') return True return False