Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/pyvesync/device_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 18 additions & 5 deletions src/pyvesync/devices/vesyncpurifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions src/pyvesync/models/purifier_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/pyvesync/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sha256 hex>``.
"""
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."""

Expand Down
13 changes: 12 additions & 1 deletion src/pyvesync/vesync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down