From acf0cd859a8400c370c1be7d59327f3a139de5e8 Mon Sep 17 00:00:00 2001 From: "Jose A. Martin" Date: Sat, 5 Sep 2026 23:22:18 +0200 Subject: [PATCH 1/4] feat(purifier): add support for Levoit Eleva 300X (LAP-C341S-WEU) Adds a new PurifierMap entry using the VeSyncAirBaseV2 class, as the Eleva 300X shares the same bypassV2 protocol as the Vital 100S/200S and Everest Air purifiers. Makes filterLifePercent, screenState, childLockSwitch, screenSwitch, lightDetectionSwitch, environmentLightState, scheduleCount, timerRemain, efficientModeTimeRemain and errorCode optional in PurifierVitalDetailsResult with sensible defaults, and makes V2AutoPreferences.roomSize optional, since the Eleva 300X does not report these fields the same way as existing Vital-class devices. Adds test fixtures for LAP-C341S-WEU in call_json_purifiers.py and the corresponding recorded API response. --- README.md | 1 + src/pyvesync/device_map.py | 20 +++ src/pyvesync/models/purifier_models.py | 22 +-- .../api/vesyncpurifier/LAP-C341S-WEU.yaml | 167 ++++++++++++++++++ src/tests/call_json_purifiers.py | 21 +++ 5 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 src/tests/api/vesyncpurifier/LAP-C341S-WEU.yaml diff --git a/README.md b/README.md index 5ceff41b..2c4ba960 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ pip install pyvesync 7. Vital 100S / 200S 8. Everest Air 9. Sprout Air Purifier +10. Eleva 300X ### Etekcity / Valceno Bulbs diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..eba86685 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1026,6 +1026,26 @@ class ThermostatMap(DeviceMapTemplate): model_name='Sprout Air', setup_entry='LAP-B851S-WUS', ), + PurifierMap( + class_name='VeSyncAirBaseV2', + dev_types=['LAP-C341S-WEU'], + modes=[ + PurifierModes.SLEEP, + PurifierModes.MANUAL, + PurifierModes.AUTO, + ], + features=[PurifierFeatures.AIR_QUALITY], + fan_levels=list(range(1, 5)), + device_alias='Eleva 300X', + auto_preferences=[ + PurifierAutoPreference.DEFAULT, + PurifierAutoPreference.EFFICIENT, + PurifierAutoPreference.QUIET, + ], + model_display='LAP-C341S Series', + model_name='Eleva 300X', + setup_entry='LAP-C341S-WEU', + ), ] """List of ['PurifierMap'][pyvesync.device_map.PurifierMap] configuration objects for purifier devices.""" diff --git a/src/pyvesync/models/purifier_models.py b/src/pyvesync/models/purifier_models.py index d510ef33..3b6207e7 100644 --- a/src/pyvesync/models/purifier_models.py +++ b/src/pyvesync/models/purifier_models.py @@ -41,21 +41,21 @@ class PurifierVitalDetailsResult(InnerPurifierBaseResult): """Vital 100S/200S and Everest Purifier Result Model.""" powerSwitch: int - filterLifePercent: int workMode: str manualSpeedLevel: int fanSpeedLevel: int AQLevel: int PM25: int - screenState: int - childLockSwitch: int - screenSwitch: int - lightDetectionSwitch: int - environmentLightState: int - scheduleCount: int - timerRemain: int - efficientModeTimeRemain: int - errorCode: int + filterLifePercent: int = 100 + screenState: int = 0 + childLockSwitch: int = 0 + screenSwitch: int = 0 + lightDetectionSwitch: int = 0 + environmentLightState: int = 0 + scheduleCount: int = 0 + timerRemain: int = 0 + efficientModeTimeRemain: int = 0 + errorCode: int = 0 autoPreference: V2AutoPreferences | None = None fanRotateAngle: int | None = None filterOpenState: int | None = None @@ -69,7 +69,7 @@ class V2AutoPreferences: """Vital 100S/200S Auto Preferences.""" autoPreferenceType: str - roomSize: int + roomSize: int | None = None @dataclass diff --git a/src/tests/api/vesyncpurifier/LAP-C341S-WEU.yaml b/src/tests/api/vesyncpurifier/LAP-C341S-WEU.yaml new file mode 100644 index 00000000..57c21f41 --- /dev/null +++ b/src/tests/api/vesyncpurifier/LAP-C341S-WEU.yaml @@ -0,0 +1,167 @@ +set_fan_speed: + 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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-WEU-CID + method: bypassV2 + payload: + data: + levelIdx: 0 + levelType: wind + manualSpeedLevel: 3 + method: setLevel + 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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-WEU-CID + method: bypassV2 + payload: + data: + levelIdx: 0 + levelType: wind + manualSpeedLevel: 1 + method: setLevel + 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_sleep_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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-WEU-CID + method: bypassV2 + payload: + data: + workMode: sleep + method: setPurifierMode + 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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-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_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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-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 +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: LAP-C341S-WEU-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: LAP-C341S-WEU-CID + method: bypassV2 + payload: + data: {} + method: getPurifierStatus + 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_purifiers.py b/src/tests/call_json_purifiers.py index 79b02dcd..3179fc33 100644 --- a/src/tests/call_json_purifiers.py +++ b/src/tests/call_json_purifiers.py @@ -197,6 +197,25 @@ class PurifierDefaults: "timerRemain": 0, "efficientModeTimeRemain": 0, "errorCode": 0, + }, + "LAP-C341S-WEU": { # Eleva 300X + "powerSwitch": int(DeviceStatus.ON), + "filterLifePercent": PurifierDefaults.filter_life, + "workMode": PurifierDefaults.purifier_mode, + "manualSpeedLevel": PurifierDefaults.fan_set_level, + "fanSpeedLevel": PurifierDefaults.fan_level, + "AQLevel": int(PurifierDefaults.air_quality_enum), + "PM25": PurifierDefaults.air_quality_value_pm25, + "screenState": int(PurifierDefaults.display), + "childLockSwitch": int(PurifierDefaults.child_lock), + "screenSwitch": int(PurifierDefaults.display_config), + "lightDetectionSwitch": int(PurifierDefaults.light_detection), + "environmentLightState": int(PurifierDefaults.light_detected), + "autoPreference": {"autoPreferenceType": "default"}, + "scheduleCount": 0, + "timerRemain": 0, + "efficientModeTimeRemain": 0, + "errorCode": 0, }, "LAP-V201S": { # Vital 200S "powerSwitch": int(DeviceStatus.ON), @@ -318,6 +337,7 @@ class PurifierDefaults: "LAP-V201S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-V201S"]), "EL551S": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["EL551S"]), "LAP-B851S-WUS": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-B851S-WUS"]), + "LAP-C341S-WEU": build_bypass_v2_response(inner_result=PURIFIER_DETAILS["LAP-C341S-WEU"]), } FunctionResponses.default_factory = lambda: ( @@ -341,6 +361,7 @@ class PurifierDefaults: "LAP-V201S": deepcopy(FunctionResponsesV2), "EL551S": deepcopy(FunctionResponsesV2), "LAP-B851S-WUS": deepcopy(FunctionResponsesV2), + "LAP-C341S-WEU": deepcopy(FunctionResponsesV2), } # Add responses for methods with different responses than the default From 8dfd6beb56f9a0e2dd3d87f4aa3bd37b14391258 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:24:27 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/device_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index eba86685..8846f62f 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1026,7 +1026,7 @@ class ThermostatMap(DeviceMapTemplate): model_name='Sprout Air', setup_entry='LAP-B851S-WUS', ), - PurifierMap( + PurifierMap( class_name='VeSyncAirBaseV2', dev_types=['LAP-C341S-WEU'], modes=[ From bf7d193d59c9c8380031eab8a96994036fc4f49b Mon Sep 17 00:00:00 2001 From: "Jose A. Martin" Date: Thu, 10 Sep 2026 15:21:05 +0200 Subject: [PATCH 3/4] feat(purifier): add carbon filter life tracking for VeSyncAirBaseV2Adds carbon_filter_life state attribute and carbonFilterLifePercentfield, following the same approach as PR #546 (jordanenglish) forthe LAP-C341S-WUSZ variant. The Eleva 300X reports this fieldregardless of region. --- src/pyvesync/base_devices/purifier_base.py | 3 +++ src/pyvesync/devices/vesyncpurifier.py | 1 + src/pyvesync/models/purifier_models.py | 1 + 3 files changed, 5 insertions(+) diff --git a/src/pyvesync/base_devices/purifier_base.py b/src/pyvesync/base_devices/purifier_base.py index 2e8e5d98..f8b21ad4 100644 --- a/src/pyvesync/base_devices/purifier_base.py +++ b/src/pyvesync/base_devices/purifier_base.py @@ -40,6 +40,7 @@ class PurifierState(DeviceState): fan_level (int): Current fan level of the purifier. fan_set_level (int): Set fan level of the purifier. filter_life (int): Filter life percentage of the purifier. + carbon_filter_life (int): Carbon filter life percentage of the purifier. auto_preference_type (str): Auto preference type of the purifier. auto_room_size (int): Auto room size of the purifier. air_quality_level (AirQualityLevel): Air quality level of the purifier. @@ -79,6 +80,7 @@ class PurifierState(DeviceState): 'fan_level', 'fan_rotate_angle', 'fan_set_level', + 'carbon_filter_life', 'filter_life', 'filter_open_state', 'humidity', @@ -106,6 +108,7 @@ def __init__( self.fan_level: int | None = None self.fan_set_level: int | None = None self.filter_life: int | None = None + self.carbon_filter_life: int | None = None self.auto_preference_type: str | None = None self.auto_room_size: int | None = None self._air_quality_level: AirQualityLevel | None = None diff --git a/src/pyvesync/devices/vesyncpurifier.py b/src/pyvesync/devices/vesyncpurifier.py index ba3ba808..c756b9c4 100644 --- a/src/pyvesync/devices/vesyncpurifier.py +++ b/src/pyvesync/devices/vesyncpurifier.py @@ -460,6 +460,7 @@ def _set_state(self, details: InnerPurifierBaseResult) -> None: self.state.device_status = DeviceStatus.from_int(details.powerSwitch) self.state.mode = details.workMode self.state.filter_life = details.filterLifePercent + self.state.carbon_filter_life = details.carbonFilterLifePercent if details.fanSpeedLevel == 255: # noqa: PLR2004 self.state.fan_level = 0 else: diff --git a/src/pyvesync/models/purifier_models.py b/src/pyvesync/models/purifier_models.py index 3b6207e7..528ed5c1 100644 --- a/src/pyvesync/models/purifier_models.py +++ b/src/pyvesync/models/purifier_models.py @@ -62,6 +62,7 @@ class PurifierVitalDetailsResult(InnerPurifierBaseResult): PM1: int | None = None PM10: int | None = None AQPercent: int | None = None + carbonFilterLifePercent: int | None = None @dataclass From a8a28ad73eb3aa5a326270318839c798ba3621a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:24:08 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/base_devices/purifier_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyvesync/base_devices/purifier_base.py b/src/pyvesync/base_devices/purifier_base.py index f8b21ad4..d4fa1c6b 100644 --- a/src/pyvesync/base_devices/purifier_base.py +++ b/src/pyvesync/base_devices/purifier_base.py @@ -72,6 +72,7 @@ class PurifierState(DeviceState): 'aq_percent', 'auto_preference_type', 'auto_room_size', + 'carbon_filter_life', 'child_lock', 'co2', 'display_forever', @@ -80,7 +81,6 @@ class PurifierState(DeviceState): 'fan_level', 'fan_rotate_angle', 'fan_set_level', - 'carbon_filter_life', 'filter_life', 'filter_open_state', 'humidity',