diff --git a/.gitignore b/.gitignore index a13874c9..7414e47e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ docstest/* .claude/settings.local.json .mcp.json .vesync_auth -**/CLAUDE.md \ No newline at end of file +**/CLAUDE.md +/docs/superpowers +/docs/development/reviews diff --git a/README.md b/README.md index 9668a94b..5ceff41b 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ pip install pyvesync 6. Two Plug Outdoor Outlet (ESO15-TB) (Each plug is a separate `VeSyncOutlet` object, energy readings are for both plugs combined) 7. BSDOG / Greensun Smart Outlet Series (BSDOG01, BSDOG02, WYSMTOD16A, WM-PLUG and more) 8. WHOPLUG / Greensun Smart Outlet +9. WYLDR Smart Plug (WYLDR16A1081) (Supports energy monitoring but not energy history) diff --git a/docs/development/utils/colors.md b/docs/development/utils/colors.md index 5b575cf5..cb9bf105 100644 --- a/docs/development/utils/colors.md +++ b/docs/development/utils/colors.md @@ -1,6 +1,6 @@ # Color Handlers -The `pyvesync.utils.colors` module provides classes and functions for handling color conversions and representations. It includes the `Color` class, which serves as a base for color manipulation, and the `HSV` and `RGB` classes for specific color models. The module is designed for internal use within the library and is not intended for public use. +The `pyvesync.utils.colors` module provides classes and functions for handling color conversions and representations. It includes the `Color` class, which serves as a base for color manipulation, and the `HSV` and `RGB` classes for specific color models. The `RGBNightlightColor` class provides the color helpers used by humidifiers with an RGB nightlight. The module is designed for internal use within the library and is not intended for public use. ## Color class @@ -37,3 +37,23 @@ This is the primary class that holds the color data and provides methods for con - "!Config" - "!__post_init__" - "!__str__" + +## RGBNightlightColor class + +Color helpers for devices with an RGB nightlight, such as the OasisMist 4.5L +(`LUH-O451S-WEU`) humidifier. The VeSync app drives the nightlight color from +an 8-color gradient slider and sends brightness-adjusted RGB values (brightness +applied via the HSV value component) rather than a raw color plus a separate +brightness. This class encapsulates that geometry: mapping an arbitrary RGB +color to the slider position (`colorSliderLocation`), applying brightness to a +color, and recovering the full-brightness base color from a dimmed one. + +::: pyvesync.utils.colors.RGBNightlightColor + handler: python + options: + show_root_heading: true + show_source: true + filters: + - "!Config" + - "!^__init*" + - "!__str__" diff --git a/docs/supported_devices.md b/docs/supported_devices.md index 1fbd9d0a..c80bd7e2 100644 --- a/docs/supported_devices.md +++ b/docs/supported_devices.md @@ -14,6 +14,7 @@ The VeSync API supports a variety of devices. The following is a list of devices - [Etekcity 15A Rectangle Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutlet15A] - [Etekcity 15A Outdoor Dual Outlet][pyvesync.devices.vesyncoutlet.VeSyncOutdoorPlug] - [BSDOG / Greensun Smart Outlet Series][pyvesync.devices.vesyncoutlet.VeSyncBSDOGPlug] - WHOPLUG / GREENSUN + - [WYLDR Smart Plug][pyvesync.devices.vesyncoutlet.VeSyncBSDOGPlug] - WYLDR16A1081 (energy monitoring without energy history) 3. Switches - [ESWL01][pyvesync.devices.vesyncswitch.VeSyncWallSwitch] - Etekcity Wall Switch - [ESWL03][pyvesync.devices.vesyncswitch.VeSyncWallSwitch] - Etekcity 3-Way Switch @@ -59,14 +60,21 @@ Switches have minimal features, the dimmer switch is the only switch that has ad ### Outlets -| Device Name | Power Stats | Nightlight | -| :------: | :----: | :----: | -| 7A Round Outlet | ✔ | | -| 10A Round EU Outlet | ✔ | | -| 10A Round US Outlet | | | -| 15A Rectangle Outlet | ✔ | ✔ | -| 15A Outdoor Dual Outlet | ✔ | | -| Round Smart Series | | | +| Device Name | Power Stats | Energy History | Nightlight | +| :------: | :----: | :----: | :----: | +| 7A Round Outlet | ✔ | ✔ | | +| 10A Round EU Outlet | ✔ | ✔ | | +| 10A Round US Outlet | | | | +| 15A Rectangle Outlet | ✔ | ✔ | ✔ | +| 15A Outdoor Dual Outlet | ✔ | ✔ | | +| Smart Plug Series (WHOGPLUG / BSDOG01) | ✔ | ✔ | | +| WYLDR Smart Plug (WYLDR16A1081) | ✔ | | | + +Power stats are realtime power, voltage and energy readings from the device. +Energy history is the weekly, monthly and yearly energy usage retrieved with +`get_weekly_energy()`, `get_monthly_energy()` and `get_yearly_energy()`. Devices +without the energy history feature log a debug message and make no API call when +these methods are used. ### Purifiers @@ -83,15 +91,21 @@ Switches have minimal features, the dimmer switch is the only switch that has ad ### Humidifiers -| Device Name | Night Light | Warm Mist | -| ------ |-------------| ----- | -| Classic 200S | | | -| Classic 300S | ✔ | ✔ | -| Dual 200S | | | -| LV600S | | ✔ | -| OasisMist | | ✔ | -| Superior 6000S | | ✔ | -| Sprout Humidifier | | | +| Device Name | Night Light | RGB Night Light | Warm Mist | +| ------ | ----- | ----- | ----- | +| Classic 200S | | | | +| Classic 300S | ✔ | | ✔ | +| Dual 200S | | | | +| LV600S | | | ✔ | +| OasisMist 4.5L | | ✔ | ✔ | +| Superior 6000S | | | ✔ | +| Sprout Humidifier | | | | + +The OasisMist 4.5L (`LUH-O451S-WEU`) exposes an RGB nightlight through +[`set_rgb_nightlight`][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_rgb_nightlight]. +Other models with the same hardware may work by adding the +`HumidifierFeatures.RGB_NIGHTLIGHT` feature flag, but only the OasisMist 4.5L has +been verified. ### Fans diff --git a/src/pyvesync/base_devices/humidifier_base.py b/src/pyvesync/base_devices/humidifier_base.py index 7b4e5d24..7ccfdd99 100644 --- a/src/pyvesync/base_devices/humidifier_base.py +++ b/src/pyvesync/base_devices/humidifier_base.py @@ -76,6 +76,13 @@ class HumidifierState(DeviceState): 'nightlight_brightness', 'nightlight_color_temp', 'nightlight_status', + 'rgb_nightlight_blue', + 'rgb_nightlight_brightness', + 'rgb_nightlight_color_mode', + 'rgb_nightlight_green', + 'rgb_nightlight_red', + 'rgb_nightlight_set_time', + 'rgb_nightlight_status', 'temperature', 'warm_mist_enabled', 'warm_mist_level', @@ -112,6 +119,13 @@ def __init__( self.mode: str | None = None self.nightlight_brightness: int | None = None self.nightlight_status: str | None = None + self.rgb_nightlight_status: str | None = None + self.rgb_nightlight_brightness: int | None = None + self.rgb_nightlight_red: int | None = None + self.rgb_nightlight_green: int | None = None + self.rgb_nightlight_blue: int | None = None + self.rgb_nightlight_color_mode: str | None = None + self.rgb_nightlight_set_time: float | None = None self.nightlight_color_temp: int | None = None self.warm_mist_enabled: bool | None = None self.warm_mist_level: int | None = None @@ -289,6 +303,15 @@ def supports_nightlight_brightness(self) -> bool: """Return True if the humidifier supports nightlight brightness.""" return HumidifierFeatures.NIGHTLIGHT_BRIGHTNESS in self.features + @property + def supports_rgb_nightlight(self) -> bool: + """Return True if the humidifier supports RGB nightlight. + + Returns: + bool: True if RGB nightlight is supported, False otherwise. + """ + return HumidifierFeatures.RGB_NIGHTLIGHT in self.features + @property def supports_drying_mode(self) -> bool: """Return True if the humidifier supports drying mode.""" @@ -461,6 +484,33 @@ async def toggle_nightlight(self, toggle: bool | None = None) -> bool: logger.error('Nightlight has not been configured.') return False + async def set_rgb_nightlight( + self, + power: bool | None = None, + brightness: int | None = None, + red: int | None = None, + green: int | None = None, + blue: int | None = None, + ) -> bool: + """Set RGB nightlight state and color. + + Args: + power: Turn nightlight on (True) or off (False). + brightness: Brightness level (0-100). + red: Red color value (0-255). + green: Green color value (0-255). + blue: Blue color value (0-255). + + Returns: + bool: Success of request. + """ + del power, brightness, red, green, blue + if not self.supports_rgb_nightlight: + logger.error('RGB Nightlight is not supported for this device.') + return False + logger.error('RGB Nightlight has not been configured.') + return False + async def set_warm_level(self, warm_level: int) -> bool: """Set Humidifier Warm Level. diff --git a/src/pyvesync/base_devices/outlet_base.py b/src/pyvesync/base_devices/outlet_base.py index da97299e..df3d2ec1 100644 --- a/src/pyvesync/base_devices/outlet_base.py +++ b/src/pyvesync/base_devices/outlet_base.py @@ -271,12 +271,24 @@ def supports_energy(self) -> bool: """ return OutletFeatures.ENERGY_MONITOR in self.features + @property + def supports_energy_history(self) -> bool: + """Return True if device supports energy history retrieval. + + Returns: + bool: True if device supports energy history, False otherwise. + """ + return OutletFeatures.ENERGY_HISTORY in self.features + async def get_weekly_energy(self) -> None: """Build weekly energy history dictionary. The data is stored in the `device.state.weekly_history` attribute as a `ResponseEnergyResult` object. """ + if not self.supports_energy_history: + logger.debug('Device does not support energy history.') + return await self._get_energy_history(EnergyIntervals.WEEK) async def get_monthly_energy(self) -> None: @@ -285,6 +297,9 @@ async def get_monthly_energy(self) -> None: The data is stored in the `device.state.monthly_history` attribute as a `ResponseEnergyResult` object. """ + if not self.supports_energy_history: + logger.debug('Device does not support energy history.') + return await self._get_energy_history(EnergyIntervals.MONTH) async def get_yearly_energy(self) -> None: @@ -293,6 +308,9 @@ async def get_yearly_energy(self) -> None: The data is stored in the `device.state.yearly_history` attribute as a `ResponseEnergyResult` object. """ + if not self.supports_energy_history: + logger.debug('Device does not support energy history.') + return await self._get_energy_history(EnergyIntervals.YEAR) async def update_energy(self) -> None: diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index a0571b43..eb6e02f9 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -74,6 +74,10 @@ KELVIN_MIN = 2700 KELVIN_MAX = 6500 +# RGB nightlight constants +RGB_STALE_DATA_TIMEOUT = 180 # Seconds to ignore stale API data after setting values +RGB_FULL_BRIGHTNESS = 100 # Full brightness percentage + class ProductLines(StrEnum): """High level product line.""" @@ -497,6 +501,7 @@ class HumidifierFeatures(Features): WARM_MIST: Warm mist status. AUTO_STOP: Auto stop when target humidity is reached. Different from auto, which adjusts fan level to maintain humidity. + RGB_NIGHTLIGHT: RGB nightlight with color control. """ ONOFF = 'onoff' @@ -507,6 +512,7 @@ class HumidifierFeatures(Features): AUTO_STOP = 'auto_stop' NIGHTLIGHT_BRIGHTNESS = 'nightlight_brightness' DRYING_MODE = 'drying_mode' + RGB_NIGHTLIGHT = 'rgb_nightlight' class PurifierFeatures(Features): @@ -573,11 +579,13 @@ class OutletFeatures(Features): Attributes: ONOFF: Device on/off status. ENERGY_MONITOR: Energy monitor status. + ENERGY_HISTORY: Energy history retrieval support. NIGHTLIGHT: Nightlight status. """ ONOFF = 'onoff' ENERGY_MONITOR = 'energy_monitor' + ENERGY_HISTORY = 'energy_history' NIGHTLIGHT = 'nightlight' diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index d467eb12..2e453734 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -416,7 +416,7 @@ class ThermostatMap(DeviceMapTemplate): OutletMap( dev_types=['wifi-switch-1.3'], class_name='VeSyncOutlet7A', - features=[OutletFeatures.ENERGY_MONITOR], + features=[OutletFeatures.ENERGY_MONITOR, OutletFeatures.ENERGY_HISTORY], model_name='WiFi Outlet US/CA', model_display='ESW01-USA Series', setup_entry='wifi-switch-1.3', @@ -432,7 +432,7 @@ class ThermostatMap(DeviceMapTemplate): OutletMap( dev_types=['ESW01-EU', 'ESW01-USA', 'ESW03-USA', 'ESW03-EU'], class_name='VeSyncOutlet10A', - features=[OutletFeatures.ENERGY_MONITOR], + features=[OutletFeatures.ENERGY_MONITOR, OutletFeatures.ENERGY_HISTORY], model_name='ESW03 10A WiFi Outlet', model_display='ESW01/03 USA/EU', setup_entry='ESW03', @@ -440,7 +440,11 @@ class ThermostatMap(DeviceMapTemplate): OutletMap( dev_types=['ESW15-USA'], class_name='VeSyncOutlet15A', - features=[OutletFeatures.ENERGY_MONITOR, OutletFeatures.NIGHTLIGHT], + features=[ + OutletFeatures.ENERGY_MONITOR, + OutletFeatures.ENERGY_HISTORY, + OutletFeatures.NIGHTLIGHT, + ], nightlight_modes=[NightlightModes.ON, NightlightModes.OFF, NightlightModes.AUTO], model_name='15A WiFi Outlet US/CA', model_display='ESW15-USA Series', @@ -449,7 +453,7 @@ class ThermostatMap(DeviceMapTemplate): OutletMap( dev_types=['ESO15-TB'], class_name='VeSyncOutdoorPlug', - features=[OutletFeatures.ENERGY_MONITOR], + features=[OutletFeatures.ENERGY_MONITOR, OutletFeatures.ENERGY_HISTORY], model_name='Outdoor Plug', model_display='ESO15-TB Series', setup_entry='ESO15-TB', @@ -459,7 +463,11 @@ class ThermostatMap(DeviceMapTemplate): 'WHOGPLUG', ], class_name='VeSyncOutletWHOGPlug', - features=[OutletFeatures.ONOFF, OutletFeatures.ENERGY_MONITOR], + features=[ + OutletFeatures.ONOFF, + OutletFeatures.ENERGY_MONITOR, + OutletFeatures.ENERGY_HISTORY, + ], model_name='Smart Plug', model_display='Smart Plug Series', setup_entry='WHOGPLUG', @@ -478,12 +486,25 @@ class ThermostatMap(DeviceMapTemplate): 'HWPLUG16', ], class_name='VeSyncBSDOGPlug', - features=[OutletFeatures.ONOFF, OutletFeatures.ENERGY_MONITOR], + features=[ + OutletFeatures.ONOFF, + OutletFeatures.ENERGY_MONITOR, + OutletFeatures.ENERGY_HISTORY, + ], model_name='Smart Plug', model_display='Smart Plug Series', setup_entry='BSDOG01', device_alias='Smart Plug Series', ), + OutletMap( + dev_types=['WYLDR16A1081'], + class_name='VeSyncBSDOGPlug', + features=[OutletFeatures.ONOFF, OutletFeatures.ENERGY_MONITOR], + model_name='Smart Plug', + model_display='WYLDR Smart Plug', + setup_entry='WYLDR16A1081', + device_alias='WYLDR Smart Plug', + ), ] """List of ['OutletMap'][pyvesync.device_map.OutletMap] configuration objects for outlet devices.""" @@ -684,7 +705,11 @@ class ThermostatMap(DeviceMapTemplate): HumidifierMap( class_name='VeSyncHumid200300S', dev_types=['LUH-O451S-WEU'], - features=[HumidifierFeatures.WARM_MIST, HumidifierFeatures.AUTO_STOP], + features=[ + HumidifierFeatures.WARM_MIST, + HumidifierFeatures.AUTO_STOP, + HumidifierFeatures.RGB_NIGHTLIGHT, + ], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', @@ -699,13 +724,17 @@ class ThermostatMap(DeviceMapTemplate): ), HumidifierMap( class_name='VeSyncHumid200300S', - dev_types=['LUH-O451S-WUS', 'LUH-O451S-WUSR', 'LUH-O601S-WUS', 'LUH-O601S-KUS'], + # configModule WFON_AHM_LUH-A451S-WUS_US only accepts a target humidity + # range of 40-80 (the cloud API rejects 30 with "target humidity is out + # of range") and does not support the HUMIDITY mode (rejected with + # "Mode value invaild!"), unlike the WUSR/601S variants below -- see + # https://github.com/webdjoe/pyvesync/issues/295 and #296. + dev_types=['LUH-O451S-WUS'], features=[HumidifierFeatures.WARM_MIST, HumidifierFeatures.AUTO_STOP], mist_modes={ HumidifierModes.AUTO: 'auto', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', - HumidifierModes.HUMIDITY: 'humidity', }, mist_levels=list(range(1, 10)), warm_mist_levels=list(range(4)), @@ -713,6 +742,24 @@ class ThermostatMap(DeviceMapTemplate): model_display='OasisMist 4.5L Series', model_name='OasisMist 4.5L', setup_entry='LUH-O451S-WUS', + target_minmax=(40, 80), + ), + HumidifierMap( + class_name='VeSyncHumid200300S', + dev_types=['LUH-O451S-WUSR', 'LUH-O601S-WUS', 'LUH-O601S-KUS'], + features=[HumidifierFeatures.WARM_MIST, HumidifierFeatures.AUTO_STOP], + mist_modes={ + HumidifierModes.AUTO: 'auto', + HumidifierModes.SLEEP: 'sleep', + HumidifierModes.MANUAL: 'manual', + HumidifierModes.HUMIDITY: 'humidity', + }, + mist_levels=list(range(1, 10)), + warm_mist_levels=list(range(4)), + device_alias='OasisMist 450S', + model_display='OasisMist 4.5L Series', + model_name='OasisMist 4.5L', + setup_entry='LUH-O451S-WUSR', ), HumidifierMap( class_name='VeSyncHumid1000S', diff --git a/src/pyvesync/devices/vesynchumidifier.py b/src/pyvesync/devices/vesynchumidifier.py index 74df19da..99951bd3 100644 --- a/src/pyvesync/devices/vesynchumidifier.py +++ b/src/pyvesync/devices/vesynchumidifier.py @@ -3,19 +3,27 @@ from __future__ import annotations import logging +import time from typing import TYPE_CHECKING import orjson from typing_extensions import deprecated from pyvesync.base_devices.humidifier_base import BreathingLampState, VeSyncHumidifier -from pyvesync.const import ConnectionStatus, DeviceStatus, DryingModes +from pyvesync.const import ( + RGB_FULL_BRIGHTNESS, + RGB_STALE_DATA_TIMEOUT, + ConnectionStatus, + DeviceStatus, + DryingModes, +) from pyvesync.models import humidifier_models as models from pyvesync.models.bypass_models import ( ResultV2GetTimer, ResultV2GetTimerV2, ResultV2SetTimer, ) +from pyvesync.utils.colors import RGBNightlightColor from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_result from pyvesync.utils.helpers import Helpers, Timer, Validators @@ -97,12 +105,48 @@ def _set_state(self, resp_model: models.ClassicLVHumidResult) -> None: if self.supports_warm_mist and resp_model.warm_level is not None: self.state.warm_mist_level = resp_model.warm_level self.state.warm_mist_enabled = resp_model.warm_enabled + if self.supports_rgb_nightlight and resp_model.rgbNightLight is not None: + self._set_rgb_nightlight_state(resp_model.rgbNightLight) + config = resp_model.configuration if config is not None: self.state.auto_target_humidity = config.auto_target_humidity self.state.automatic_stop_config = config.automatic_stop self.state.display_set_status = DeviceStatus.from_bool(config.display) + def _set_rgb_nightlight_state(self, rgb: models.RGBNightLight) -> None: + # Skip updating RGB nightlight state if we recently set it. The + # VeSync API returns stale data for several minutes after setting + # values, so we ignore updates briefly after a set command. + if ( + self.state.rgb_nightlight_set_time is not None + and (time.time() - self.state.rgb_nightlight_set_time) + < RGB_STALE_DATA_TIMEOUT + ): + return + + self.state.rgb_nightlight_status = rgb.action + self.state.rgb_nightlight_brightness = rgb.brightness + self.state.rgb_nightlight_color_mode = rgb.colorMode + # The API uses brightness-adjusted RGB values. Store the base color + # at full brightness so that changing only brightness doesn't drift. + brightness_adjusted = ( + rgb.brightness is not None and 0 < rgb.brightness < RGB_FULL_BRIGHTNESS + ) + if brightness_adjusted: + base_r, base_g, base_b = RGBNightlightColor.normalize_to_full_brightness( + rgb.red, rgb.green, rgb.blue + ) + self.state.rgb_nightlight_red = base_r + self.state.rgb_nightlight_green = base_g + self.state.rgb_nightlight_blue = base_b + else: + self.state.rgb_nightlight_red = rgb.red + self.state.rgb_nightlight_green = rgb.green + self.state.rgb_nightlight_blue = rgb.blue + # Clear the set time since we've now received valid data from API + self.state.rgb_nightlight_set_time = None + async def get_details(self) -> None: r_dict = await self.call_bypassv2_api('getHumidifierStatus') r_model = process_bypassv2_result( @@ -283,6 +327,121 @@ async def toggle_nightlight(self, toggle: bool | None = None) -> bool: brightness = 100 if toggle else 0 return await self.set_nightlight_brightness(brightness) + async def set_rgb_nightlight( # noqa: C901, PLR0912 + self, + power: bool | None = None, + brightness: int | None = None, + red: int | None = None, + green: int | None = None, + blue: int | None = None, + ) -> bool: + """Set RGB nightlight state and color. + + Args: + power: Turn nightlight on (True) or off (False). + brightness: Brightness level (0-100); values below 40 are raised to + the device minimum of 40. Out-of-range values are rejected. + red: Red color value (0-255). + green: Green color value (0-255). + blue: Blue color value (0-255). + + Returns: + bool: Success of request. + """ + if not self.supports_rgb_nightlight: + logger.warning('RGB Nightlight is not supported for %s', self.device_name) + return False + + # API requires all fields, so use current state for any not provided. + if power is not None: + action = 'on' if power else 'off' + else: + action = self.state.rgb_nightlight_status or 'on' + turning_off = action == 'off' + + # Coalesce with `is None` so a legitimately stored 0 channel survives. + if brightness is None: + brightness = ( + self.state.rgb_nightlight_brightness + if self.state.rgb_nightlight_brightness is not None + else 40 + ) + if red is None: + red = ( + self.state.rgb_nightlight_red + if self.state.rgb_nightlight_red is not None + else 255 + ) + if green is None: + green = ( + self.state.rgb_nightlight_green + if self.state.rgb_nightlight_green is not None + else 255 + ) + if blue is None: + blue = ( + self.state.rgb_nightlight_blue + if self.state.rgb_nightlight_blue is not None + else 255 + ) + color_mode = self.state.rgb_nightlight_color_mode or 'color' + + # Validate and reject rather than silently clamping bad input. + if not Validators.validate_range(brightness, 0, 100): + logger.warning('Brightness must be between 0 and 100') + return False + if not Validators.validate_rgb(red, green, blue): + logger.warning('RGB values must be between 0 and 255') + return False + + # Device minimum brightness is 40 per the VeSync app. + brightness = max(40, brightness) + + # Calculate colorSliderLocation from the base RGB color (full brightness). + color_slider_location = RGBNightlightColor.rgb_to_color_slider_location( + red, green, blue + ) + + # The VeSync app sends brightness-adjusted RGB, not raw color + brightness. + # From decompiled app: yv/p.java method b() and RGBNightLightView.java + if brightness != RGB_FULL_BRIGHTNESS: + adj_red, adj_green, adj_blue = RGBNightlightColor.apply_brightness_to_rgb( + red, green, blue, brightness + ) + else: + adj_red, adj_green, adj_blue = red, green, blue + + payload_data: dict[str, int | str] = { + 'action': action, + 'brightness': brightness, + 'red': adj_red, + 'green': adj_green, + 'blue': adj_blue, + 'colorMode': color_mode, + 'speed': 0, + 'colorSliderLocation': color_slider_location, + } + + r_dict = await self.call_bypassv2_api('setLightStatus', payload_data) + r = Helpers.process_dev_response(logger, 'set_rgb_nightlight', self, r_dict) + if r is None: + return False + + # process_dev_response already set connection_status; update local state + # and record the timestamp used to ignore stale API responses briefly. + self.state.rgb_nightlight_status = action + # An off command must not overwrite the stored color/brightness so the + # previous setting is restored on the next power-on. + if not turning_off: + self.state.rgb_nightlight_brightness = brightness + self.state.rgb_nightlight_red = red + self.state.rgb_nightlight_green = green + self.state.rgb_nightlight_blue = blue + self.state.rgb_nightlight_color_mode = color_mode + self.state.rgb_nightlight_set_time = time.time() + + return True + async def set_mode(self, mode: str) -> bool: if mode not in self.mist_modes: logger.warning('Invalid humidity mode used - %s', mode) @@ -1101,8 +1260,9 @@ async def _set_nightlight_state( if self.state.nightlight_color_temp is None: self.state.nightlight_color_temp = 3500 # Default color temp if not set + sent_brightness = brightness or self.state.nightlight_brightness or 100 payload_data = { - 'brightness': brightness or self.state.nightlight_brightness, + 'brightness': sent_brightness, 'colorTemperature': color_temp or self.state.nightlight_color_temp, 'nightLightSwitch': int(toggle), } @@ -1111,11 +1271,40 @@ async def _set_nightlight_state( if r is None: return False - self.state.nightlight_brightness = brightness + self.state.nightlight_brightness = sent_brightness self.state.nightlight_status = DeviceStatus.from_bool(toggle) self.state.connection_status = ConnectionStatus.ONLINE return True + async def set_nightlight_brightness(self, brightness: int) -> bool: + if not self.supports_nightlight_brightness: + logger.warning( + '%s is a %s does not have a nightlight or it is not supported.', + self.device_name, + self.device_type, + ) + return False + + if not Validators.validate_zero_to_hundred(brightness): + logger.warning('Brightness value must be set between 0 and 100') + return False + + toggle = brightness > 0 + return await self._set_nightlight_state(toggle, brightness=brightness) + + async def toggle_nightlight(self, toggle: bool | None = None) -> bool: + if not self.supports_nightlight: + logger.warning( + '%s is a %s does not have a nightlight or it is not supported.', + self.device_name, + self.device_type, + ) + return False + + if toggle is None: + toggle = self.state.nightlight_status != DeviceStatus.ON + return await self._set_nightlight_state(toggle) + async def toggle_automatic_stop(self, toggle: bool | None = None) -> bool: if toggle is None: toggle = self.state.automatic_stop_config is not True diff --git a/src/pyvesync/devices/vesyncoutlet.py b/src/pyvesync/devices/vesyncoutlet.py index 994d6975..d0338e07 100644 --- a/src/pyvesync/devices/vesyncoutlet.py +++ b/src/pyvesync/devices/vesyncoutlet.py @@ -819,6 +819,9 @@ async def toggle_switch(self, toggle: bool | None = None) -> bool: async def _get_energy_history(self, history_interval: str | EnergyIntervals) -> None: """Get energy history for BSDGO1 outlet.""" + if not self.supports_energy_history: + logger.debug('Device does not support energy history.') + return if history_interval not in self._energy_intervals: logger.error('Invalid energy history interval - %s', history_interval) return @@ -850,6 +853,9 @@ async def _get_energy_history(self, history_interval: str | EnergyIntervals) -> async def get_yearly_energy(self) -> None: """Get yearly energy for WHOG outlet.""" + if not self.supports_energy_history: + logger.debug('Device does not support energy history.') + return r_dict = await self._bypass_v1_api_helper( RequestWHOGYearlyEnergy, method='getELECConsumePerMonthLastYear' ) @@ -894,6 +900,10 @@ def end_of_month_utc_timestamp(year: int, month: int) -> int: class VeSyncBSDOGPlug(VeSyncOutletWHOGPlug): """VeSync BSDOG01/WYZYOG smart plugs. + Also used by the WYLDR16A1081 smart plug, which shares the same API but + does not support energy history retrieval (its device map entry omits + the `OutletFeatures.ENERGY_HISTORY` feature). + Args: details (ResponseDeviceDetailsModel): The device details. manager (VeSync): The VeSync manager. diff --git a/src/pyvesync/models/humidifier_models.py b/src/pyvesync/models/humidifier_models.py index 93cac405..51424ce4 100644 --- a/src/pyvesync/models/humidifier_models.py +++ b/src/pyvesync/models/humidifier_models.py @@ -61,6 +61,20 @@ class BypassV2InnerErrorResult(InnerHumidifierBaseResult): # The correct subclass is determined by the mashumaro discriminator +@dataclass +class RGBNightLight(ResponseBaseModel): + """RGB Night Light Model for Humidifiers.""" + + action: str + colorMode: str + brightness: int + red: int + green: int + blue: int + speed: int = 0 + colorSliderLocation: int = 0 + + @dataclass class ClassicLVHumidResult(InnerHumidifierBaseResult): """Classic 200S Humidifier Result Model. @@ -82,6 +96,7 @@ class ClassicLVHumidResult(InnerHumidifierBaseResult): warm_level: int | None = None night_light_brightness: int | None = None configuration: ClassicConfig | None = None + rgbNightLight: RGBNightLight | None = None @dataclass diff --git a/src/pyvesync/utils/colors.py b/src/pyvesync/utils/colors.py index 84b31b78..28ef08e5 100644 --- a/src/pyvesync/utils/colors.py +++ b/src/pyvesync/utils/colors.py @@ -5,6 +5,7 @@ import colorsys import logging from dataclasses import InitVar, dataclass +from typing import ClassVar from pyvesync.utils.helpers import Validators @@ -248,3 +249,129 @@ def rgb_to_hsv(red: float, green: float, blue: float) -> HSV: float(round(hsv_tuple[1] * hsv_factors[1], 2)), float(round(hsv_tuple[2] * hsv_factors[2], 0)), ) + + +class RGBNightlightColor: + """Color helpers for RGB nightlight devices. + + Encapsulates the 8-color gradient used by the VeSync app for the RGB + nightlight color slider, along with the geometry needed to map an + arbitrary RGB color to a slider position and to apply or recover + brightness. + """ + + # 8-color gradient used by VeSync app for RGB nightlight color slider + GRADIENT: ClassVar[list[tuple[int, int, int]]] = [ + (252, 50, 0), # #fc3200 - Red (position 0) + (255, 171, 2), # #ffab02 - Orange (position ~14.3) + (181, 255, 0), # #b5ff00 - Yellow-Green (position ~28.6) + (2, 255, 120), # #02ff78 - Green (position ~42.9) + (3, 200, 254), # #03c8fe - Cyan (position ~57.1) + (0, 40, 255), # #0028ff - Blue (position ~71.4) + (220, 0, 255), # #dc00ff - Purple (position ~85.7) + (254, 0, 60), # #fe003c - Pink/Red (position 100) + ] + + @staticmethod + def apply_brightness_to_rgb( + red: int, green: int, blue: int, brightness: int + ) -> tuple[int, int, int]: + """Apply brightness to RGB color using HSV color space. + + The VeSync app applies brightness by converting to HSV, setting the V + (value) component to brightness/100, then converting back to RGB. + + From decompiled app: yv/p.java method b() + + Args: + red: Red value (0-255). + green: Green value (0-255). + blue: Blue value (0-255). + brightness: Brightness level (0-100). + + Returns: + tuple: Brightness-adjusted (red, green, blue) values. + """ + if max(red, green, blue) == 0: + return (0, 0, 0) + h, s, _ = colorsys.rgb_to_hsv(red / 255.0, green / 255.0, blue / 255.0) + v = brightness / 100.0 + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return (round(r * 255), round(g * 255), round(b * 255)) + + @staticmethod + def normalize_to_full_brightness( + red: int, green: int, blue: int + ) -> tuple[int, int, int]: + """Normalize brightness-adjusted RGB back to full brightness (100%). + + Inverse of `apply_brightness_to_rgb`. Given RGB values that have been + dimmed, recover the original "full brightness" color by setting HSV + value to 1.0 while preserving hue and saturation. + + Args: + red: Red value (0-255), brightness-adjusted. + green: Green value (0-255), brightness-adjusted. + blue: Blue value (0-255), brightness-adjusted. + + Returns: + tuple: Normalized (red, green, blue) values at full brightness. + """ + if max(red, green, blue) == 0: + return (0, 0, 0) + h, s, _ = colorsys.rgb_to_hsv(red / 255.0, green / 255.0, blue / 255.0) + v = 1.0 + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return (round(r * 255), round(g * 255), round(b * 255)) + + @classmethod + def rgb_to_color_slider_location(cls, red: int, green: int, blue: int) -> int: + """Convert RGB values to colorSliderLocation (0-100). + + The VeSync app uses an 8-color gradient for the color slider. This + finds the closest position on that gradient by checking each segment + and finding where the input color best fits. + + Note: Input RGB should be at full brightness for accurate results. + If the input has reduced brightness, first normalize it. + + From decompiled app: yv/p.java (HumidifierColor.kt) + + Args: + red: Red value (0-255). + green: Green value (0-255). + blue: Blue value (0-255). + + Returns: + int: Color slider location (0-100). + """ + gradient = cls.GRADIENT + num_colors = len(gradient) + segment_size = 100.0 / (num_colors - 1) + + best_position = 0.0 + best_distance_sq = float('inf') + + for i in range(num_colors - 1): + ax, ay, az = gradient[i] + bx, by, bz = gradient[i + 1] + dx, dy, dz = bx - ax, by - ay, bz - az + seg_len_sq = dx * dx + dy * dy + dz * dz + if seg_len_sq == 0: + fraction = 0.0 + else: + fraction = ( + (red - ax) * dx + (green - ay) * dy + (blue - az) * dz + ) / seg_len_sq + fraction = max(0.0, min(1.0, fraction)) + + cx = ax + dx * fraction + cy = ay + dy * fraction + cz = az + dz * fraction + distance_sq = (red - cx) ** 2 + (green - cy) ** 2 + (blue - cz) ** 2 + + if distance_sq < best_distance_sq: + best_distance_sq = distance_sq + best_position = i * segment_size + fraction * segment_size + + return round(best_position) diff --git a/src/tests/api/vesynchumidifier/LEH-B381S.yaml b/src/tests/api/vesynchumidifier/LEH-B381S.yaml index 9d64e8b5..03e1ab8e 100644 --- a/src/tests/api/vesynchumidifier/LEH-B381S.yaml +++ b/src/tests/api/vesynchumidifier/LEH-B381S.yaml @@ -108,6 +108,35 @@ set_mist_level: userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 +set_nightlight_brightness: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LEH-B381S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-B381S-CID + method: bypassV2 + payload: + data: + brightness: 50 + colorTemperature: 3500 + nightLightSwitch: 1 + method: setLightStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 turn_off: headers: Content-Type: application/json; charset=UTF-8 @@ -190,6 +219,35 @@ turn_off_display: userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 +turn_off_nightlight: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LEH-B381S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-B381S-CID + method: bypassV2 + payload: + data: + brightness: 100 + colorTemperature: 3500 + nightLightSwitch: 0 + method: setLightStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 turn_on: headers: Content-Type: application/json; charset=UTF-8 @@ -272,6 +330,35 @@ turn_on_display: userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 +turn_on_nightlight: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LEH-B381S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-B381S-CID + method: bypassV2 + payload: + data: + brightness: 100 + colorTemperature: 3500 + nightLightSwitch: 1 + method: setLightStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 update: headers: Content-Type: application/json; charset=UTF-8 diff --git a/src/tests/api/vesynchumidifier/LUH-O451S-WEU.yaml b/src/tests/api/vesynchumidifier/LUH-O451S-WEU.yaml index 2581f883..4a97a739 100644 --- a/src/tests/api/vesynchumidifier/LUH-O451S-WEU.yaml +++ b/src/tests/api/vesynchumidifier/LUH-O451S-WEU.yaml @@ -108,6 +108,40 @@ set_mist_level: userCountryCode: US method: post url: /cloud/v2/deviceManaged/bypassV2 +set_rgb_nightlight: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WEU-CID + method: bypassV2 + payload: + data: + action: 'on' + blue: 0 + brightness: 100 + colorMode: color + colorSliderLocation: 0 + green: 50 + red: 252 + speed: 0 + method: setLightStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 turn_off: headers: Content-Type: application/json; charset=UTF-8 diff --git a/src/tests/api/vesynchumidifier/LUH-O451S-WUSR.yaml b/src/tests/api/vesynchumidifier/LUH-O451S-WUSR.yaml new file mode 100644 index 00000000..75aedc66 --- /dev/null +++ b/src/tests/api/vesynchumidifier/LUH-O451S-WUSR.yaml @@ -0,0 +1,300 @@ +set_auto_mode: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + mode: auto + method: setHumidityMode + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +set_humidity: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + target_humidity: 50 + method: setTargetHumidity + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +set_manual_mode: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + mode: manual + method: setHumidityMode + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +set_mist_level: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + id: 0 + level: 2 + type: mist + method: setVirtualLevel + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_off: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + enabled: false + id: 0 + method: setSwitch + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_off_automatic_stop: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + enabled: false + method: setAutomaticStop + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_off_display: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + state: false + method: setDisplay + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_on: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + enabled: true + id: 0 + method: setSwitch + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_on_automatic_stop: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + enabled: true + method: setAutomaticStop + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_on_display: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: + state: true + method: setDisplay + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: LUH-O451S-WUSR-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-O451S-WUSR-CID + method: bypassV2 + payload: + data: {} + method: getHumidifierStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/api/vesyncoutlet/WYLDR16A1081.yaml b/src/tests/api/vesyncoutlet/WYLDR16A1081.yaml new file mode 100644 index 00000000..4dce7153 --- /dev/null +++ b/src/tests/api/vesyncoutlet/WYLDR16A1081.yaml @@ -0,0 +1,91 @@ +turn_off: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: WYLDR16A1081-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: WYLDR16A1081-CID + method: bypassV2 + payload: + data: + powerSwitch_1: 0 + method: setProperty + source: APP + subDeviceNo: 0 + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +turn_on: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: WYLDR16A1081-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: WYLDR16A1081-CID + method: bypassV2 + payload: + data: + powerSwitch_1: 1 + method: setProperty + source: APP + subDeviceNo: 0 + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: WYLDR16A1081-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: WYLDR16A1081-CID + method: bypassV2 + payload: + data: + properties: + - powerSwitch_1 + - realTimeVoltage + - realTimePower + - electricalEnergy + - protectionStatus + - voltageUpperThreshold + - currentUpperThreshold + - scheduleNum + method: getProperty + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/base_test_cases.py b/src/tests/base_test_cases.py index c12dd607..dda5a6f4 100644 --- a/src/tests/base_test_cases.py +++ b/src/tests/base_test_cases.py @@ -40,7 +40,7 @@ def setup(self, caplog): self.manager.auth._account_id = TestDefaults.account_id yield self.mock.stop() - self.loop.stop() + self.loop.close() async def run_coro(self, coro): """Run a coroutine in the event loop.""" diff --git a/src/tests/call_json_humidifiers.py b/src/tests/call_json_humidifiers.py index 7b4e68f3..fb628b4b 100644 --- a/src/tests/call_json_humidifiers.py +++ b/src/tests/call_json_humidifiers.py @@ -70,6 +70,13 @@ class HumidifierDefaults: nightlight_status = DeviceStatus.OFF nightlight_brightness = 0 nightlight_color_temperature = 4000 + # RGB Nightlight Defaults + rgb_nightlight_status = "on" + rgb_nightlight_color_mode = "color" + rgb_nightlight_brightness = 100 + rgb_nightlight_red = 252 + rgb_nightlight_green = 50 + rgb_nightlight_blue = 0 # Drying Defaults drying_level = 1 drying_state = DryingModes.DONE @@ -190,6 +197,16 @@ class HumidifierDefaults: "warm_enabled": HumidifierDefaults.warm_mist_enabled, "warm_level": HumidifierDefaults.warm_mist_level, "automatic_stop_reach_target": HumidifierDefaults.auto_stop_reached, + "rgbNightLight": { + "action": HumidifierDefaults.rgb_nightlight_status, + "colorMode": HumidifierDefaults.rgb_nightlight_color_mode, + "brightness": HumidifierDefaults.rgb_nightlight_brightness, + "red": HumidifierDefaults.rgb_nightlight_red, + "green": HumidifierDefaults.rgb_nightlight_green, + "blue": HumidifierDefaults.rgb_nightlight_blue, + "speed": 0, + "colorSliderLocation": 0, + }, "configuration": { "auto_target_humidity": HumidifierDefaults.target_humidity, "display": HumidifierDefaults.display_config, @@ -335,6 +352,7 @@ class HumidifierDefaults: "warmLevel": HumidifierDefaults.warm_mist_level, }, } +HUMIDIFIER_DETAILS["LUH-O451S-WUSR"] = deepcopy(HUMIDIFIER_DETAILS["LUH-O451S-WUS"]) """This dictionary contains the details response for each humidifier. It stores the innermost result that is passed to the DETAILS_RESPONSE variable where @@ -348,6 +366,7 @@ class HumidifierDefaults: "LUH-A602S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-A602S-WUS"]), "LUH-A603S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-A603S-WUS"]), "LUH-O451S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-O451S-WUS"]), + "LUH-O451S-WUSR": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-O451S-WUSR"]), "LUH-O451S-WEU": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-O451S-WEU"]), "LUH-M101S-WEUR": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-M101S-WEUR"]), "LUH-M101S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-M101-WUS"]), @@ -373,6 +392,7 @@ class HumidifierDefaults: "LUH-A602S-WUS": deepcopy(FunctionResponsesV2), "LUH-A603S-WUS": deepcopy(FunctionResponsesV2), "LUH-O451S-WUS": deepcopy(FunctionResponsesV2), + "LUH-O451S-WUSR": deepcopy(FunctionResponsesV2), "LUH-O451S-WEU": deepcopy(FunctionResponsesV2), "LUH-M101S-WEUR": deepcopy(FunctionResponsesV2), "LUH-M101S-WUS": deepcopy(FunctionResponsesV2), diff --git a/src/tests/call_json_outlets.py b/src/tests/call_json_outlets.py index 7838f7d6..d9812c1f 100644 --- a/src/tests/call_json_outlets.py +++ b/src/tests/call_json_outlets.py @@ -164,6 +164,7 @@ class OutletDefaults: "ESO15-TB": build_bypass_v1_response(result_dict=OUTLET_DETAILS["ESO15-TB"]), "BSDOG01": build_bypass_v2_response(inner_result=OUTLET_DETAILS["BSDOG01"]), "WHOGPLUG": build_bypass_v2_response(inner_result=OUTLET_DETAILS["WHOGPLUG"]), + "WYLDR16A1081": build_bypass_v2_response(inner_result=OUTLET_DETAILS["BSDOG01"]), } @@ -230,10 +231,11 @@ class OutletDefaults: "ESO15-TB": deepcopy(FunctionResponsesV1), "BSDOG01": deepcopy(FunctionResponsesV2), "WHOGPLUG": deepcopy(FunctionResponsesV2), + "WYLDR16A1081": deepcopy(FunctionResponsesV2), } for k in METHOD_RESPONSES: - if k in ["ESW10-USA"]: + if k in ["ESW10-USA", "WYLDR16A1081"]: METHOD_RESPONSES[k]["get_weekly_energy"] = None METHOD_RESPONSES[k]["get_monthly_energy"] = None METHOD_RESPONSES[k]["get_yearly_energy"] = None diff --git a/src/tests/test_colors.py b/src/tests/test_colors.py new file mode 100644 index 00000000..bd5f15b1 --- /dev/null +++ b/src/tests/test_colors.py @@ -0,0 +1,46 @@ +"""Unit tests for pyvesync.utils.colors.RGBNightlightColor.""" + +import pytest + +from pyvesync.utils.colors import RGBNightlightColor + + +def test_normalize_black_stays_black(): + """Black (0,0,0) must not normalize to white.""" + assert RGBNightlightColor.normalize_to_full_brightness(0, 0, 0) == (0, 0, 0) + + +def test_apply_brightness_black_stays_black(): + """Applying brightness to black must stay black, not become gray.""" + assert RGBNightlightColor.apply_brightness_to_rgb(0, 0, 0, 50) == (0, 0, 0) + + +def test_normalize_recovers_full_brightness_color(): + """A saturated hue dimmed then normalized recovers a max-channel of 255.""" + dimmed = RGBNightlightColor.apply_brightness_to_rgb(252, 50, 0, 40) + base = RGBNightlightColor.normalize_to_full_brightness(*dimmed) + assert max(base) == 255 + + +def test_apply_brightness_rounds_not_truncates(): + """round() keeps the green channel at 128 where int() would give 127.""" + # (255,128,64) scaled to value 0.502 -> green 128.5 rounds to 128 (int->127 earlier) + red, green, blue = RGBNightlightColor.apply_brightness_to_rgb(255, 129, 64, 51) + assert green == 66 # round(129/255*0.51*255) == round(65.79) == 66 + + +@pytest.mark.parametrize( + 'index', + list(range(len(RGBNightlightColor.GRADIENT))), +) +def test_slider_anchor_maps_to_its_position(index): + """Each gradient anchor maps to its evenly-spaced slider position.""" + red, green, blue = RGBNightlightColor.GRADIENT[index] + expected = round(index * (100.0 / (len(RGBNightlightColor.GRADIENT) - 1))) + assert RGBNightlightColor.rgb_to_color_slider_location(red, green, blue) == expected + + +def test_slider_location_within_bounds(): + """Arbitrary colours map inside the 0..100 slider range.""" + loc = RGBNightlightColor.rgb_to_color_slider_location(10, 200, 30) + assert 0 <= loc <= 100 diff --git a/src/tests/test_humidifiers.py b/src/tests/test_humidifiers.py index 25ea06ac..4f12a537 100644 --- a/src/tests/test_humidifiers.py +++ b/src/tests/test_humidifiers.py @@ -33,7 +33,7 @@ from pyvesync.base_devices.humidifier_base import VeSyncHumidifier from base_test_cases import TestBase from utils import assert_test, parse_args -from defaults import TestDefaults +from defaults import TestDefaults, build_bypass_v2_response import call_json_humidifiers @@ -121,6 +121,14 @@ class TestHumidifiers(TestBase): "LUH-A602S-WUS": [["set_warm_level", {"warm_level": 3}]], "LUH-A603S-WUS": [["set_warm_level", {"warm_level": 3}]], "LEH-S601S": [["turn_off_drying_mode"]], + "LEH-B381S": [ + ["turn_on_nightlight"], + ["turn_off_nightlight"], + ["set_nightlight_brightness", {"brightness": 50}], + ], + "LUH-O451S-WEU": [ + ["set_rgb_nightlight", {"power": True, "brightness": 100, "red": 252, "green": 50, "blue": 0}], + ], } def test_details(self, setup_entry, method): @@ -282,3 +290,126 @@ def test_methods(self, setup_entry, method): assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) + + RGB_DEVICE = "LUH-O451S-WEU" + + def _rgb_success(self): + """Return a successful BypassV2 envelope for set calls.""" + return (build_bypass_v2_response(inner_result={}), 200) + + def test_set_rgb_preserves_zero_channel(self): + """A stored 0 channel must not be replaced by 255 on a partial update.""" + self.mock_api.return_value = self._rgb_success() + obj = self.get_device("humidifiers", self.RGB_DEVICE) + obj.state.rgb_nightlight_red = 0 + obj.state.rgb_nightlight_green = 255 + obj.state.rgb_nightlight_blue = 0 + result = self.run_in_loop(obj.set_rgb_nightlight, brightness=50) + assert result is True + assert obj.state.rgb_nightlight_red == 0 + assert obj.state.rgb_nightlight_blue == 0 + + def test_set_rgb_rejects_out_of_range_rgb(self): + """Out-of-range RGB is rejected, not silently clamped.""" + self.mock_api.return_value = self._rgb_success() + obj = self.get_device("humidifiers", self.RGB_DEVICE) + result = self.run_in_loop(obj.set_rgb_nightlight, red=999, green=0, blue=0) + assert result is False + + def test_set_rgb_off_preserves_color_state(self): + """Turning off must not clobber a stored 0-channel color or brightness.""" + self.mock_api.return_value = self._rgb_success() + obj = self.get_device("humidifiers", self.RGB_DEVICE) + obj.state.rgb_nightlight_red = 0 # buggy code would coalesce 0 -> 255 + obj.state.rgb_nightlight_green = 255 + obj.state.rgb_nightlight_blue = 0 + obj.state.rgb_nightlight_brightness = 80 + result = self.run_in_loop(obj.set_rgb_nightlight, power=False) + assert result is True + assert obj.state.rgb_nightlight_status == "off" + assert obj.state.rgb_nightlight_red == 0 # preserved, not 255 + assert obj.state.rgb_nightlight_blue == 0 + assert obj.state.rgb_nightlight_brightness == 80 + + def test_set_rgb_on_sets_status(self): + """power=True records the nightlight status as 'on'.""" + self.mock_api.return_value = self._rgb_success() + obj = self.get_device("humidifiers", self.RGB_DEVICE) + obj.state.rgb_nightlight_status = "off" + result = self.run_in_loop( + obj.set_rgb_nightlight, power=True, brightness=60, red=10, green=20, blue=30 + ) + assert result is True + assert obj.state.rgb_nightlight_status == "on" + + def test_set_rgb_sets_connection_online(self): + """A successful set marks the device online (via process_dev_response).""" + self.mock_api.return_value = self._rgb_success() + obj = self.get_device("humidifiers", self.RGB_DEVICE) + obj.state.connection_status = const.ConnectionStatus.OFFLINE + result = self.run_in_loop(obj.set_rgb_nightlight, brightness=60, red=10, green=20, blue=30) + assert result is True + assert obj.state.connection_status == const.ConnectionStatus.ONLINE + + def test_rgb_state_normalizes_dimmed_read(self): + """A dimmed API color is stored as a full-brightness base (max channel 255).""" + from pyvesync.models.humidifier_models import RGBNightLight + + obj = self.get_device("humidifiers", self.RGB_DEVICE) + dimmed = RGBNightLight( + action="on", colorMode="color", brightness=40, red=102, green=20, blue=0 + ) + obj._set_rgb_nightlight_state(dimmed) + assert obj.state.rgb_nightlight_status == "on" + assert max( + obj.state.rgb_nightlight_red, + obj.state.rgb_nightlight_green, + obj.state.rgb_nightlight_blue, + ) == 255 + + def test_rgb_state_black_stays_black_on_read(self): + """A dimmed all-zero color must not be stored as white.""" + from pyvesync.models.humidifier_models import RGBNightLight + + obj = self.get_device("humidifiers", self.RGB_DEVICE) + black = RGBNightLight( + action="on", colorMode="color", brightness=40, red=0, green=0, blue=0 + ) + obj._set_rgb_nightlight_state(black) + assert obj.state.rgb_nightlight_red == 0 + assert obj.state.rgb_nightlight_green == 0 + assert obj.state.rgb_nightlight_blue == 0 + + def test_oasismist_450s_wus_rejects_humidity_below_40(self): + """configModule WFON_AHM_LUH-A451S-WUS_US rejects a target humidity of + 30 with "target humidity is out of range" (pyvesync#296) even though + the client-side range historically allowed it. The correct range for + this specific variant is 40-80, not the library-wide default 30-80. + """ + obj = self.get_device("humidifiers", "LUH-O451S-WUS") + assert obj.target_minmax == (40, 80) + result = self.run_in_loop(obj.set_humidity, 30) + assert result is False + + def test_oasismist_450s_wusr_still_allows_humidity_30(self): + """The WUSR variant (different configModule) is not affected by the + WUS-only restriction from #296 and keeps the library-wide 30-80 + range.""" + self.mock_api.return_value = (build_bypass_v2_response(inner_result={}), 200) + obj = self.get_device("humidifiers", "LUH-O451S-WUSR") + assert obj.target_minmax == (30, 80) + result = self.run_in_loop(obj.set_humidity, 30) + assert result is True + + def test_oasismist_450s_wus_has_no_humidity_mode(self): + """configModule WFON_AHM_LUH-A451S-WUS_US rejects the HUMIDITY mist + mode with "Mode value invaild!" (pyvesync#295) -- it must not be + offered for this variant.""" + obj = self.get_device("humidifiers", "LUH-O451S-WUS") + assert const.HumidifierModes.HUMIDITY not in obj.mist_modes + + def test_oasismist_450s_wusr_still_has_humidity_mode(self): + """The WUSR variant is unaffected by the WUS-only mode restriction + from #295 and keeps the HUMIDITY mist mode.""" + obj = self.get_device("humidifiers", "LUH-O451S-WUSR") + assert const.HumidifierModes.HUMIDITY in obj.mist_modes diff --git a/src/tests/test_outlets.py b/src/tests/test_outlets.py index d2246ea3..bf85850d 100644 --- a/src/tests/test_outlets.py +++ b/src/tests/test_outlets.py @@ -278,6 +278,14 @@ def test_power(self, setup_entry): if not outlet_obj.supports_energy: pytest.skip(f"{setup_entry} does not support energy monitoring.") + if not outlet_obj.supports_energy_history: + self.run_in_loop(outlet_obj.update_energy) + assert self.mock_api.call_count == 0 + assert outlet_obj.state.weekly_history is None + assert outlet_obj.state.monthly_history is None + assert outlet_obj.state.yearly_history is None + return + self.mock_api.side_effect = [ (dict(call_json_outlets.METHOD_RESPONSES[setup_entry]['get_weekly_energy']), 200), (dict(call_json_outlets.METHOD_RESPONSES[setup_entry]['get_monthly_energy']), 200), diff --git a/src/tests/test_x_vesync_api_responses.py b/src/tests/test_x_vesync_api_responses.py index 87be9e53..3447ef77 100644 --- a/src/tests/test_x_vesync_api_responses.py +++ b/src/tests/test_x_vesync_api_responses.py @@ -76,7 +76,7 @@ def setup(self, caplog): self.manager.auth._account_id = TestDefaults.account_id yield self.mock.stop() - self.loop.stop() + self.loop.close() async def run_coro(self, coro): """Run a coroutine in the event loop."""