Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/pyvesync/base_devices/humidifier_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions src/pyvesync/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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'
Expand All @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion src/pyvesync/device_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
138 changes: 137 additions & 1 deletion src/pyvesync/devices/vesynchumidifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -283,6 +327,98 @@ 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(
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 (40-100). Values below 40 will be clamped.
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'

if brightness is None:
brightness = self.state.rgb_nightlight_brightness or 40

if red is None:
red = self.state.rgb_nightlight_red or 255
if green is None:
green = self.state.rgb_nightlight_green or 255
if blue is None:
blue = self.state.rgb_nightlight_blue or 255

# Brightness range is 40-100 per VeSync app
brightness = max(40, min(100, brightness))

# Clamp RGB values to valid range
red = max(0, min(255, red))
green = max(0, min(255, green))
blue = max(0, min(255, blue))

color_mode = self.state.rgb_nightlight_color_mode or 'color'

# Calculate colorSliderLocation from the base RGB color (at full brightness)
color_slider_location = RGBNightlightColor.rgb_to_color_slider_location(
red, green, blue
)

# Apply brightness to RGB values - the VeSync app sends brightness-adjusted
# RGB values to the API, not raw colors with separate 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

# Update state and record timestamp to ignore stale API responses
self.state.rgb_nightlight_status = action
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)
Expand Down
15 changes: 15 additions & 0 deletions src/pyvesync/models/humidifier_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading