diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..2a0072b3 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -947,6 +947,8 @@ class ThermostatMap(DeviceMapTemplate): 'LAP-V201-AUSR', 'LAP-V201S-AUSR', 'LAP-V201S-AEUR', + 'LAP-P501S-WUSR', + 'LAP-P501S-AUSR', ], modes=[ PurifierModes.SLEEP, diff --git a/src/pyvesync/devices/vesyncpurifier.py b/src/pyvesync/devices/vesyncpurifier.py index ba3ba808..bf56d042 100644 --- a/src/pyvesync/devices/vesyncpurifier.py +++ b/src/pyvesync/devices/vesyncpurifier.py @@ -451,6 +451,17 @@ def __init__( """Initialize the VeSync Base API V2 Air Purifier Class.""" super().__init__(details, manager, feature_map) + @property + def fan_max_level(self) -> int: + """Return the maximum fan speed level for this device. + + The Levoit Vital Pet Pro (LAP-P501S*) has only 3 fan speeds + (1-3) compared to the standard Vital 200S which has 4 (1-4). + """ + if 'P501S' in self.device_type: + return 3 + return 4 + def _set_state(self, details: InnerPurifierBaseResult) -> None: """Set Purifier state from details response.""" if not isinstance(details, PurifierVitalDetailsResult): @@ -487,7 +498,7 @@ def _set_state(self, details: InnerPurifierBaseResult) -> None: self.state.fan_rotate_angle = details.fanRotateAngle if details.filterOpenState is not None: self.state.filter_open_state = bool(details.filterOpenState) - if details.timerRemain > 0: + if details.timerRemain is not None and details.timerRemain > 0: self.state.timer = Timer(details.timerRemain, 'off') @property @@ -675,19 +686,21 @@ async def set_auto_preference( return True async def set_fan_speed(self, speed: None | int = None) -> bool: + max_level = self.fan_max_level + allowed_levels = list(range(1, max_level + 1)) if speed is not None: - if speed not in self.fan_levels: + if speed not in allowed_levels: _LOGGER.warning( '%s is invalid speed - valid speeds are %s', speed, - str(self.fan_levels), + str(allowed_levels), ) return False new_speed = speed elif self.state.fan_level is None: - new_speed = self.fan_levels[0] + new_speed = allowed_levels[0] else: - new_speed = Helpers.bump_level(self.state.fan_level, self.fan_levels) + new_speed = Helpers.bump_level(self.state.fan_level, allowed_levels) payload_data = {'levelIdx': 0, 'manualSpeedLevel': new_speed, 'levelType': 'wind'} r_dict = await self.call_bypassv2_api('setLevel', payload_data) diff --git a/src/pyvesync/models/purifier_models.py b/src/pyvesync/models/purifier_models.py index d510ef33..80ac5cd8 100644 --- a/src/pyvesync/models/purifier_models.py +++ b/src/pyvesync/models/purifier_models.py @@ -38,7 +38,7 @@ class Config(BaseConfig): # type: ignore[override] @dataclass class PurifierVitalDetailsResult(InnerPurifierBaseResult): - """Vital 100S/200S and Everest Purifier Result Model.""" + """Vital 100S/200S, Everest, and Vital Pet Pro Purifier Result Model.""" powerSwitch: int filterLifePercent: int @@ -50,12 +50,12 @@ class PurifierVitalDetailsResult(InnerPurifierBaseResult): screenState: int childLockSwitch: int screenSwitch: int - lightDetectionSwitch: int - environmentLightState: int - scheduleCount: int - timerRemain: int - efficientModeTimeRemain: int - errorCode: int + scheduleCount: int | None = None + timerRemain: int | None = None + errorCode: int | None = None + lightDetectionSwitch: int | None = None + environmentLightState: int | None = None + efficientModeTimeRemain: int | None = None autoPreference: V2AutoPreferences | None = None fanRotateAngle: int | None = None filterOpenState: int | None = None diff --git a/src/pyvesync/utils/helpers.py b/src/pyvesync/utils/helpers.py index 89948510..0ff48a1c 100644 --- a/src/pyvesync/utils/helpers.py +++ b/src/pyvesync/utils/helpers.py @@ -124,6 +124,27 @@ def temperature_celsius_to_fahrenheit(celsius: float) -> float: return celsius * 9.0 / 5.0 + 32 +VESYNC_CERT_SHA1 = '2CA4647FA8C5C9475B13FBD47984E525F38AD3B8' + + +def generate_pack_file_signature(trace_id: str) -> str: + """Generate the _packFileSignature header value for VeSync API requests. + + The VeSync backend validates this signature using the standard AOSP test key + certificate SHA-1 hash rather than Etekcity's production certificate. This + allows us to compute the signature cleanly in Python. + + Args: + trace_id: The traceId value from the request body. + + Returns: + The formatted signature string, e.g. ``v0001-``. + """ + raw = str(trace_id) + VESYNC_CERT_SHA1 + h = hashlib.sha256(raw.encode('utf-8')).hexdigest()[:64].lower() + return f'v0001-{h}' + + class Helpers: """VeSync Helper Functions.""" diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 63ffc436..8adfca85 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -40,7 +40,10 @@ VeSyncTokenError, raise_api_errors, ) -from pyvesync.utils.helpers import Helpers +from pyvesync.utils.helpers import ( + Helpers, + generate_pack_file_signature, +) from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: @@ -482,6 +485,14 @@ async def async_call_api( req_dict = json_object else: req_dict = None + # Inject _packFileSignature and _signOsInfo headers when a traceId is present + if isinstance(req_dict, dict): + trace_id = req_dict.get('traceId') + if trace_id is None and 'payload' in req_dict: + trace_id = req_dict['payload'].get('traceId') + if headers is not None and trace_id is not None: + headers['_packFileSignature'] = generate_pack_file_signature(trace_id) + headers['_signOsInfo'] = 'Android' try: async with self.session.request( method,