"""VeSync API Device Library.""" from __future__ import annotations import asyncio import logging from dataclasses import MISSING, fields from pathlib import Path from typing import TYPE_CHECKING, Self from aiohttp import ClientSession from aiohttp.client_exceptions import ClientResponseError from mashumaro.mixins.orjson import DataClassORJSONMixin from pyvesync.auth import VeSyncAuth from pyvesync.const import ( DEFAULT_REGION, DEFAULT_TZ, MAX_API_REAUTH_RETRIES, REGION_API_MAP, STATUS_OK, ConnectionStatus, ProductTypes, ) from pyvesync.device_container import DeviceContainer from pyvesync.models.vesync_models import ( FirmwareDeviceItemModel, RequestDeviceListModel, RequestFirmwareModel, ResponseDeviceListModel, ResponseFirmwareModel, ) from pyvesync.utils.errors import ( ErrorCodes, ErrorTypes, VeSyncAPIResponseError, VeSyncAPIStatusCodeError, VeSyncError, VeSyncServerError, VeSyncTokenError, raise_api_errors, ) from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync.base_devices import VeSyncBaseDevice logger = logging.getLogger(__name__) class VeSync: # pylint: disable=function-redefined """VeSync Manager Class.""" __slots__ = ( '__weakref__', '_api_attempts', '_auth', '_close_session', '_debug', '_device_container', '_redact', '_verbose', 'enabled', 'in_process', 'language', 'session', 'time_zone', ) def __init__( self, username: str, password: str, country_code: str = DEFAULT_REGION, session: ClientSession | None = None, time_zone: str = DEFAULT_TZ, redact: bool = True, ) -> None: """Initialize VeSync Manager. This class is used as the manager for all VeSync objects, all methods and API calls are performed from this class. Time zone, debug and redact are optional. Time zone must be a string of an IANA time zone format. Once class is instantiated, call `await manager.login()` to log in to VeSync servers, which returns `True` if successful. Once logged in, call `await manager.get_devices()` to retrieve devices. Then `await `manager.update()` to update all devices or `await manager.devices[0].update()` to update a single device. Args: username (str): VeSync account username (usually email address) password (str): VeSync account password country_code (str): VeSync account country in ISO 3166 Alpha-2 format. By default, the account region is detected automatically at the login step If your account country is different from the default `US`, a second login attempt may be necessary - in this case you should specify the country directly to speed up the login process. session (ClientSession): aiohttp client session for API calls, by default None time_zone (str): Time zone for device from IANA database, by default DEFAULT_TZ. This is automatically set to the time zone of the VeSync account during login. redact (bool): Enable redaction of sensitive information, by default True. Attributes: session (ClientSession): Client session for API calls devices (DeviceContainer): Container for all VeSync devices, has functionality of a mutable set. See [`DeviceContainer`][pyvesync.device_container.DeviceContainer] for more information auth (VeSyncAuth): Authentication manager time_zone (str): Time zone for VeSync account pulled from API enabled (bool): True if logged in to VeSync, False if not Note: This class is a context manager, use `async with VeSync() as manager:` to manage the session context. The session will be closed when exiting if no session is passed in. The `manager.devices` attribute is a DeviceContainer object that contains all VeSync devices. The `manager.devices` object has the functionality of a set, and can be iterated over to access devices. See :obj:`DeviceContainer` for more information. If using a context manager is not convenient, `manager.__aenter__()` and `manager.__aexit__()` can be called directly. Either username/password or token/account_id must be provided for authentication. See Also: :obj:`DeviceContainer` Container object to store VeSync devices :obj:`DeviceState` Object to store device state information """ self.session = session self._api_attempts = 0 self._close_session = False self.redact = redact self._verbose: bool = False self.time_zone: str = time_zone self.language: str = 'en' self.enabled = False self.in_process = False self._device_container: DeviceContainer = DeviceContainer() # Initialize authentication manager self._auth = VeSyncAuth( manager=self, username=username, password=password, country_code=country_code, ) @property def devices(self) -> DeviceContainer: """Return VeSync device container. See Also: The pyvesync.device_container.DeviceContainer object for methods and properties. """ return self._device_container @property def auth(self) -> VeSyncAuth: """Return VeSync authentication manager.""" return self._auth @property def country_code(self) -> str: """Return country code.""" return self._auth.country_code @property def current_region(self) -> str: """Return current region.""" return self._auth.current_region @property def token(self) -> str: """Return authentication token. Returns: str: Authentication token. Raises: AttributeError: If token is not set. """ return self._auth.token @property def account_id(self) -> str: """Return account ID. Returns: str: Account ID. Raises: AttributeError: If account ID is not set. """ return self._auth.account_id @property def debug(self) -> bool: """Return debug flag.""" return LibraryLogger.debug_enabled def check_debug(self) -> bool: """Check if debug logging is enabled - should be called minimally.""" return LibraryLogger.check_debug() @property def redact(self) -> bool: """Return debug flag.""" return self._redact @redact.setter def redact(self, new_flag: bool) -> None: """Set debug flag.""" if new_flag: LibraryLogger.shouldredact = True elif new_flag is False: LibraryLogger.shouldredact = False self._redact = new_flag def output_credentials_json(self) -> str | None: """Output current authentication credentials as a JSON string.""" return self.auth.output_credentials_json() def output_credentials_dict(self) -> dict[str, str] | None: """Output current authentication credentials as a dictionary.""" return self.auth.output_credentials_dict() async def save_credentials(self, filename: str | Path | None) -> None: """Save authentication credentials to a file. Args: filename (str | Path | None): The name of the file to save credentials to. If None, no action is taken. """ if filename is not None: await self.auth.save_credentials_to_file(filename) async def load_credentials_from_file( self, filename: str | Path | None = None ) -> bool: """Load authentication credentials from a file. Args: filename (str | Path | None): The name of the file to load credentials from. If None, no action is taken. Returns: bool: True if credentials were loaded successfully, False otherwise. """ return await self.auth.load_credentials_from_file(filename) def set_credentials( self, token: str, account_id: str, country_code: str, region: str ) -> None: """Set authentication credentials. Args: token (str): Authentication token. account_id (str): Account ID. country_code (str): Country code in ISO 3166 Alpha-2 format. region (str): Current region code. """ self._auth.set_credentials(token, account_id, country_code, region) def log_to_file(self, filename: str | Path, stdout: bool = True) -> None: """Log to file and enable debug logging. Args: filename (str | Path): The name of the file to log to. stdout (bool): Whether to also log to stdout, by default True. """ LibraryLogger.configure_file_logging(filename, level=logging.DEBUG, stdout=stdout) logger.debug('Logging to file: %s', filename) def process_devices(self, dev_list_resp: ResponseDeviceListModel) -> bool: """Instantiate Device Objects. Internal method run by `get_devices()` to instantiate device objects. """ current_device_count = len(self._device_container) if current_device_count > 0: self._device_container.remove_stale_devices(dev_list_resp) new_device_count = len(self._device_container) if new_device_count != current_device_count: logger.debug( 'Removed %s devices', str(current_device_count - new_device_count) ) current_device_count = new_device_count self._device_container.add_new_devices(dev_list_resp, self) new_device_count = len(self._device_container) if new_device_count != current_device_count: logger.debug('Added %s devices', str(new_device_count - current_device_count)) return True async def get_devices(self) -> bool: """Return tuple listing outlets, switches, and fans of devices. This is also called by `VeSync.update()` Raises: VeSyncAPIResponseError: If API response is invalid. VeSyncServerError: If server returns an error. """ self.in_process = True proc_return = False if not self.auth.is_authenticated or ( not self.auth.token or not self.auth.account_id ): logger.info("Not logged in to VeSync, can't get devices") return False request_model = RequestDeviceListModel( token=self.auth.token, accountID=self.auth.account_id, timeZone=self.time_zone ) logger.debug('Requesting device list from VeSync') response_dict, _ = await self.async_call_api( '/cloud/v1/deviceManaged/devices', 'post', headers=Helpers.req_header_bypass(), json_object=request_model.to_dict(), ) if response_dict is None: raise VeSyncAPIResponseError( 'Error receiving response to device list request' ) response = ResponseDeviceListModel.from_dict(response_dict) if response.code == 0: proc_return = self.process_devices(response) else: error_info = ErrorCodes.get_error_info(response.code) if response.msg is not None: error_info.message = f'{error_info.message} ({response.msg})' if error_info.error_type == ErrorTypes.SERVER_ERROR: raise VeSyncServerError(error_info.message) raise VeSyncAPIResponseError( 'Error receiving response to device list request' ) self.in_process = False return proc_return async def login(self) -> bool: # pylint: disable=W9006 # pylint mult docstring raises """Log into VeSync server. Username and password are provided when class is instantiated. Returns: True if login successful, False otherwise Raises: VeSyncLoginError: If login fails, for example due to invalid username or password. VeSyncAPIResponseError: If API response is invalid. VeSyncServerError: If server returns an error. """ self.enabled = False success = await self._auth.login() if success: self.enabled = True return success async def update(self) -> None: """Fetch updated information about devices and new device list. Pulls devices list from VeSync and instantiates any new devices. Devices are stored in the instance attributes `outlets`, `switches`, `fans`, and `bulbs`. The `_device_list` attribute is a dictionary of these attributes. """ if not self.enabled: logger.error('Not logged in to VeSync') return await self.get_devices() await self.update_all_devices() async def update_all_devices(self) -> None: """Run `get_details()` for each device and update state.""" logger.debug('Start updating the device details one by one') if len(self._device_container) == 0: logger.error('No devices to update') return update_tasks: list[asyncio.Task] = [ asyncio.create_task(device.update()) for device in self._device_container ] done, _ = await asyncio.wait(update_tasks, return_when=asyncio.ALL_COMPLETED) for task in done: exc = task.exception() if exc is not None and isinstance(exc, VeSyncError): logger.error('Error updating device: %s', exc) async def __aenter__(self) -> Self: """Asynchronous context manager enter.""" return self async def __aexit__(self, *exec_info: object) -> None: """Asynchronous context manager exit.""" if self.session and self._close_session: logger.debug('Closing session, exiting context manager') await self.session.close() return logger.debug('Session not closed, exiting context manager') async def _reauthenticate(self) -> bool: """Re-authenticate using stored username and password. Returns: True if re-authentication successful, False otherwise """ self.enabled = False self._api_attempts += 1 if self._api_attempts >= MAX_API_REAUTH_RETRIES: logger.error('Max API re-authentication attempts reached') raise VeSyncTokenError success = await self.auth.reauthenticate() if success: self.enabled = True self._api_attempts = 0 return True return await self.auth.reauthenticate() async def async_call_api( self, api: str, method: str, json_object: dict | None | DataClassORJSONMixin = None, headers: dict | None = None, device: VeSyncBaseDevice | None = None, ) -> tuple[dict | None, int]: """Make API calls by passing endpoint, header and body. api argument is appended to `API_BASE_URL`. Raises VeSyncRateLimitError if API returns a rate limit error. Args: api (str): Endpoint to call with `API_BASE_URL`. method (str): HTTP method to use. json_object (dict | RequestBaseModel): JSON object to send in body. headers (dict): Headers to send with request. device (VeSyncBaseDevice | None): Device making the request, if any. Returns: tuple[dict | None, int]: Response and status code. Attempts to parse response as JSON, if not possible returns None. Raises: VeSyncAPIStatusCodeError: If API returns an error status code. VeSyncRateLimitError: If API returns a rate limit error. VeSyncServerError: If API returns a server error. VeSyncTokenError: If API returns an authentication error. ClientResponseError: If API returns a client response error. Note: Future releases will require the `json_object` argument to be a dataclass, instead of dictionary. """ self.check_debug() if self.session is None: self.session = ClientSession() self._close_session = True response = None status_code = None if isinstance(json_object, DataClassORJSONMixin): req_dict = json_object.to_dict() elif isinstance(json_object, dict): req_dict = json_object else: req_dict = None try: async with self.session.request( method, url=self._api_base_url_for_current_region() + api, json=req_dict, headers=headers, raise_for_status=False, ) as response: resp_bytes = await response.read() resp_status = response.status if resp_status != STATUS_OK: LibraryLogger.log_api_status_error(logger, response=response) raise VeSyncAPIStatusCodeError(str(resp_status)) LibraryLogger.log_api_call( logger, response=response, response_body=resp_bytes, request_headers=headers, request_body=req_dict, ) resp_dict, status_code = await self._api_response_wrapper( resp_bytes, resp_status, api, req_dict, device=device ) except ClientResponseError as e: LibraryLogger.log_api_exception(logger, exception=e, request_body=req_dict) raise return resp_dict, status_code async def _api_response_wrapper( self, response_bytes: bytes | None, status_code: int, endpoint: str, request_body: dict | None, device: VeSyncBaseDevice | None = None, ) -> tuple[dict | None, int]: """Internal wrapper used by async_call_api.""" resp_dict = LibraryLogger.try_json_loads(response_bytes) if not isinstance(resp_dict, dict): return None, status_code error_info = Helpers.parse_error_code(resp_dict) if error_info.error_type == ErrorTypes.TOKEN_ERROR: self.enabled = False if await self._reauthenticate(): return await self.async_call_api( endpoint, 'post', request_body, device=device ) raise VeSyncTokenError(error_info.message) raise_api_errors(error_info) return resp_dict, status_code def _api_base_url_for_current_region(self) -> str: """Retrieve the API base url for the current region. At this point, only two different URLs exist: One for `EU` region (for all EU countries), and one for all others (currently `US`, `CA`, `MX`, `JP` - also used as a fallback). If `API_BASE_URL` is set, it will take precedence over the determined URL. """ return REGION_API_MAP[self.current_region] def _update_fw_version(self, info_list: list[FirmwareDeviceItemModel]) -> bool: """Update device firmware versions from API response.""" if not info_list: logger.info('No devices found in firmware response') return False update_dict = {} for device in info_list: if not device.firmUpdateInfos: if device.code != 0: logger.info( 'Device %s has error code %s with message: %s', device.deviceName, device.code, device.msg, ) else: logger.debug( 'Device %s has no firmware updates available', device.deviceName ) continue for update_info in device.firmUpdateInfos: update_dict[device.deviceCid] = ( update_info.currentVersion, update_info.latestVersion, ) if update_info.isMainFw is True: break for device_obj in self._device_container: if device_obj.cid in update_dict: device_obj.latest_firm_version = update_dict[device_obj.cid][1] device_obj.current_firm_version = update_dict[device_obj.cid][0] return True async def check_firmware(self) -> bool: """Check for firmware updates for all devices. This method will check for firmware updates for all devices in the device container. It will call the `get_firmware_update()` method on each device and log the results. """ if len(self.devices) == 0: logger.warning('No devices to check for firmware updates') return False body_fields = tuple( field.name for field in fields(RequestFirmwareModel) if field.default_factory is MISSING and field.default is MISSING ) body = Helpers.get_manager_attributes(self, body_fields) # Add cid list of devices that are online and not an air fryer body['cidList'] = [ device.cid for device in self.devices if ( device.product_type != ProductTypes.AIR_FRYER and device.state.connection_status == ConnectionStatus.ONLINE ) ] if len(body['cidList']) == 0: logger.warning( 'No online devices (excluding air fryers) to check firmware for' ) return False resp_dict, _ = await self.async_call_api( '/cloud/v2/deviceManaged/getFirmwareUpdateInfoList', 'post', json_object=RequestFirmwareModel(**body), ) if resp_dict is None: raise VeSyncAPIResponseError( 'Error receiving response to firmware update request' ) resp_model = ResponseFirmwareModel.from_dict(resp_dict) if resp_model.code != 0: error_info = ErrorCodes.get_error_info(resp_model.code) resp_message = resp_model.msg if resp_message is not None: error_info.message = f'{error_info.message} ({resp_message})' logger.warning('Error in firmware update response: %s', error_info.message) return False info_list = resp_model.result.cidFwInfoList return self._update_fw_version(info_list)