From 52afece03c8cabcc686173a45396064588033fa8 Mon Sep 17 00:00:00 2001 From: MangelSpec <74370284+MangelSpec@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:15:01 +0200 Subject: [PATCH 1/5] feat: Add RGB nightlight support for VeSyncHumid200300S (LUH-O451S-WEU) (#502) ## Summary Adds RGB nightlight control support for the `VeSyncHumid200300S` device class, specifically for the **LUH-O451S-WEU** (OasisMist 4.5L) humidifier model. This addresses missing nightlight functionality that prevents Home Assistant from creating light entities for humidifiers with RGB nightlight features. Related issue: [home-assistant/core#160387](https://github.com/home-assistant/core/issues/160387) ## Changes - Added `HumidifierFeatures.RGB_NIGHTLIGHT` feature flag - Added `supports_rgb_nightlight` property to base class - Implemented `set_rgb_nightlight(power, brightness, red, green, blue)` method - Added RGB nightlight state attributes (status, brightness, r/g/b, color_mode) ## API Quirks 1. **Brightness-adjusted RGB**: API expects RGB values pre-multiplied by brightness via HSV conversion 2. **Color slider location**: API requires a `colorSliderLocation` (0-100) mapped from an 8-color gradient 3. **!Stale API responses!**: After setting values, API returns old data for several minutes - implemented timeout to prevent state drift. This is sadly not working perfectly yet, I didn't find out how to improve this behavior or force the getHumidifierStatus to return the values we just set before. Maybe someone with more insight or time can improve this as it's also causing issues when updating the nightlight via App and then using this library. ## Known Limitations - Minimum brightness is 40% (enforced by VeSync app) - Other models with RGB nightlights (e.g., LUH-D301S-WUSR) may work by adding the feature flag - only tested on LUH-O451S-WEU ## Testing Tested on physical LUH-O451S-WEU device: power on/off, brightness, color changes, state refresh. --- .gitignore | 3 +- docs/development/utils/colors.md | 22 ++- docs/supported_devices.md | 24 ++- src/pyvesync/base_devices/humidifier_base.py | 50 ++++++ src/pyvesync/const.py | 6 + src/pyvesync/device_map.py | 6 +- src/pyvesync/devices/vesynchumidifier.py | 161 +++++++++++++++++- src/pyvesync/models/humidifier_models.py | 15 ++ src/pyvesync/utils/colors.py | 127 ++++++++++++++ .../api/vesynchumidifier/LUH-O451S-WEU.yaml | 34 ++++ src/tests/call_json_humidifiers.py | 17 ++ src/tests/test_colors.py | 46 +++++ src/tests/test_humidifiers.py | 94 +++++++++- 13 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 src/tests/test_colors.py diff --git a/.gitignore b/.gitignore index a13874c9..1f55645f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ docstest/* .claude/settings.local.json .mcp.json .vesync_auth -**/CLAUDE.md \ No newline at end of file +**/CLAUDE.md +/docs/superpowers 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..2d35b9b7 100644 --- a/docs/supported_devices.md +++ b/docs/supported_devices.md @@ -83,15 +83,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/const.py b/src/pyvesync/const.py index a0571b43..5f2fc641 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): diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index d467eb12..060d309f 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -684,7 +684,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', diff --git a/src/pyvesync/devices/vesynchumidifier.py b/src/pyvesync/devices/vesynchumidifier.py index 74df19da..93cb9367 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) 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/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/call_json_humidifiers.py b/src/tests/call_json_humidifiers.py index 7b4e68f3..f7edd911 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, 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..c6749c3c 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,9 @@ 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"]], + "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 +285,92 @@ 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 From c048ca231cef5cb7b63da73c22dfbb1827e797a8 Mon Sep 17 00:00:00 2001 From: "Jon D." Date: Sun, 12 Jul 2026 05:21:22 +0200 Subject: [PATCH 2/5] feat: Add support for WYLDR16A1081 smart plug (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WYLDR16A1081 to the device map as a dedicated entry with a new VeSyncWYLDRPlug class. This device is functionally identical to the BSDOG01 series (on/off, real-time power, voltage, daily energy) but does not support energy history retrieval via the bypassV2 API — calls to getEnergyHistory return result code -1 for this device type. VeSyncWYLDRPlug overrides _get_energy_history() with a no-op so that weekly and monthly history requests are silently skipped instead of generating spurious warning logs on every poll cycle. --- README.md | 1 + docs/supported_devices.md | 24 ++++-- src/pyvesync/base_devices/outlet_base.py | 18 ++++ src/pyvesync/const.py | 2 + src/pyvesync/device_map.py | 33 +++++-- src/pyvesync/devices/vesyncoutlet.py | 10 +++ src/tests/api/vesyncoutlet/WYLDR16A1081.yaml | 91 ++++++++++++++++++++ src/tests/call_json_outlets.py | 4 +- src/tests/test_outlets.py | 8 ++ 9 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 src/tests/api/vesyncoutlet/WYLDR16A1081.yaml 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/supported_devices.md b/docs/supported_devices.md index 2d35b9b7..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 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 5f2fc641..eb6e02f9 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -579,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 060d309f..172b04c3 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.""" 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/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/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_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), From 3a0abc86fa6c2686f11d6528535ebd259dcb7d2b Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 12 Jul 2026 04:23:51 +0100 Subject: [PATCH 3/5] fix: close event loop in teardown to prevent file descriptor leak (#514) Using loop.stop() leaves the loop open, which can cause 'Too many open files' errors when running the test suite. --- src/tests/base_test_cases.py | 2 +- src/tests/test_x_vesync_api_responses.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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.""" From 58eee3bdbfbaac98d64cd0dd4fd66e1f1c24ab8e Mon Sep 17 00:00:00 2001 From: Rakesh Piboina <63509637+rakeshpyboina@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:08:37 -0500 Subject: [PATCH 4/5] fix: wire up nightlight brightness/toggle for Sprout Humidifier (LEH-B381S) (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The LEH-B381S (Sprout Humidifier) already declares `NIGHTLIGHT` / `NIGHTLIGHT_BRIGHTNESS` support in `device_map.py`, and `VeSyncSproutHumid` already had a working private `_set_nightlight_state` helper that builds the correct `setLightStatus` payload. However, the class never overrode the base class's `set_nightlight_brightness` / `toggle_nightlight` methods, so any caller (e.g. Home Assistant's `select`/`switch` entities) fell through to the base-class stub, which just logs `Nightlight brightness has not been configured.` and returns `False` instead of calling the API. Traceback this was pulled from (Home Assistant, via `select.async_select_option`): ``` File ".../vesync/select.py", line 194, in async_select_option ERROR [pyvesync.base_devices.humidifier_base] Nightlight brightness has not been configured. ``` - Added `set_nightlight_brightness` and `toggle_nightlight` overrides to `VeSyncSproutHumid`, wired to the existing `_set_nightlight_state` helper (mirroring the pattern used in `VeSyncHumid200300S`/`VeSyncHumid1000S`). - Fixed a latent bug in `_set_nightlight_state`: it unconditionally stored the passed-in `brightness` (including `None`) as the new state, and would send `"brightness": null` to the API when toggling the light before a brightness had ever been read or set. It now falls back to the last known brightness, or `100` if none is known yet. ## Test plan - [x] Added `turn_on_nightlight`, `turn_off_nightlight`, and `set_nightlight_brightness` test cases for `LEH-B381S` in `test_humidifiers.py` - [x] Recorded new API fixtures in `src/tests/api/vesynchumidifier/LEH-B381S.yaml` - [x] `pytest src/tests/` — 355 passed, 3 skipped - [x] `mypy src/pyvesync/devices/vesynchumidifier.py` — no issues - [ ] Validated against a physical LEH-B381S device (reporter has the device; will confirm in this thread) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Joe Trabulsy --- .gitignore | 1 + src/pyvesync/devices/vesynchumidifier.py | 34 +++++++- src/tests/api/vesynchumidifier/LEH-B381S.yaml | 87 +++++++++++++++++++ src/tests/test_humidifiers.py | 5 ++ 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1f55645f..7414e47e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ docstest/* .vesync_auth **/CLAUDE.md /docs/superpowers +/docs/development/reviews diff --git a/src/pyvesync/devices/vesynchumidifier.py b/src/pyvesync/devices/vesynchumidifier.py index 93cb9367..99951bd3 100644 --- a/src/pyvesync/devices/vesynchumidifier.py +++ b/src/pyvesync/devices/vesynchumidifier.py @@ -1260,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), } @@ -1270,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/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/test_humidifiers.py b/src/tests/test_humidifiers.py index c6749c3c..a14d115b 100644 --- a/src/tests/test_humidifiers.py +++ b/src/tests/test_humidifiers.py @@ -121,6 +121,11 @@ 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}], ], From 87e845518d3431cab44bc3df713cdaa9bfec697d Mon Sep 17 00:00:00 2001 From: Oscar Pacheco Date: Fri, 28 Aug 2026 21:51:11 -0600 Subject: [PATCH 5/5] fix: separate LUH-O451S-WUS humidity range and mist modes from WUSR/601S configModule WFON_AHM_LUH-A451S-WUS_US only accepts a target humidity of 40-80 (the cloud API rejects 30 with "target humidity is out of range") and rejects the HUMIDITY mist mode ("Mode value invaild!"), unlike the WUSR/601S variants it was grouped with, which the maintainer confirmed in the issue threads can go down to 30. Splits the single HumidifierMap entry for ['LUH-O451S-WUS', 'LUH-O451S-WUSR', 'LUH-O601S-WUS', 'LUH-O601S-KUS'] into two: LUH-O451S-WUS keeps target_minmax=(40, 80) and drops the HUMIDITY mode, the other three dev_types keep the previous (30, 80) range and HUMIDITY mode, matching the only variant with confirmed API-rejected behavior. Fixes #295, fixes #296. --- src/pyvesync/device_map.py | 26 +- .../api/vesynchumidifier/LUH-O451S-WUSR.yaml | 300 ++++++++++++++++++ src/tests/call_json_humidifiers.py | 3 + src/tests/test_humidifiers.py | 34 ++ 4 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/tests/api/vesynchumidifier/LUH-O451S-WUSR.yaml diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..2e453734 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -724,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)), @@ -738,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/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/call_json_humidifiers.py b/src/tests/call_json_humidifiers.py index f7edd911..fb628b4b 100644 --- a/src/tests/call_json_humidifiers.py +++ b/src/tests/call_json_humidifiers.py @@ -352,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 @@ -365,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"]), @@ -390,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/test_humidifiers.py b/src/tests/test_humidifiers.py index a14d115b..4f12a537 100644 --- a/src/tests/test_humidifiers.py +++ b/src/tests/test_humidifiers.py @@ -379,3 +379,37 @@ def test_rgb_state_black_stays_black_on_read(self): 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