From 208913bcd52522e6a88cf1601967fd8f523d46a7 Mon Sep 17 00:00:00 2001 From: "Jose A. Martin" Date: Tue, 11 Aug 2026 10:21:18 +0200 Subject: [PATCH 1/2] feat(humidifier): add support for Levoit NeoClassic 450S (LUH-N451S-WEU) Adds a new HumidifierMap entry using the VeSyncSproutHumid class, as the NeoClassic 450S shares the same bypassV2 protocol and workMode values (autoPro/sleep/manual) as the Sprout Humidifier. A separate entry is used instead of adding the dev_type to the existing Sprout entry, since the NeoClassic 450S lacks drying mode, HEPA filter, child lock and external sensor support. Makes several SproutHumidifierResult fields optional with sensible defaults (autoModePreference, waterShortageDryingSwitch, childLockSwitch, filterLifePercent, hepaFilterLifePercent, lampType, dumpedState, roomSize, supportLampAct, lastDryingCompletedTime, afterDryLastHumidityTime), since the NeoClassic 450S does not report these Sprout-specific fields. Adds test fixtures for LUH-N451S-WEU in call_json_humidifiers.py and the corresponding recorded API responses. Fixes #172743 --- README.md | 1 + src/pyvesync/device_map.py | 19 ++ src/pyvesync/models/humidifier_models.py | 23 +- .../api/vesynchumidifier/LUH-N451S-WEU.yaml | 300 ++++++++++++++++++ src/tests/call_json_humidifiers.py | 31 ++ 5 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 src/tests/api/vesynchumidifier/LUH-N451S-WEU.yaml diff --git a/README.md b/README.md index 5ceff41b..2cdf5e4c 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ pip install pyvesync 6. OasisMist 1000S 7. Superior 6000S 8. Sprout Humidifier +9. NeoClassic 450S ### Levoit Fans diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..78677af9 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -809,6 +809,25 @@ class ThermostatMap(DeviceMapTemplate): model_name='Sprout Humidifier', setup_entry='LEH-B381S', ), + HumidifierMap( + class_name='VeSyncSproutHumid', + dev_types=['LUH-N451S-WEU'], + features=[ + HumidifierFeatures.NIGHTLIGHT, + HumidifierFeatures.NIGHTLIGHT_BRIGHTNESS, + HumidifierFeatures.AUTO_STOP, + ], + mist_modes={ + HumidifierModes.AUTO: 'autoPro', + HumidifierModes.SLEEP: 'sleep', + HumidifierModes.MANUAL: 'manual', + }, + mist_levels=list(range(1, 3)), + device_alias='NeoClassic 450S', + model_display='LUH-N451S Series', + model_name='NeoClassic 450S', + setup_entry='LUH-N451S-WEU', + ), ] """List of ['HumidifierMap'][pyvesync.device_map.HumidifierMap] configuration objects for humidifier devices.""" diff --git a/src/pyvesync/models/humidifier_models.py b/src/pyvesync/models/humidifier_models.py index 51424ce4..5464b505 100644 --- a/src/pyvesync/models/humidifier_models.py +++ b/src/pyvesync/models/humidifier_models.py @@ -229,7 +229,6 @@ class Levoit1000SNightLight(ResponseBaseModel): @dataclass class SproutHumidifierResult(InnerHumidifierBaseResult): """Sprout Humidifier Result Model.""" - powerSwitch: int humidity: int targetHumidity: int @@ -242,20 +241,20 @@ class SproutHumidifierResult(InnerHumidifierBaseResult): autoStopState: int screenSwitch: int screenState: int - autoModePreference: int autoPreference: int - waterShortageDryingSwitch: int - childLockSwitch: int - filterLifePercent: int - hepaFilterLifePercent: int temperature: int lampSwitch: int - lampType: int - dumpedState: int - roomSize: int - supportLampAct: int - lastDryingCompletedTime: int - afterDryLastHumidityTime: int + autoModePreference: int = 0 + waterShortageDryingSwitch: int = 0 + childLockSwitch: int = 0 + filterLifePercent: int = 0 + hepaFilterLifePercent: int = 0 + lampType: int = 0 + dumpedState: int = 0 + roomSize: int = 0 + supportLampAct: int = 0 + lastDryingCompletedTime: int = 0 + afterDryLastHumidityTime: int = 0 sensorContent: SproutSensorcontent | None = None breathingLamp: SproutBreathinglamp | None = None guardingInfo: SproutGuardinginfo | None = None diff --git a/src/tests/api/vesynchumidifier/LUH-N451S-WEU.yaml b/src/tests/api/vesynchumidifier/LUH-N451S-WEU.yaml new file mode 100644 index 00000000..ad25e890 --- /dev/null +++ b/src/tests/api/vesynchumidifier/LUH-N451S-WEU.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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + workMode: autoPro + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + targetHumidity: 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + workMode: 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + levelIdx: 0 + levelType: mist + virtualLevel: 2 + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + powerSwitch: 0 + switchIdx: 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + autoStopSwitch: 0 + method: setAutoStopSwitch + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + screenSwitch: 0 + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + powerSwitch: 1 + switchIdx: 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + autoStopSwitch: 1 + method: setAutoStopSwitch + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-CID + method: bypassV2 + payload: + data: + screenSwitch: 1 + 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-N451S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LUH-N451S-WEU-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..fb974346 100644 --- a/src/tests/call_json_humidifiers.py +++ b/src/tests/call_json_humidifiers.py @@ -331,6 +331,35 @@ class HumidifierDefaults: "lastDryingCompletedTime": 1764685402, "afterDryLastHumidityTime": 1764651910, }, + "LUH-N451S-WEU": { # NeoClassic 450S + "powerSwitch": int(HumidifierDefaults.device_status), + "humidity": HumidifierDefaults.humidity, + "targetHumidity": HumidifierDefaults.target_humidity, + "virtualLevel": HumidifierDefaults.virtual_mist_level, + "mistLevel": HumidifierDefaults.mist_level, + "workMode": HumidifierDefaults.humidifier_mode.value, + "waterLacksState": int(HumidifierDefaults.water_lacks), + "waterTankLifted": int(HumidifierDefaults.water_tank_lifted), + "autoStopSwitch": int(HumidifierDefaults.auto_stop), + "autoStopState": int(HumidifierDefaults.auto_stop_reached), + "screenSwitch": int(HumidifierDefaults.display_config), + "screenState": int(HumidifierDefaults.display), + "autoPreference": 4, + "temperature": HumidifierDefaults.temperature, + "lampSwitch": 0, + "nightLight": { + "nightLightSwitch": int(HumidifierDefaults.nightlight_status), + "brightness": HumidifierDefaults.nightlight_brightness, + "colorTemperature": HumidifierDefaults.nightlight_color_temperature, + "nightLightLevel": 1, + "brightnessLevel2": 100, + }, + "scheduleCount": 0, + "timerRemain": 0, + "errorCode": 0, + "totalWorkTime": 113340, + "errorCodes": [], + }, "LUH-A603S-WUS": { # LV600S with warm mist "powerSwitch": int(DeviceStatus.ON), "humidity": HumidifierDefaults.humidity, @@ -370,6 +399,7 @@ class HumidifierDefaults: "LUH-M101S-WUS": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-M101-WUS"]), "LEH-S601S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-S601S"]), "LEH-B381S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-B381S"]), + "LUH-N451S-WEU": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LUH-N451S-WEU"]), } @@ -395,6 +425,7 @@ class HumidifierDefaults: "LUH-M101S-WUS": deepcopy(FunctionResponsesV2), "LEH-S601S": deepcopy(FunctionResponsesV2), "LEH-B381S": deepcopy(FunctionResponsesV2), + "LUH-N451S-WEU": deepcopy(FunctionResponsesV2), } From 4f44f298f03d511e08ddaa4541df6d00bfaa8926 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:24:40 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/models/humidifier_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pyvesync/models/humidifier_models.py b/src/pyvesync/models/humidifier_models.py index 5464b505..7fd311e5 100644 --- a/src/pyvesync/models/humidifier_models.py +++ b/src/pyvesync/models/humidifier_models.py @@ -229,6 +229,7 @@ class Levoit1000SNightLight(ResponseBaseModel): @dataclass class SproutHumidifierResult(InnerHumidifierBaseResult): """Sprout Humidifier Result Model.""" + powerSwitch: int humidity: int targetHumidity: int