diff --git a/README.md b/README.md index 5ceff41b..777a3287 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 @@ -542,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/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/base_devices/vesyncbasedevice.py b/src/pyvesync/base_devices/vesyncbasedevice.py index 50bbb87f..50c399cf 100644 --- a/src/pyvesync/base_devices/vesyncbasedevice.py +++ b/src/pyvesync/base_devices/vesyncbasedevice.py @@ -243,10 +243,29 @@ async def get_details(self) -> None: be called to update the device state. """ + uses_device_list_state: bool = False + """Whether this device's live state is carried in the device list response. + + When True, `VeSync.update_all_devices()` re-fetches the device list after the + per-device detail calls and passes each entry to `update_from_device_list()`. + """ + async def update(self) -> None: """Update device details.""" await self.get_details() + def update_from_device_list(self, details: ResponseDeviceDetailsModel) -> None: + """Update state from this device's entry in the device list response. + + Called whenever the device list is fetched and this device already exists. + The default does nothing; devices whose `deviceProp` summary is more current + than their detail API override this and set `uses_device_list_state`. + + Args: + details (ResponseDeviceDetailsModel): This device's device list entry. + """ + del details + def display(self, state: bool = True) -> None: """Print formatted static device info to stdout. 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_container.py b/src/pyvesync/device_container.py index 6b4926a9..9f63abec 100644 --- a/src/pyvesync/device_container.py +++ b/src/pyvesync/device_container.py @@ -284,8 +284,26 @@ def add_new_devices( """ device_list = device_list_result.result.list for device in device_list: - if self.device_exists(device.cid, device.subDeviceNo) not in self._data: + existing = self.get_by_cid(device.cid, device.subDeviceNo) + if existing is None: self.add_device_from_model(device, manager) + else: + existing.update_from_device_list(device) + + def get_by_cid( + self, cid: str, sub_device_no: int | None = None + ) -> VeSyncBaseDevice | None: + """Return the device with the given cid and sub_device_no, if present. + + Args: + cid (str): The cid of the device. + sub_device_no (int | None): The sub-device number, None for most devices. + + Returns: + VeSyncBaseDevice | None: The matching device or None. + """ + target = hash(cid + str(sub_device_no)) + return next((dev for dev in self._data if hash(dev) == target), None) @property def outlets(self) -> list[VeSyncOutlet]: diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..59853c4b 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -789,6 +789,23 @@ 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', + HumidifierModes.FAN: 'fan', + }, + # 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/devices/vesynchumidifier.py b/src/pyvesync/devices/vesynchumidifier.py index 99951bd3..87c61310 100644 --- a/src/pyvesync/devices/vesynchumidifier.py +++ b/src/pyvesync/devices/vesynchumidifier.py @@ -4,7 +4,7 @@ import logging import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import orjson from typing_extensions import deprecated @@ -28,6 +28,8 @@ from pyvesync.utils.helpers import Helpers, Timer, Validators if TYPE_CHECKING: + from collections.abc import Callable + from pyvesync import VeSync from pyvesync.device_map import HumidifierMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel @@ -617,6 +619,41 @@ def __init__( """Initialize Superior 6000S Humidifier class.""" super().__init__(details, manager, feature_map) + uses_device_list_state = True + + def update_from_device_list(self, details: ResponseDeviceDetailsModel) -> None: + """Refresh state from the device list `deviceProp` summary. + + The Superior Studio 450S updates its device list entry in near real time + (power, work mode, humidity, water shortage, drying cycle) while the + `getHumidifierStatus` response can lag by minutes, so prefer these values. + Fields absent from the summary are left as set by `get_details()`. + """ + prop = details.deviceProp + if not prop: + return + # deviceProp key -> (state attribute, converter) + mapping: dict[str, tuple[str, Callable[[Any], Any]]] = { + 'powerSwitch': ('device_status', DeviceStatus.from_int), + 'connectionStatus': ('connection_status', ConnectionStatus), + 'workMode': ('mode', self._reverse_mist_modes.get), + 'humidity': ('humidity', int), + 'targetHumidity': ('auto_target_humidity', int), + 'virtualLevel': ('mist_virtual_level', int), + 'mistLevel': ('mist_level', int), + 'waterLacksState': ('water_lacks', bool), + 'screenSwitch': ('display_set_status', DeviceStatus.from_int), + 'childLockSwitch': ('child_lock', bool), + 'filterLifePercent': ('filter_life', int), + 'dryingState': ('drying_mode_status', DryingModes.from_int), + 'autoDryingSwitch': ('drying_mode_auto_switch', DeviceStatus.from_int), + 'dryingLevel': ('drying_mode_level', int), + 'dryingRemain': ('drying_mode_time_remain', int), + } + for key, (attr, convert) in mapping.items(): + if (value := prop.get(key)) is not None: + setattr(self.state, attr, convert(value)) + def _set_state(self, resp_model: models.Superior6000SResult) -> None: """Set state from Superior 6000S API result model.""" self.state.device_status = DeviceStatus.from_int(resp_model.powerSwitch) 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/pyvesync/models/vesync_models.py b/src/pyvesync/models/vesync_models.py index 875f13b4..5c8f92a7 100644 --- a/src/pyvesync/models/vesync_models.py +++ b/src/pyvesync/models/vesync_models.py @@ -182,7 +182,8 @@ def _flatten_device_prop(d: dict[str, Any]) -> dict[str, Any]: d['connectionStatus'] = device_prop['connectionStatus'] if device_prop.get('wifiMac') is not None: d['macID'] = device_prop['wifiMac'] - d.pop('deviceProp', None) + else: + d.pop('deviceProp', None) return d @@ -218,6 +219,13 @@ class ResponseDeviceDetailsModel(ResponseBaseModel): deviceStatus: str = 'off' connectionStatus: str = 'offline' productType: str | None = None + deviceProp: dict[str, Any] | None = None + """Raw per-device status summary from the device list, if the API provides one. + + For some devices (e.g. the Levoit Superior Studio 450S) this is updated in near + real time while the bypassV2 status call lags, so device classes can opt in to + refreshing their state from it via `VeSyncBaseDevice.update_from_device_list`. + """ @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 63ffc436..e2ac845a 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -391,7 +391,12 @@ async def update(self) -> None: await self.update_all_devices() async def update_all_devices(self) -> None: - """Run `get_details()` for each device and update state.""" + """Run `get_details()` for each device and update state. + + If any device reports `uses_device_list_state`, the device list is fetched + once afterwards so those devices can refresh from their `deviceProp` entry, + which for some models is far more current than the detail API response. + """ logger.debug('Start updating the device details one by one') if len(self._device_container) == 0: logger.error('No devices to update') @@ -405,6 +410,10 @@ async def update_all_devices(self) -> None: if exc is not None and isinstance(exc, VeSyncError): logger.error('Error updating device: %s', exc) + if any(dev.uses_device_list_state for dev in self._device_container): + logger.debug('Refreshing device list for live device state') + await self.get_devices() + async def __aenter__(self) -> Self: """Asynchronous context manager enter.""" return self 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..ac289224 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"], @@ -379,3 +380,84 @@ 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 + + def test_superior_studio_device_list_state(self): + """Superior Studio 450S refreshes live state from the device list deviceProp.""" + from pyvesync.models.vesync_models import ResponseDeviceDetailsModel + + obj = self.get_device("humidifiers", "LEH-S451S") + assert obj.uses_device_list_state is True + entry = ResponseDeviceDetailsModel.from_dict( + { + "deviceRegion": "US", + "isOwner": True, + "deviceName": obj.device_name, + "cid": obj.cid, + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LEH-S451S-WUS", + "type": "wifi-air", + "configModule": "VS_WFON_AHM_LEH-E601S-WUS_US", + "deviceProp": { + "powerSwitch": 1, + "connectionStatus": "online", + "workMode": "fan", + "humidity": 41, + "targetHumidity": 50, + "virtualLevel": 25, + "mistLevel": 2, + "waterLacksState": 1, + "screenSwitch": 1, + "childLockSwitch": 0, + "filterLifePercent": 100, + "dryingState": 1, + "autoDryingSwitch": 1, + "dryingLevel": 2, + "dryingRemain": 3600, + "wifiMac": "aa:bb:cc:dd:ee:ff", + }, + } + ) + assert entry.deviceProp is not None + assert entry.deviceStatus == "on" + obj.update_from_device_list(entry) + assert obj.state.device_status == const.DeviceStatus.ON + assert obj.state.connection_status == const.ConnectionStatus.ONLINE + assert obj.state.mode == const.HumidifierModes.FAN + assert obj.state.humidity == 41 + assert obj.state.water_lacks is True + assert obj.state.mist_virtual_level == 25 + assert obj.state.drying_mode_status == const.DryingModes.RUNNING + assert obj.state.drying_mode_time_remain == 3600 + + def test_device_list_entry_without_prop_is_ignored(self): + """Devices without a deviceProp summary leave state untouched.""" + from pyvesync.models.vesync_models import ResponseDeviceDetailsModel + + obj = self.get_device("humidifiers", "LEH-S451S") + before = obj.state.to_dict() + entry = ResponseDeviceDetailsModel.from_dict( + { + "deviceRegion": "US", + "isOwner": True, + "deviceName": obj.device_name, + "cid": obj.cid, + "connectionType": "WiFi", + "deviceType": "LEH-S451S-WUS", + "type": "wifi-air", + "configModule": "VS_WFON_AHM_LEH-E601S-WUS_US", + } + ) + assert entry.deviceProp is None + obj.update_from_device_list(entry) + assert obj.state.to_dict() == before 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..8a47dee8 --- /dev/null +++ b/testing_scripts/dump_humidifier_status.py @@ -0,0 +1,306 @@ +"""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] [--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 +``--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. + +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 + +import argparse +import asyncio +import json +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 + +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 + + +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( # noqa: PLR0913, PLR0917, C901 + email: str, + password: str, + dev_filter: str, + output: str, + watch: int, + name_filter: str | None = None, + watch_devlist: bool = False, +) -> None: + """Run the dump.""" + async with VeSync(email, password) as manager: + if not await manager.login(): + logger.error('Login failed') + sys.exit(1) + + async def fetch_device_list() -> list[dict[str, Any]]: + """Fetch the raw device list (includes devices not in the device map).""" + 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(), + ) + return (resp or {}).get('result', {}).get('list', []) or [] + + devices = await fetch_device_list() + 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) + 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.""" + 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, _ = 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( + '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', + ) + 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): + logger.info( + 'pyvesync parsed state for %s:\n%s', + hum.device_name, + hum.state.to_json(indent=True), + ) + + if watch > 0: + try: + await watch_loop( + targets, + fetch_status, + fetch_device_list if watch_devlist else None, + status_key, + dump, + watch, + output, + ) + finally: + # Ctrl+C cancels the task; make sure what we have is written out. + _write_dump(dump, output) + return + + _write_dump(dump, output) + + +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) + if not quiet: + logger.info('Wrote redacted dump to %s', output) + + +async def watch_loop( # noqa: PLR0913, PLR0917, C901 + targets: list[dict[str, Any]], + fetch_status: Callable[[dict[str, Any]], Awaitable[dict[str, Any] | None]], + fetch_device_list: Callable[[], Awaitable[list[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. + + When ``fetch_device_list`` is given, the device list is polled too and changes in + each target's ``deviceProp`` summary are recorded separately, to compare how quickly + the two cloud sources update. + """ + logger.info('Watching %d device(s) every %ds; Ctrl+C to stop', len(targets), watch) + last: dict[str, dict[str, Any]] = {} + last_prop: 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 {} + last_prop[dev['cid']] = dev.get('deviceProp') or {} + dump['watch'] = [] + try: + while True: + await asyncio.sleep(watch) + if fetch_device_list is not None: + for entry in await fetch_device_list(): + cid = entry.get('cid') + if cid not in last_prop: + continue + prop = entry.get('deviceProp') or {} + changed = _diff(last_prop[cid], prop) + if changed: + logger.info( + '%s deviceProp changed: %s', entry.get('deviceName'), changed + ) + dump['watch'].append( + { + 'time': datetime.now(UTC).isoformat(), + 'device': entry.get('deviceName'), + 'source': 'deviceList', + 'changed': changed, + } + ) + last_prop[cid] = prop + 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'), + 'source': 'getHumidifierStatus', + 'changed': changed, + } + ) + 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') + + +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('--name', default=None, help='optional regex on deviceName') + 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', + ) + p.add_argument( + '--watch-devlist', + action='store_true', + help='with --watch, also poll the device list and diff its deviceProp summary', + ) + a = p.parse_args() + try: + asyncio.run( + main( + a.email, + a.password, + a.filter, + a.output, + a.watch, + a.name, + a.watch_devlist, + ) + ) + except KeyboardInterrupt: + logger.info('Interrupted')