"""Async IO client library for Modern Forms fans.""" from __future__ import annotations import asyncio import json import socket from datetime import datetime, timedelta from typing import Any, Dict, Optional, Union import aiohttp import async_timeout import backoff # type: ignore from yarl import URL from .__version__ import __version__ from .const import ( COMMAND_ADAPTIVE_LEARNING, COMMAND_AWAY_MODE, COMMAND_FAN_DIRECTION, COMMAND_FAN_POWER, COMMAND_FAN_SLEEP_TIMER, COMMAND_FAN_SPEED, COMMAND_LIGHT_BRIGHTNESS, COMMAND_LIGHT_POWER, COMMAND_LIGHT_SLEEP_TIMER, COMMAND_QUERY_STATIC_DATA, COMMAND_QUERY_STATUS, COMMAND_REBOOT, DEFAULT_API_ENDPOINT, DEFAULT_PORT, DEFAULT_TIMEOUT_SECS, FAN_DIRECTION_FORWARD, FAN_DIRECTION_REVERSE, FAN_SPEED_HIGH_VALUE, FAN_SPEED_LOW_VALUE, LIGHT_BRIGHTNESS_HIGH_VALUE, LIGHT_BRIGHTNESS_LOW_VALUE, SLEEP_TIMER_CANCEL, ) from .exceptions import ( ModernFormsConnectionError, ModernFormsConnectionTimeoutError, ModernFormsEmptyResponseError, ModernFormsError, ModernFormsInvalidSettingsError, ModernFormsNotInitializedError, ) from .models import Device class ModernFormsDevice: """Modern Forms device reppresentation.""" _device: Optional[Device] = None def __init__( self, host: str, port: int = DEFAULT_PORT, base_path: str = "/", username: str = "", password: str = "", request_timeout: float = DEFAULT_TIMEOUT_SECS, session: aiohttp.client.ClientSession = None, tls: bool = False, verify_ssl: bool = True, user_agent: str = None, # type: ignore ) -> None: """Initialize connection with Modern Forms Fan.""" self._session = session self._close_session = False self._base_path = base_path self._host = host self._password = password self._port = port self._socketaddr = None self._request_timeout = request_timeout self._tls = tls self._username = username self._verify_ssl = verify_ssl self._user_agent = user_agent if self._user_agent is None: self._user_agent = f"AIOModernForms/{__version__}" if self._base_path[-1] != "/": self._base_path += "/" self._base_path += DEFAULT_API_ENDPOINT @backoff.on_exception( backoff.expo, ModernFormsEmptyResponseError, max_tries=3, logger=None ) async def update(self, full_update: bool = False) -> Device: """Get all information about the device in a single call.""" info_data = await self._request({COMMAND_QUERY_STATIC_DATA: True}) state_data = await self._request() if not state_data: raise ModernFormsEmptyResponseError( f"Modern Forms device at {self._host}" + " returned an empty API response on full update" ) if self._device is None or full_update: self._device = Device(state_data=state_data, info_data=info_data) self._device.update_from_dict(state_data=state_data) return self._device @backoff.on_exception( backoff.expo, ModernFormsConnectionError, max_tries=3, logger=None ) async def _request(self, commands: Optional[dict] = None) -> Any: """Handle a request to a Modern Forms Fan device.""" scheme = "https" if self._tls else "http" url = URL.build( scheme=scheme, host=self._host, port=self._port, path=self._base_path, ) auth = None if self._username and self._password: auth = aiohttp.BasicAuth(self._username, self._password) headers = { "User-Agent": self._user_agent, "Accept": "application/json", } if self._session is None: self._session = aiohttp.ClientSession() self._close_session = True # If updating the state, always request for a state response if commands is None: commands = {COMMAND_QUERY_STATUS: True} try: with async_timeout.timeout(self._request_timeout): response = await self._session.request( "POST", url, auth=auth, json=commands, headers=headers, ssl=self._verify_ssl, ) except asyncio.TimeoutError as exception: raise ModernFormsConnectionTimeoutError( "Timeout occurred while connecting to Modern Forms device at" + f" {self._host}" ) from exception except (aiohttp.ClientError, socket.gaierror) as exception: raise ModernFormsConnectionError( "Error occurred while communicating with Modern Forms device at" + f" {self._host}" ) from exception content_type = response.headers.get("Content-Type", "") if (response.status // 100) in [4, 5]: contents = await response.read() response.close() if content_type == "application/json": raise ModernFormsError( response.status, json.loads(contents.decode("utf8")) ) raise ModernFormsError( response.status, {"message": contents.decode("utf8")} ) data = await response.json() return data async def request(self, commands: Optional[dict] = None): """Issue one or more commands to the Modern Forms fan.""" if self._device is None: await self.update() data = await self._request(commands=commands) self._device.update_from_dict(state_data=data) # type: ignore return self._device.state # type: ignore @property def status(self): """Fan get status.""" if self._device is None: raise ModernFormsNotInitializedError( "The device has not been initialized. " + "Please run update on the device before getting state" ) return self._device.state @property def info(self): """Fan get info.""" if self._device is None: raise ModernFormsNotInitializedError( "The device has not been initialized. " + "Please run update on the device before getting state" ) return self._device.info async def light( self, *, brightness: Optional[int] = None, on: Optional[bool] = None, sleep: Optional[Union[int, datetime]] = None, ): """Change Fans Light state.""" commands: Dict[str, Union[bool, int]] = {} if brightness is not None: if ( not isinstance(brightness, int) or int(brightness) < LIGHT_BRIGHTNESS_LOW_VALUE or int(brightness) > LIGHT_BRIGHTNESS_HIGH_VALUE ): raise ModernFormsInvalidSettingsError( "brightness value must be between" + f" {LIGHT_BRIGHTNESS_LOW_VALUE} and {LIGHT_BRIGHTNESS_HIGH_VALUE}" ) commands[COMMAND_LIGHT_BRIGHTNESS] = brightness if on is not None: if not isinstance(on, bool): raise ModernFormsInvalidSettingsError("on must be a boolean") commands[COMMAND_LIGHT_POWER] = on if sleep is not None: if isinstance(sleep, int): # turns off sleep timer commands[COMMAND_LIGHT_SLEEP_TIMER] = SLEEP_TIMER_CANCEL if sleep > 0: # count as number of seconds to sleep sleep_till = datetime.now() + timedelta(seconds=sleep) commands[COMMAND_LIGHT_SLEEP_TIMER] = int(sleep_till.timestamp()) elif isinstance(sleep, datetime) and not ( sleep < datetime.now() or sleep > (datetime.now() + timedelta(hours=24)) ): commands[COMMAND_LIGHT_SLEEP_TIMER] = int(sleep.timestamp()) else: raise ModernFormsInvalidSettingsError( "The time to sleep till must be a datetime object that is not more" + " then 24 hours into the future, or an interger for number of" + " seconds to sleep. 0 cancels the sleep timer." ) await self.request(commands=commands) async def fan( self, *, on: Optional[bool] = None, sleep: Optional[Union[int, datetime]] = None, speed: Optional[int] = None, direction: Optional[str] = None, ): """Change Fans Fan state.""" commands: Dict[str, Union[bool, int, str]] = {} if speed is not None: if ( not isinstance(speed, int) or int(speed) < FAN_SPEED_LOW_VALUE or int(speed) > FAN_SPEED_HIGH_VALUE ): raise ModernFormsInvalidSettingsError( "speed value must be between" + f" {FAN_SPEED_LOW_VALUE} and {FAN_SPEED_HIGH_VALUE}" ) commands[COMMAND_FAN_SPEED] = speed if on is not None: if not isinstance(on, bool): raise ModernFormsInvalidSettingsError("on must be a boolean") commands[COMMAND_FAN_POWER] = on if sleep is not None: if isinstance(sleep, int): # turns off sleep timer commands[COMMAND_FAN_SLEEP_TIMER] = SLEEP_TIMER_CANCEL if sleep > 0: # count as number of seconds to sleep sleep_till = datetime.now() + timedelta(seconds=sleep) commands[COMMAND_FAN_SLEEP_TIMER] = int(sleep_till.timestamp()) elif isinstance(sleep, datetime) and not ( sleep < datetime.now() or sleep > (datetime.now() + timedelta(hours=24)) ): commands[COMMAND_FAN_SLEEP_TIMER] = int(sleep.timestamp()) else: raise ModernFormsInvalidSettingsError( "The time to sleep till must be a datetime object that is not more" + " then 24 hours into the future, or an interger for number of" + " seconds to sleep. 0 cancels the sleep timer." ) if direction is not None: if not isinstance(direction, str) or direction not in [ FAN_DIRECTION_FORWARD, FAN_DIRECTION_REVERSE, ]: raise ModernFormsInvalidSettingsError( f"fan direction must be {FAN_DIRECTION_FORWARD}" + f" or {FAN_DIRECTION_REVERSE}" ) commands[COMMAND_FAN_DIRECTION] = direction await self.request(commands=commands) async def away(self, away=bool): """Change the away state of the device.""" await self.request( commands={COMMAND_AWAY_MODE: away, COMMAND_QUERY_STATUS: True} ) async def adaptive_learning(self, adaptive_learning=bool): """Change the adaptive learning state of the device.""" await self.request( commands={ COMMAND_ADAPTIVE_LEARNING: adaptive_learning, COMMAND_QUERY_STATUS: True, } ) async def reboot(self): """Send a reboot to the Fan.""" try: await self.request(commands={COMMAND_REBOOT: True}) except ModernFormsConnectionTimeoutError: # a successful reboot drops the connection pass async def close(self) -> None: """Close open client session.""" if self._session and self._close_session: await self._session.close() async def __aenter__(self) -> ModernFormsDevice: """Async enter.""" return self async def __aexit__(self, *exc_info) -> None: """Async exit.""" await self.close()