From e163548949745684b43fd5bd114bc050a44b8c21 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Tue, 6 Oct 2026 14:12:26 -0700 Subject: [PATCH 1/8] feat: add support for Levoit Superior Studio Smart 450S (LEH-S451S) Adds a HumidifierMap entry for the Superior Studio Smart 450S evaporative humidifier (LEH-S451S-WUS and regional variants), reusing the VeSyncSuperior6000S class since the device shares the Superior family's bypassV2 protocol (getHumidifierStatus, setHumidityMode, setVirtualLevel, setDryingMode, etc.). Fields on Superior6000SResult that are not essential for state parsing (scheduleCount, timerRemain, errorCode, autoPreference, childLockSwitch, filterLifePercent, temperature) now default to 0 so a response that omits any of them still parses instead of dropping the device. Fixes #540 --- README.md | 1 + docs/supported_devices.md | 2 + src/pyvesync/device_map.py | 16 + src/pyvesync/models/humidifier_models.py | 14 +- src/tests/api/vesynchumidifier/LEH-S451S.yaml | 327 ++++++++++++++++++ src/tests/call_json_humidifiers.py | 69 ++++ src/tests/test_humidifiers.py | 1 + 7 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 src/tests/api/vesynchumidifier/LEH-S451S.yaml diff --git a/README.md b/README.md index 5ceff41b..068f0978 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ pip install pyvesync 6. OasisMist 1000S 7. Superior 6000S 8. Sprout Humidifier +9. Superior Studio Smart 450S ### Levoit Fans diff --git a/docs/supported_devices.md b/docs/supported_devices.md index c80bd7e2..a22e6036 100644 --- a/docs/supported_devices.md +++ b/docs/supported_devices.md @@ -36,6 +36,7 @@ The VeSync API supports a variety of devices. The following is a list of devices - [OasisMist 4.5L Humidifier][pyvesync.devices.vesynchumidifier.VeSyncHumid200300S] - [OasisMist 1000S Humidifier][pyvesync.devices.vesynchumidifier.VeSyncHumid1000S] - [Superior 6000S][pyvesync.devices.vesynchumidifier.VeSyncSuperior6000S] - 6L Smart Humidifier + - [Superior Studio Smart 450S][pyvesync.devices.vesynchumidifier.VeSyncSuperior6000S] - 4.5L Evaporative Humidifier - [Sprout Humidifier][pyvesync.devices.vesynchumidifier.VeSyncSproutHumid] 6. Fans - [42" Tower Fan][pyvesync.devices.vesyncfan.VeSyncTowerFan] @@ -99,6 +100,7 @@ these methods are used. | LV600S | | | ✔ | | OasisMist 4.5L | | ✔ | ✔ | | Superior 6000S | | | ✔ | +| Superior Studio 450S | | | ✔ | | Sprout Humidifier | | | | The OasisMist 4.5L (`LUH-O451S-WEU`) exposes an RGB nightlight through diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..5b47184c 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -789,6 +789,22 @@ class ThermostatMap(DeviceMapTemplate): model_name='Superior 6000S', setup_entry='LEH-S601S', ), + HumidifierMap( + class_name='VeSyncSuperior6000S', + dev_types=['LEH-S451S-WUS', 'LEH-S451S-WUSR', 'LEH-S451S-WEU', 'LEH-S451S-WEUR'], + features=[HumidifierFeatures.DRYING_MODE, HumidifierFeatures.AUTO_STOP], + mist_modes={ + HumidifierModes.AUTO: 'autoPro', + HumidifierModes.SLEEP: 'sleep', + HumidifierModes.MANUAL: 'manual', + }, + # virtualLevel 1-34 (maxFanLevel); mistLevel 1-3 is the coarse fan band + mist_levels=list(range(1, 35)), + device_alias='Superior Studio 450S', + model_display='LEH-S451S Series', + model_name='Superior Studio Smart 450S', + setup_entry='LEH-S451S', + ), HumidifierMap( class_name='VeSyncSproutHumid', dev_types=['LEH-B381S-WUS', 'LEH-B381S-WEU'], diff --git a/src/pyvesync/models/humidifier_models.py b/src/pyvesync/models/humidifier_models.py index 51424ce4..74ce1ce0 100644 --- a/src/pyvesync/models/humidifier_models.py +++ b/src/pyvesync/models/humidifier_models.py @@ -173,13 +173,13 @@ class Superior6000SResult(InnerHumidifierBaseResult): autoStopState: int screenSwitch: int screenState: int - scheduleCount: int - timerRemain: int - errorCode: int - autoPreference: int - childLockSwitch: int - filterLifePercent: int - temperature: int + scheduleCount: int = 0 + timerRemain: int = 0 + errorCode: int = 0 + autoPreference: int = 0 + childLockSwitch: int = 0 + filterLifePercent: int = 0 + temperature: int = 0 dryingMode: DryingModeModel | None = None diff --git a/src/tests/api/vesynchumidifier/LEH-S451S.yaml b/src/tests/api/vesynchumidifier/LEH-S451S.yaml new file mode 100644 index 00000000..d488a498 --- /dev/null +++ b/src/tests/api/vesynchumidifier/LEH-S451S.yaml @@ -0,0 +1,327 @@ +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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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_off_drying_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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-CID + method: bypassV2 + payload: + data: + autoDryingSwitch: 0 + method: setDryingMode + 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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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: LEH-S451S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LEH-S451S-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..b666dbf4 100644 --- a/src/tests/call_json_humidifiers.py +++ b/src/tests/call_json_humidifiers.py @@ -279,6 +279,73 @@ class HumidifierDefaults: "filterLifePercent": HumidifierDefaults.wick_life_percent, "temperature": HumidifierDefaults.temperature, }, + "LEH-S451S": { # Superior Studio Smart 450S - mirrors a real getHumidifierStatus + "powerSwitch": int(DeviceStatus.ON), + "humidity": HumidifierDefaults.humidity, + "targetHumidity": HumidifierDefaults.target_humidity, + "virtualLevel": 18, + "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), + "scheduleCount": 2, + "timerRemain": 0, + "totalWorkTime": 19800, + "supportCalibrationSensors": ["humidity"], + "errorCodes": [], # note: list, no `errorCode` key on this model + "autoPreference": 5, + "childLockSwitch": int(HumidifierDefaults.child_lock_switch), + "filterLifePercent": HumidifierDefaults.wick_life_percent, + "workDuration": 20220, + "filterRemainDay": 729, + "resetFilterDate": 0, + "temperature": HumidifierDefaults.temperature, + "waterShortageDryingSwitch": 0, + "dryingMode": { + "dryingLevel": HumidifierDefaults.drying_level, + "autoDryingSwitch": int(HumidifierDefaults.drying_mode_switch), + "dryingState": int(HumidifierDefaults.drying_state), + "dryingRemain": 0, + "dryingLevelInfo": [ + {"dryingLevel": 1, "totalTimeSec": 12000}, + {"dryingLevel": 2, "totalTimeSec": 5400}, + {"dryingLevel": 3, "totalTimeSec": 3600}, + ], + }, + "cleaningTotalTime": 1200, + "totalSteps": {}, + "dirtyWater": 0, + "exceptionType": 0, + "sleepFanLevel": 1, + "maxFanLevel": 34, + "fanLevelNoise": [ + {"noiseId": 0, "minLevel": 1, "maxLevel": 10}, + {"noiseId": 1, "minLevel": 11, "maxLevel": 20}, + {"noiseId": 2, "minLevel": 21, "maxLevel": 30}, + {"noiseId": 3, "minLevel": 31, "maxLevel": 34}, + ], + "totalSaveWater": 96, + "maintainRecords": { + "preCleanTime": 0, + "nextCleanTime": 0, + "preDryTime": 1791265783, + "nextDryTime": 1791870583, + }, + "afterDryLastHumidityTime": 1791306620, + "fanLevels": [ + {"fanLevelId": 1, "minLevel": 1, "maxLevel": 18}, + {"fanLevelId": 2, "minLevel": 19, "maxLevel": 30}, + {"fanLevelId": 3, "minLevel": 31, "maxLevel": 34}, + ], + "runningError": 0, + "errorOccurs": 0, + "finishMaintainProcess": 0, + "humidUnreachableState": 0, + }, "LEH-B381S": { # Sprout Humidifier "powerSwitch": int(HumidifierDefaults.device_status), "humidity": HumidifierDefaults.humidity, @@ -369,6 +436,7 @@ class HumidifierDefaults: "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"]), "LEH-S601S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-S601S"]), + "LEH-S451S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-S451S"]), "LEH-B381S": build_bypass_v2_response(inner_result=HUMIDIFIER_DETAILS["LEH-B381S"]), } @@ -394,6 +462,7 @@ class HumidifierDefaults: "LUH-M101S-WEUR": deepcopy(FunctionResponsesV2), "LUH-M101S-WUS": deepcopy(FunctionResponsesV2), "LEH-S601S": deepcopy(FunctionResponsesV2), + "LEH-S451S": deepcopy(FunctionResponsesV2), "LEH-B381S": deepcopy(FunctionResponsesV2), } diff --git a/src/tests/test_humidifiers.py b/src/tests/test_humidifiers.py index a14d115b..3cab102b 100644 --- a/src/tests/test_humidifiers.py +++ b/src/tests/test_humidifiers.py @@ -121,6 +121,7 @@ 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-S451S": [["turn_off_drying_mode"]], "LEH-B381S": [ ["turn_on_nightlight"], ["turn_off_nightlight"], From a7d57de234df11894697c739f308f9d5c33f08d2 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Tue, 6 Oct 2026 14:12:53 -0700 Subject: [PATCH 2/8] feat: add dump_humidifier_status.py for unsupported-device reports Adds testing_scripts/dump_humidifier_status.py, which logs in, prints the raw device list, and calls getHumidifierStatus directly for any device type matching a filter, even one not in the device map. It writes a redacted JSON dump suitable for attaching to a device request issue. Documents the script in the testing_scripts README, the packet capturing guide, and the README device request section. --- README.md | 6 + docs/development/capturing.md | 10 ++ testing_scripts/README.md | 13 ++ testing_scripts/dump_humidifier_status.py | 155 ++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 testing_scripts/dump_humidifier_status.py diff --git a/README.md b/README.md index 068f0978..777a3287 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,12 @@ if __name__ == "__main__": If you would like to request a new device to be added to the library, please open an issue on GitHub. Be sure to include the device model number and a link to the product page. If you are able to provide packet captures or are willing to share the device temporarily, please indicate that in the issue. See the [Packet Capturing for New Device Support](https://webdjoe.github.io/pyvesync/latest/development/capturing) document for more details. +For humidifiers that use the `bypassV2` API, the [dump_humidifier_status.py](./testing_scripts/dump_humidifier_status.py) script can often provide enough information without a packet capture. It logs the raw device list entry and the `getHumidifierStatus` response for any device type, even one not yet in the device map, and writes a redacted JSON file that can be attached to the issue: + +```bash +python testing_scripts/dump_humidifier_status.py --email --password --filter +``` + ## Contributing All [contributions](https://webdjoe.github.io/pyvesync/latest/development/contributing) are welcome. diff --git a/docs/development/capturing.md b/docs/development/capturing.md index b3f7e5b1..d4a98936 100644 --- a/docs/development/capturing.md +++ b/docs/development/capturing.md @@ -6,6 +6,16 @@ The process outlined below is time consuming and can be difficult. An alternativ Please do not post a device request without being willing to either capture packets or share the device. +## Quick alternative for humidifiers + +Most recent humidifiers use the `bypassV2` API with a `getHumidifierStatus` method. For these devices the `testing_scripts/dump_humidifier_status.py` script can often provide enough information to add basic support without a packet capture. It logs in, prints the raw device list entry (including the exact `deviceType` and `configModule`), calls `getHumidifierStatus` directly for any device type matching `--filter`, and writes a redacted JSON dump: + +```bash +python testing_scripts/dump_humidifier_status.py --email --password --filter S451S +``` + +Attach the resulting `vesync_dump.json` to the issue. A packet capture is still needed to confirm the request payloads for set methods (mode names, level ranges) if they differ from the device's existing class. + ## Prerequisites 1. **Mumu Emulator**: Download and install the Mumu Android emulator from [Mumu Player](https://www.mumuplayer.com/). This emulator allows you to run Android apps on your computer. Other emulators may work, but Mumu is known to be compatible with Arm64 apk's and allows `adb root` access. diff --git a/testing_scripts/README.md b/testing_scripts/README.md index 307744f6..888569df 100644 --- a/testing_scripts/README.md +++ b/testing_scripts/README.md @@ -104,3 +104,16 @@ python vs_test_script.py \ # or testing_scripts/vs_test_script.py if using repo ## Logging The script logs output to both the console and a file specified by the `OUTPUT_FILE` variable. + +## Dumping status for an unsupported humidifier + +`dump_humidifier_status.py` helps gather the information needed to add a new humidifier. It works even when the device is not in pyvesync's device map: + +1. Logs in and prints every device in the account's raw device list (name, `deviceType`, `configModule`, firmware). +2. For each device whose `deviceType` matches `--filter` (a regex, default `S451S`), sends a raw `bypassV2` `getHumidifierStatus` call and prints the full JSON result, noting whether pyvesync recognised the device. +3. Prints the pyvesync-parsed state for matching devices that are supported. +4. Writes a redacted copy (`cid`, `uuid`, `macID`, account IDs and tokens removed) to `--output` (default `vesync_dump.json`) for attaching to a GitHub issue. + +```bash +python testing_scripts/dump_humidifier_status.py --email --password --filter S451S --output vesync_dump.json +``` diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py new file mode 100644 index 00000000..cb1a0f80 --- /dev/null +++ b/testing_scripts/dump_humidifier_status.py @@ -0,0 +1,155 @@ +"""Dump raw VeSync device list and humidifier status for unsupported-device debugging. + +Usage: + python testing_scripts/dump_humidifier_status.py --email EMAIL --password PASSWORD \ + [--filter S451S] [--output vesync_dump.json] + +Logs in, prints every device returned by the VeSync device list API (device type, +config module, firmware, etc.) and, for each device whose deviceType matches +``--filter``, issues a raw ``bypassV2`` ``getHumidifierStatus`` call so the full JSON +result can be shared in a GitHub issue or used to build a response model. This works +even when the device is not in pyvesync's device map. Account identifiers are redacted +from the output file. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any + +from pyvesync import VeSync +from pyvesync.models.vesync_models import RequestDeviceListModel +from pyvesync.utils.device_mixins import BYPASS_V2_BASE +from pyvesync.utils.helpers import Helpers + +logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s') +logging.getLogger('pyvesync').setLevel(logging.DEBUG) +logger = logging.getLogger('dump') + +REDACT_KEYS = {'cid', 'uuid', 'macID', 'accountID', 'token', 'deviceId', 'ownerShip'} +REQUEST_KEYS = ( + 'acceptLanguage', + 'appVersion', + 'phoneBrand', + 'phoneOS', + 'accountID', + 'debugMode', + 'traceId', + 'timeZone', + 'token', + 'userCountryCode', +) + + +def redact(obj: Any) -> Any: # noqa: ANN401 + """Recursively redact identifying keys.""" + if isinstance(obj, dict): + return { + k: ('' if k in REDACT_KEYS else redact(v)) for k, v in obj.items() + } + if isinstance(obj, list): + return [redact(i) for i in obj] + return obj + + +async def main(email: str, password: str, dev_filter: str, output: str) -> None: + """Run the dump.""" + async with VeSync(email, password) as manager: + if not await manager.login(): + logger.error('Login failed') + sys.exit(1) + + # Raw device list (includes devices pyvesync does not know about) + req = RequestDeviceListModel( + token=manager.auth.token, + accountID=manager.auth.account_id, + timeZone=manager.time_zone, + ) + resp, _ = await manager.async_call_api( + '/cloud/v1/deviceManaged/devices', + 'post', + headers=Helpers.req_header_bypass(), + json_object=req.to_dict(), + ) + devices = (resp or {}).get('result', {}).get('list', []) or [] + logger.info('Device list returned %d devices', len(devices)) + for dev in devices: + logger.info( + ' %-30s type=%-18s configModule=%-28s firm=%s', + dev.get('deviceName'), + dev.get('deviceType'), + dev.get('configModule'), + dev.get('currentFirmVersion'), + ) + + await manager.update() + known = {d.cid for d in manager.devices} + dump: dict[str, Any] = {'device_list': redact(devices), 'status': {}} + + pattern = re.compile(dev_filter, re.IGNORECASE) + for dev in devices: + if not pattern.search(dev.get('deviceType', '')): + continue + logger.info( + 'Device %s (%s) %s pyvesync device map', + dev.get('deviceName'), + dev.get('deviceType'), + 'IS in' if dev.get('cid') in known else 'is NOT in', + ) + body = Helpers.get_defaultvalues_attributes(REQUEST_KEYS).copy() + body.update(Helpers.get_manager_attributes(manager, REQUEST_KEYS)) + body.update( + { + 'method': 'bypassV2', + 'cid': dev['cid'], + 'deviceId': dev['cid'], + 'configModule': dev.get('configModule'), + 'configModel': dev.get('configModule'), + 'payload': { + 'method': 'getHumidifierStatus', + 'source': 'APP', + 'data': {}, + }, + } + ) + r, status = await manager.async_call_api( + BYPASS_V2_BASE + 'bypassV2', + 'post', + headers=Helpers.req_header_bypass(), + json_object=body, + ) + logger.info( + 'getHumidifierStatus HTTP %s:\n%s', status, json.dumps(r, indent=2) + ) + dump['status'][dev.get('deviceType')] = redact(r) + + for hum in manager.devices.humidifiers: + if pattern.search(hum.device_type): + logger.info( + 'pyvesync parsed state for %s:\n%s', + hum.device_name, + hum.state.to_json(indent=True), + ) + + Path(output).write_text( # noqa: ASYNC240 - one-off write after API calls finish + json.dumps(dump, indent=2), encoding='utf-8' + ) + logger.info('Wrote redacted dump to %s', output) + + +if __name__ == '__main__': + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--email', required=True) + p.add_argument('--password', required=True) + p.add_argument( + '--filter', default='S451S', help='regex on deviceType (default S451S)' + ) + p.add_argument('--output', default='vesync_dump.json') + a = p.parse_args() + asyncio.run(main(a.email, a.password, a.filter, a.output)) From d5f34a8afd6023e9ac95e4f321075da23281d9a4 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 11:36:10 -0700 Subject: [PATCH 3/8] fix: key dump_humidifier_status output by device name so multiple units are kept --- testing_scripts/dump_humidifier_status.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py index cb1a0f80..b40dcda5 100644 --- a/testing_scripts/dump_humidifier_status.py +++ b/testing_scripts/dump_humidifier_status.py @@ -127,7 +127,9 @@ async def main(email: str, password: str, dev_filter: str, output: str) -> None: logger.info( 'getHumidifierStatus HTTP %s:\n%s', status, json.dumps(r, indent=2) ) - dump['status'][dev.get('deviceType')] = redact(r) + # Key by name as well as type so several units of one model do not overwrite + key = f"{dev.get('deviceName')} ({dev.get('deviceType')})" + dump['status'][key] = redact(r) for hum in manager.devices.humidifiers: if pattern.search(hum.device_type): From aae1c390eac135162747b31d6b01c03758ad6af0 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 11:39:06 -0700 Subject: [PATCH 4/8] feat: add fan work mode for Superior Studio 450S The LEH-S451S reports workMode 'fan' (fan only, no humidification). Add HumidifierModes.FAN and map it for this model so it no longer logs an unknown mode. Also accept the optional runningError and exceptionType status fields on the Superior result model. --- src/pyvesync/const.py | 2 ++ src/pyvesync/device_map.py | 1 + src/pyvesync/models/humidifier_models.py | 2 ++ src/tests/test_humidifiers.py | 11 +++++++++++ 4 files changed, 16 insertions(+) diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index eb6e02f9..6b411fba 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -651,6 +651,7 @@ class HumidifierModes(Features): SLEEP: Sleep mode. TURBO: Turbo mode. PET: Pet mode. + FAN: Fan only mode (evaporative humidifiers, no humidification). UNKNOWN: Unknown mode. AUTOPRO: AutoPro mode. """ @@ -661,6 +662,7 @@ class HumidifierModes(Features): SLEEP = 'sleep' TURBO = 'turbo' PET = 'pet' + FAN = 'fan' UNKNOWN = 'unknown' diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 5b47184c..59853c4b 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -797,6 +797,7 @@ class ThermostatMap(DeviceMapTemplate): HumidifierModes.AUTO: 'autoPro', HumidifierModes.SLEEP: 'sleep', HumidifierModes.MANUAL: 'manual', + HumidifierModes.FAN: 'fan', }, # virtualLevel 1-34 (maxFanLevel); mistLevel 1-3 is the coarse fan band mist_levels=list(range(1, 35)), diff --git a/src/pyvesync/models/humidifier_models.py b/src/pyvesync/models/humidifier_models.py index 74ce1ce0..acbaf4e3 100644 --- a/src/pyvesync/models/humidifier_models.py +++ b/src/pyvesync/models/humidifier_models.py @@ -180,6 +180,8 @@ class Superior6000SResult(InnerHumidifierBaseResult): childLockSwitch: int = 0 filterLifePercent: int = 0 temperature: int = 0 + runningError: int = 0 + exceptionType: int = 0 dryingMode: DryingModeModel | None = None diff --git a/src/tests/test_humidifiers.py b/src/tests/test_humidifiers.py index 3cab102b..ee024df9 100644 --- a/src/tests/test_humidifiers.py +++ b/src/tests/test_humidifiers.py @@ -380,3 +380,14 @@ 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_superior_studio_fan_mode(self): + """Superior Studio 450S fan-only work mode maps to HumidifierModes.FAN.""" + from copy import deepcopy + from pyvesync.models.humidifier_models import Superior6000SResult + + obj = self.get_device("humidifiers", "LEH-S451S") + details = deepcopy(call_json_humidifiers.HUMIDIFIER_DETAILS["LEH-S451S"]) + details["workMode"] = "fan" + obj._set_state(Superior6000SResult.from_dict(details)) + assert obj.state.mode == const.HumidifierModes.FAN From 676db0abebe70fceedf4549cc70f9ee89e77fdcf Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 11:47:22 -0700 Subject: [PATCH 5/8] feat: add --watch mode to dump_humidifier_status to log changing status fields --- testing_scripts/dump_humidifier_status.py | 100 ++++++++++++++++++---- 1 file changed, 83 insertions(+), 17 deletions(-) diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py index b40dcda5..1b5e12d4 100644 --- a/testing_scripts/dump_humidifier_status.py +++ b/testing_scripts/dump_humidifier_status.py @@ -2,7 +2,7 @@ Usage: python testing_scripts/dump_humidifier_status.py --email EMAIL --password PASSWORD \ - [--filter S451S] [--output vesync_dump.json] + [--filter S451S] [--output vesync_dump.json] [--watch SECONDS] Logs in, prints every device returned by the VeSync device list API (device type, config module, firmware, etc.) and, for each device whose deviceType matches @@ -10,6 +10,11 @@ result can be shared in a GitHub issue or used to build a response model. This works even when the device is not in pyvesync's device map. Account identifiers are redacted from the output file. + +With ``--watch SECONDS`` the status calls repeat at that interval and only the fields +that changed since the previous poll are printed, which helps catch transient states +(tank lifted, running dry, drying cycle). Each poll costs one API call per matching +device, so keep the interval modest to stay within the VeSync daily quota. """ from __future__ import annotations @@ -20,6 +25,8 @@ import logging import re import sys +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -58,7 +65,16 @@ def redact(obj: Any) -> Any: # noqa: ANN401 return obj -async def main(email: str, password: str, dev_filter: str, output: str) -> None: +def _diff(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]: + """Return the keys whose values differ between two status results.""" + return { + k: new.get(k) for k in sorted(set(old) | set(new)) if old.get(k) != new.get(k) + } + + +async def main( + email: str, password: str, dev_filter: str, output: str, watch: int +) -> None: """Run the dump.""" async with VeSync(email, password) as manager: if not await manager.login(): @@ -93,15 +109,10 @@ async def main(email: str, password: str, dev_filter: str, output: str) -> None: dump: dict[str, Any] = {'device_list': redact(devices), 'status': {}} pattern = re.compile(dev_filter, re.IGNORECASE) - for dev in devices: - if not pattern.search(dev.get('deviceType', '')): - continue - logger.info( - 'Device %s (%s) %s pyvesync device map', - dev.get('deviceName'), - dev.get('deviceType'), - 'IS in' if dev.get('cid') in known else 'is NOT in', - ) + targets = [d for d in devices if pattern.search(d.get('deviceType', ''))] + + async def fetch_status(dev: dict[str, Any]) -> dict[str, Any] | None: + """Call getHumidifierStatus for a raw device list entry.""" body = Helpers.get_defaultvalues_attributes(REQUEST_KEYS).copy() body.update(Helpers.get_manager_attributes(manager, REQUEST_KEYS)) body.update( @@ -118,18 +129,27 @@ async def main(email: str, password: str, dev_filter: str, output: str) -> None: }, } ) - r, status = await manager.async_call_api( + r, _ = await manager.async_call_api( BYPASS_V2_BASE + 'bypassV2', 'post', headers=Helpers.req_header_bypass(), json_object=body, ) + return r + + def status_key(dev: dict[str, Any]) -> str: + return f'{dev.get("deviceName")} ({dev.get("deviceType")})' + + for dev in targets: logger.info( - 'getHumidifierStatus HTTP %s:\n%s', status, json.dumps(r, indent=2) + 'Device %s (%s) %s pyvesync device map', + dev.get('deviceName'), + dev.get('deviceType'), + 'IS in' if dev.get('cid') in known else 'is NOT in', ) - # Key by name as well as type so several units of one model do not overwrite - key = f"{dev.get('deviceName')} ({dev.get('deviceType')})" - dump['status'][key] = redact(r) + r = await fetch_status(dev) + logger.info('getHumidifierStatus:\n%s', json.dumps(r, indent=2)) + dump['status'][status_key(dev)] = redact(r) for hum in manager.devices.humidifiers: if pattern.search(hum.device_type): @@ -139,12 +159,52 @@ async def main(email: str, password: str, dev_filter: str, output: str) -> None: hum.state.to_json(indent=True), ) + if watch > 0: + await watch_loop(manager, targets, fetch_status, status_key, dump, watch) + Path(output).write_text( # noqa: ASYNC240 - one-off write after API calls finish json.dumps(dump, indent=2), encoding='utf-8' ) logger.info('Wrote redacted dump to %s', output) +async def watch_loop( + manager: VeSync, + targets: list[dict[str, Any]], + fetch_status: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]], + status_key: Callable[[dict[str, Any]], str], + dump: dict[str, Any], + watch: int, +) -> None: + """Poll matching devices and record only the fields that change.""" + del manager + logger.info('Watching %d device(s) every %ds; Ctrl+C to stop', len(targets), watch) + last: dict[str, dict[str, Any]] = {} + for dev in targets: + full = dump['status'].get(status_key(dev)) or {} + last[dev['cid']] = (full.get('result') or {}).get('result') or {} + dump['watch'] = [] + try: + while True: + await asyncio.sleep(watch) + for dev in targets: + r = await fetch_status(dev) + inner = ((r or {}).get('result') or {}).get('result') or {} + changed = _diff(last[dev['cid']], inner) + if changed: + logger.info('%s changed: %s', dev.get('deviceName'), changed) + dump['watch'].append( + { + 'time': datetime.now(UTC).isoformat(), + 'device': dev.get('deviceName'), + 'changed': changed, + } + ) + last[dev['cid']] = inner + except (KeyboardInterrupt, asyncio.CancelledError): + logger.info('Watch stopped') + + if __name__ == '__main__': p = argparse.ArgumentParser(description=__doc__) p.add_argument('--email', required=True) @@ -153,5 +213,11 @@ async def main(email: str, password: str, dev_filter: str, output: str) -> None: '--filter', default='S451S', help='regex on deviceType (default S451S)' ) p.add_argument('--output', default='vesync_dump.json') + p.add_argument( + '--watch', + type=int, + default=0, + help='poll matching devices every N seconds and log changed fields', + ) a = p.parse_args() - asyncio.run(main(a.email, a.password, a.filter, a.output)) + asyncio.run(main(a.email, a.password, a.filter, a.output, a.watch)) From ca9c018576d8cb745dd0187243b72e069ee280fc Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 12:41:26 -0700 Subject: [PATCH 6/8] fix: write dump atomically and survive Ctrl+C in watch mode --- testing_scripts/dump_humidifier_status.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py index 1b5e12d4..075f097d 100644 --- a/testing_scripts/dump_humidifier_status.py +++ b/testing_scripts/dump_humidifier_status.py @@ -160,11 +160,21 @@ def status_key(dev: dict[str, Any]) -> str: ) if watch > 0: - await watch_loop(manager, targets, fetch_status, status_key, dump, watch) + try: + await watch_loop(manager, targets, fetch_status, status_key, dump, watch) + finally: + # Ctrl+C cancels the task; make sure what we have is written out. + _write_dump(dump, output) + return - Path(output).write_text( # noqa: ASYNC240 - one-off write after API calls finish - json.dumps(dump, indent=2), encoding='utf-8' - ) + _write_dump(dump, output) + + +def _write_dump(dump: dict[str, Any], output: str) -> None: + """Write the dump atomically so an interrupted run never leaves an empty file.""" + tmp = Path(output).with_suffix('.tmp') + tmp.write_text(json.dumps(dump, indent=2), encoding='utf-8') + tmp.replace(output) logger.info('Wrote redacted dump to %s', output) @@ -220,4 +230,7 @@ async def watch_loop( help='poll matching devices every N seconds and log changed fields', ) a = p.parse_args() - asyncio.run(main(a.email, a.password, a.filter, a.output, a.watch)) + try: + asyncio.run(main(a.email, a.password, a.filter, a.output, a.watch)) + except KeyboardInterrupt: + logger.info('Interrupted') From f90f8ed69507f2f1694f1e36499020f0918b3948 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 12:47:33 -0700 Subject: [PATCH 7/8] feat: add --name filter to dump_humidifier_status --- testing_scripts/dump_humidifier_status.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py index 075f097d..acd73f97 100644 --- a/testing_scripts/dump_humidifier_status.py +++ b/testing_scripts/dump_humidifier_status.py @@ -2,7 +2,7 @@ Usage: python testing_scripts/dump_humidifier_status.py --email EMAIL --password PASSWORD \ - [--filter S451S] [--output vesync_dump.json] [--watch SECONDS] + [--filter S451S] [--name Josh] [--output vesync_dump.json] [--watch SECONDS] Logs in, prints every device returned by the VeSync device list API (device type, config module, firmware, etc.) and, for each device whose deviceType matches @@ -73,7 +73,12 @@ def _diff(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]: async def main( - email: str, password: str, dev_filter: str, output: str, watch: int + email: str, + password: str, + dev_filter: str, + output: str, + watch: int, + name_filter: str | None = None, ) -> None: """Run the dump.""" async with VeSync(email, password) as manager: @@ -110,6 +115,9 @@ async def main( pattern = re.compile(dev_filter, re.IGNORECASE) targets = [d for d in devices if pattern.search(d.get('deviceType', ''))] + if name_filter: + name_re = re.compile(name_filter, re.IGNORECASE) + targets = [d for d in targets if name_re.search(d.get('deviceName', ''))] async def fetch_status(dev: dict[str, Any]) -> dict[str, Any] | None: """Call getHumidifierStatus for a raw device list entry.""" @@ -222,6 +230,7 @@ async def watch_loop( p.add_argument( '--filter', default='S451S', help='regex on deviceType (default S451S)' ) + p.add_argument('--name', default=None, help='optional regex on deviceName') p.add_argument('--output', default='vesync_dump.json') p.add_argument( '--watch', @@ -231,6 +240,6 @@ async def watch_loop( ) a = p.parse_args() try: - asyncio.run(main(a.email, a.password, a.filter, a.output, a.watch)) + asyncio.run(main(a.email, a.password, a.filter, a.output, a.watch, a.name)) except KeyboardInterrupt: logger.info('Interrupted') From ea99ae66782a631d113f2143c261fdde677c5031 Mon Sep 17 00:00:00 2001 From: Joshua Kriegshauser Date: Fri, 9 Oct 2026 12:52:04 -0700 Subject: [PATCH 8/8] fix: save dump after every watch cycle --- testing_scripts/dump_humidifier_status.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/testing_scripts/dump_humidifier_status.py b/testing_scripts/dump_humidifier_status.py index acd73f97..e10497d6 100644 --- a/testing_scripts/dump_humidifier_status.py +++ b/testing_scripts/dump_humidifier_status.py @@ -169,7 +169,9 @@ def status_key(dev: dict[str, Any]) -> str: if watch > 0: try: - await watch_loop(manager, targets, fetch_status, status_key, dump, watch) + await watch_loop( + manager, targets, fetch_status, status_key, dump, watch, output + ) finally: # Ctrl+C cancels the task; make sure what we have is written out. _write_dump(dump, output) @@ -178,21 +180,23 @@ def status_key(dev: dict[str, Any]) -> str: _write_dump(dump, output) -def _write_dump(dump: dict[str, Any], output: str) -> None: +def _write_dump(dump: dict[str, Any], output: str, *, quiet: bool = False) -> None: """Write the dump atomically so an interrupted run never leaves an empty file.""" tmp = Path(output).with_suffix('.tmp') tmp.write_text(json.dumps(dump, indent=2), encoding='utf-8') tmp.replace(output) - logger.info('Wrote redacted dump to %s', output) + if not quiet: + logger.info('Wrote redacted dump to %s', output) -async def watch_loop( +async def watch_loop( # noqa: PLR0913, PLR0917 manager: VeSync, targets: list[dict[str, Any]], fetch_status: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]], status_key: Callable[[dict[str, Any]], str], dump: dict[str, Any], watch: int, + output: str | None = None, ) -> None: """Poll matching devices and record only the fields that change.""" del manager @@ -219,6 +223,11 @@ async def watch_loop( } ) last[dev['cid']] = inner + dump['watch_polls'] = dump.get('watch_polls', 0) + 1 + if output: + # Save after every cycle: Ctrl+C under some shells kills the process + # before any cleanup runs, so the file must already be current. + _write_dump(dump, output, quiet=True) except (KeyboardInterrupt, asyncio.CancelledError): logger.info('Watch stopped')