diff --git a/.USER_AGENT b/.USER_AGENT index 8107c56..e69de29 100644 --- a/.USER_AGENT +++ b/.USER_AGENT @@ -1 +0,0 @@ -#RANDOM:5 \ No newline at end of file diff --git a/README.md b/README.md index 14f61b7..468bfa9 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ This is a Python 3.8+ module aiming to interact with the Chamberlain MyQ API. Code is licensed under the MIT license. # [Homeassistant](https://home-assistant.io) + [Homeassistant](https://home-assistant.io) has a [core myQ component](https://www.home-assistant.io/integrations/myq/) leveraging this package. -In addition, there is also a [HACS myQ component](https://github.com/ehendrix23/hass_myq) available that can be added into HACS as a custom repository. +In addition, there is also a [HACS myQ component](https://github.com/ehendrix23/hass_myq) available that can be added into HACS as a custom repository. # Getting Started @@ -62,7 +63,7 @@ async def main() -> None: # Return only gateway devices: devices = myq.gateways # >>> {"serial_number123": } - + # Return *all* devices: devices = myq.devices # >>> {"serial_number123": , "serial_number456": } @@ -70,74 +71,90 @@ async def main() -> None: asyncio.get_event_loop().run_until_complete(main()) ``` -## API Properties -* `accounts`: dictionary with all accounts -* `covers`: dictionary with all covers -* `devices`: dictionary with all devices -* `gateways`: dictionary with all gateways -* `lamps`: dictionary with all lamps -* `last_state_update`: datetime (in UTC) last state update was retrieved -* `password`: password used for authentication. Can only be set, not retrieved -* `username`: username for authentication. - -## Account Properties +## API Properties -* `id`: ID for the account -* `name`: Name of the account +- `accounts`: dictionary with all accounts (MyQAccount) +- `covers`: dictionary with all covers (MyQGarageDoor) +- `devices`: dictionary with all devices (MyQDevice) +- `gateways`: dictionary with all gateways (MyQDevice) +- `lamps`: dictionary with all lamps (MyQLamp) +- `last_state_update`: datetime (in UTC) last state update was retrieved for all items +- `password`: password used for authentication. Can only be set, not retrieved +- `username`: username for authentication. + +## Account Properties (MyQAccount) + +- `api`: Associated API object +- `id`: ID for the account +- `name`: Name of the account +- `covers`: dictionary with all covers for account (MyQGarageDoor) +- `devices`: dictionary with all devices for account (MyQDevice) +- `gateways`: dictionary with all gateways for account (MyQDevice) +- `lamps`: dictionary with all lamps for account (MyQLamp) +- `account_json`: Dictionary containing all account information as retrieved from MyQ +- `last_state_update`: datetime (in UTC) last state update was retrieved for all devices within this account ## Device Properties -* `account`: Return account associated with device -* `close_allowed`: Return whether the device can be closed unattended. -* `device_family`: Return the family in which this device lives. -* `device_id`: Return the device ID (serial number). -* `device_platform`: Return the device platform. -* `device_type`: Return the device type. -* `firmware_version`: Return the family in which this device lives. -* `href`: URI for device -* `name`: Return the device name. -* `online`: Return whether the device is online. -* `open_allowed`: Return whether the device can be opened unattended. -* `parent_device_id`: Return the device ID (serial number) of this device's parent. -* `state`: Return the current state of the device. -* `state_update`: Returns datetime when device was last updated +- `account`: Return account associated with device (MyQAccount) +- `close_allowed`: Return whether the device can be closed unattended. +- `device_family`: Return the family in which this device lives. +- `device_id`: Return the device ID (serial number). +- `device_json`: Dictionary containing all device information as retrieved from MyQ +- `device_platform`: Return the device platform. +- `device_type`: Return the device type. +- `firmware_version`: Return the family in which this device lives. +- `href`: URI for device +- `name`: Return the device name. +- `online`: Return whether the device is online. +- `open_allowed`: Return whether the device can be opened unattended. +- `parent_device_id`: Return the device ID (serial number) of this device's parent. +- `state`: Return the current state of the device. +- `state_update`: Returns datetime when device was last updated ## API Methods These are coroutines and need to be `await`ed – see `example.py` for examples. -* `authenticate`: Authenticate (or re-authenticate) to MyQ. Call this to +- `authenticate`: Authenticate (or re-authenticate) to MyQ. Call this to re-authenticate immediately after changing username and/or password otherwise new username/password will only be used when token has to be refreshed. -* `update_device_info`: Retrieve info and status for accounts and devices +- `update_device_info`: Retrieve info and status for all accounts and devices +## Account Methods + +All of the routines on the `MyQAccount` class are coroutines and need to be +`await`ed – see `example.py` for examples. + +- `update`: get the latest device info (state, etc.) for all devices associated with this account. ## Device Methods All of the routines on the `MyQDevice` class are coroutines and need to be `await`ed – see `example.py` for examples. -* `update`: get the latest device info (state, etc.). Note that - this runs api.update_device_info and thus all accounts/devices will be updated +- `update`: get the latest device info (state, etc.). Note that + this runs MyQAccount.update and thus all devices within account will be updated ## Cover Methods All Device methods in addition to: -* `close`: close the cover -* `open`: open the cover + +- `close`: close the cover +- `open`: open the cover ## Lamp Methods All Device methods in addition to: -* `turnon`: turn lamp on -* `turnoff`: turn lamp off +- `turnon`: turn lamp on +- `turnoff`: turn lamp off # Acknowledgement -Huge thank you to [hjdhjd](https://github.com/hjdhjd) for figuring out the updated V6 API and -sharing his work with us. +Huge thank you to [hjdhjd](https://github.com/hjdhjd) for figuring out the updated V6 API and +sharing his work with us. # Disclaimer diff --git a/example.py b/example.py index 9e0d457..f9a1c61 100644 --- a/example.py +++ b/example.py @@ -5,17 +5,26 @@ from aiohttp import ClientSession from pymyq import login +from pymyq.account import MyQAccount from pymyq.errors import MyQError, RequestError -from pymyq.garagedoor import STATE_OPEN, STATE_CLOSED +from pymyq.garagedoor import STATE_CLOSED, STATE_OPEN _LOGGER = logging.getLogger() EMAIL = "" PASSWORD = "" -ISSUE_COMMANDS = False +ISSUE_COMMANDS = True +# LOGLEVEL = logging.DEBUG +LOGLEVEL = logging.WARNING def print_info(number: int, device): + """Print the device information + + Args: + number (int): [description] + device ([type]): [description] + """ print(f" Device {number + 1}: {device.name}") print(f" Device Online: {device.online}") print(f" Device ID: {device.device_id}") @@ -34,124 +43,157 @@ def print_info(number: int, device): print(" ---------") +async def print_garagedoors(account: MyQAccount): # noqa: C901 + """Print garage door information and open/close if requested + + Args: + account (MyQAccount): Account for which to retrieve garage doors + """ + print(f" GarageDoors: {len(account.covers)}") + print(" ---------------") + if len(account.covers) != 0: + for idx, device in enumerate(account.covers.values()): + print_info(number=idx, device=device) + + if ISSUE_COMMANDS: + try: + open_task = None + opened = closed = False + if device.open_allowed: + if device.state == STATE_OPEN: + print(f"Garage door {device.name} is already open") + else: + print(f"Opening garage door {device.name}") + try: + open_task = await device.open(wait_for_state=False) + except MyQError as err: + _LOGGER.error( + "Error when trying to open %s: %s", + device.name, + str(err), + ) + print(f"Garage door {device.name} is {device.state}") + + else: + print(f"Opening of garage door {device.name} is not allowed.") + + # We're not waiting for opening to be completed before we do call to close. + # The API will wait automatically for the open to complete before then + # processing the command to close. + + if device.close_allowed: + if device.state == STATE_CLOSED: + print(f"Garage door {device.name} is already closed") + else: + if open_task is None: + print(f"Closing garage door {device.name}") + else: + print( + f"Already requesting closing garage door {device.name}" + ) + + try: + closed = await device.close(wait_for_state=True) + except MyQError as err: + _LOGGER.error( + "Error when trying to close %s: %s", + device.name, + str(err), + ) + + if open_task is not None and not isinstance(open_task, bool): + opened = await open_task + + if opened and closed: + print( + f"Garage door {device.name} was opened and then closed again." + ) + elif opened: + print(f"Garage door {device.name} was opened but not closed.") + elif closed: + print(f"Garage door {device.name} was closed but not opened.") + else: + print(f"Garage door {device.name} was not opened nor closed.") + + except RequestError as err: + _LOGGER.error(err) + print(" ------------------------------") + + +async def print_lamps(account: MyQAccount): + """Print lamp information and turn on/off if requested + + Args: + account (MyQAccount): Account for which to retrieve lamps + """ + print(f" Lamps: {len(account.lamps)}") + print(" ---------") + if len(account.lamps) != 0: + for idx, device in enumerate(account.lamps.values()): + print_info(number=idx, device=device) + + if ISSUE_COMMANDS: + try: + print(f"Turning lamp {device.name} on") + await device.turnon(wait_for_state=True) + await asyncio.sleep(5) + print(f"Turning lamp {device.name} off") + await device.turnoff(wait_for_state=True) + except RequestError as err: + _LOGGER.error(err) + print(" ------------------------------") + + +async def print_gateways(account: MyQAccount): + """Print gateways for account + + Args: + account (MyQAccount): Account for which to retrieve gateways + """ + print(f" Gateways: {len(account.gateways)}") + print(" ------------") + if len(account.gateways) != 0: + for idx, device in enumerate(account.gateways.values()): + print_info(number=idx, device=device) + + print("------------------------------") + + +async def print_other(account: MyQAccount): + """Print unknown/other devices for account + + Args: + account (MyQAccount): Account for which to retrieve unknown devices + """ + print(f" Other: {len(account.other)}") + print(" ------------") + if len(account.other) != 0: + for idx, device in enumerate(account.other.values()): + print_info(number=idx, device=device) + + print("------------------------------") + + async def main() -> None: """Create the aiohttp session and run the example.""" - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(level=LOGLEVEL) async with ClientSession() as websession: try: # Create an API object: print(f"{EMAIL} {PASSWORD}") api = await login(EMAIL, PASSWORD, websession) - for account in api.accounts: - print(f"Account ID: {account}") - print(f"Account Name: {api.accounts[account]}") + for account in api.accounts.values(): + print(f"Account ID: {account.id}") + print(f"Account Name: {account.name}") # Get all devices listed with this account – note that you can use # api.covers to only examine covers or api.lamps for only lamps. - print(f" GarageDoors: {len(api.covers)}") - print(" ---------------") - if len(api.covers) != 0: - for idx, device_id in enumerate( - device_id - for device_id in api.covers - if api.devices[device_id].account == account - ): - device = api.devices[device_id] - print_info(number=idx, device=device) - - if ISSUE_COMMANDS: - try: - if device.open_allowed: - if device.state == STATE_OPEN: - print( - f"Garage door {device.name} is already open" - ) - else: - print(f"Opening garage door {device.name}") - try: - if await device.open(wait_for_state=True): - print( - f"Garage door {device.name} has been opened." - ) - else: - print( - f"Failed to open garage door {device.name}." - ) - except MyQError as err: - _LOGGER.error( - f"Error when trying to open {device.name}: {str(err)}" - ) - else: - print( - f"Opening of garage door {device.name} is not allowed." - ) - - if device.close_allowed: - if device.state == STATE_CLOSED: - print( - f"Garage door {device.name} is already closed" - ) - else: - print(f"Closing garage door {device.name}") - wait_task = None - try: - wait_task = await device.close( - wait_for_state=False - ) - except MyQError as err: - _LOGGER.error( - f"Error when trying to close {device.name}: {str(err)}" - ) - - print(f"Device {device.name} is {device.state}") - - if wait_task and await wait_task: - print( - f"Garage door {device.name} has been closed." - ) - else: - print( - f"Failed to close garage door {device.name}." - ) - - except RequestError as err: - _LOGGER.error(err) - print(" ------------------------------") - print(f" Lamps: {len(api.lamps)}") - print(" ---------") - if len(api.lamps) != 0: - for idx, device_id in enumerate( - device_id - for device_id in api.lamps - if api.devices[device_id].account == account - ): - device = api.devices[device_id] - print_info(number=idx, device=device) - - if ISSUE_COMMANDS: - try: - print(f"Turning lamp {device.name} on") - await device.turnon(wait_for_state=True) - await asyncio.sleep(5) - print(f"Turning lamp {device.name} off") - await device.turnoff(wait_for_state=True) - except RequestError as err: - _LOGGER.error(err) - print(" ------------------------------") - - print(f" Gateways: {len(api.gateways)}") - print(" ------------") - if len(api.gateways) != 0: - for idx, device_id in enumerate( - device_id - for device_id in api.gateways - if api.devices[device_id].account == account - ): - device = api.devices[device_id] - print_info(number=idx, device=device) - - print("------------------------------") + await print_garagedoors(account=account) + + await print_lamps(account=account) + + await print_gateways(account=account) except MyQError as err: _LOGGER.error("There was an error: %s", err) diff --git a/pylintrc b/pylintrc index 841fa42..bee6552 100644 --- a/pylintrc +++ b/pylintrc @@ -11,4 +11,3 @@ reports=no [FORMAT] expected-line-ending-format=LF - diff --git a/pymyq/__version__.py b/pymyq/__version__.py index 97413d1..ce40bae 100644 --- a/pymyq/__version__.py +++ b/pymyq/__version__.py @@ -1,2 +1,2 @@ """Define a version constant.""" -__version__ = "3.0.4" +__version__ = "3.1.0" diff --git a/pymyq/account.py b/pymyq/account.py new file mode 100644 index 0000000..176a806 --- /dev/null +++ b/pymyq/account.py @@ -0,0 +1,195 @@ +"""Define MyQ accounts.""" + +import asyncio +from datetime import datetime, timedelta +import logging +from typing import TYPE_CHECKING, Dict, Optional + +from .const import ( + DEVICE_FAMILY_GARAGEDOOR, + DEVICE_FAMILY_GATEWAY, + DEVICE_FAMLY_LAMP, + DEVICES_ENDPOINT, +) +from .device import MyQDevice +from .errors import MyQError +from .garagedoor import MyQGaragedoor +from .lamp import MyQLamp + +if TYPE_CHECKING: + from .api import API + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_STATE_UPDATE_INTERVAL = timedelta(seconds=5) + + +class MyQAccount: + """Define an account.""" + + def __init__(self, api: "API", account_json: dict, devices: dict = {}) -> None: + + self._api = api + self.account_json = account_json + self._devices = devices + self.last_state_update = None # type: Optional[datetime] + self._update = asyncio.Lock() # type: asyncio.Lock + + @property + def api(self) -> "API": + """Return API object""" + return self._api + + @property + def id(self) -> Optional[str]: + """Return account id """ + return self.account_json.get("id") + + @property + def name(self) -> Optional[str]: + """Return account name""" + return self.account_json.get("name") + + @property + def devices(self) -> Dict[str, MyQDevice]: + """Return all devices within account""" + return self._devices + + @property + def covers(self) -> Dict[str, MyQGaragedoor]: + """Return only those devices that are covers.""" + return { + device_id: device + for device_id, device in self.devices.items() + if isinstance(device, MyQGaragedoor) + } + + @property + def lamps(self) -> Dict[str, MyQLamp]: + """Return only those devices that are lamps.""" + return { + device_id: device + for device_id, device in self.devices.items() + if isinstance(device, MyQLamp) + } + + @property + def gateways(self) -> Dict[str, MyQDevice]: + """Return only those devices that are gateways.""" + return { + device_id: device + for device_id, device in self.devices.items() + if device.device_json["device_family"] == DEVICE_FAMILY_GATEWAY + } + + @property + def other(self) -> Dict[str, MyQDevice]: + """Return only those devices that are covers.""" + return { + device_id: device + for device_id, device in self.devices.items() + if type(device) is MyQDevice + and device.device_json["device_family"] != DEVICE_FAMILY_GATEWAY + } + + async def _get_devices(self) -> None: + + _LOGGER.debug("Retrieving devices for account %s", self.name or self.id) + + _, devices_resp = await self._api.request( + method="get", + returns="json", + url=DEVICES_ENDPOINT.format(account_id=self.id), + ) + + if devices_resp is not None and not isinstance(devices_resp, dict): + raise MyQError( + f"Received object devices_resp of type {type(devices_resp)} but expecting type dict" + ) + + state_update_timestmp = datetime.utcnow() + if devices_resp is not None and devices_resp.get("items") is not None: + for device in devices_resp.get("items"): + serial_number = device.get("serial_number") + if serial_number is None: + _LOGGER.debug( + "No serial number for device with name %s.", device.get("name") + ) + continue + + if serial_number in self._devices: + _LOGGER.debug( + "Updating information for device with serial number %s", + serial_number, + ) + myqdevice = self._devices[serial_number] + myqdevice.device_json = device + myqdevice.last_state_update = state_update_timestmp + + else: + if device.get("device_family") == DEVICE_FAMILY_GARAGEDOOR: + _LOGGER.debug( + "Adding new garage door with serial number %s", + serial_number, + ) + new_device = MyQGaragedoor( + account=self, + device_json=device, + state_update=state_update_timestmp, + ) + elif device.get("device_family") == DEVICE_FAMLY_LAMP: + _LOGGER.debug( + "Adding new lamp with serial number %s", serial_number + ) + new_device = MyQLamp( + account=self, + device_json=device, + state_update=state_update_timestmp, + ) + else: + if device.get("device_family") == DEVICE_FAMILY_GATEWAY: + _LOGGER.debug( + "Adding new gateway with serial number %s", + serial_number, + ) + else: + _LOGGER.debug( + "Adding unknown device family %s with serial number %s", + device.get("device_family"), + serial_number, + ) + + new_device = MyQDevice( + account=self, + device_json=device, + state_update=state_update_timestmp, + ) + + if new_device: + self._devices[serial_number] = new_device + else: + _LOGGER.debug("No devices found for account %s", self.name or self.id) + + async def update(self) -> None: + """Get up-to-date device info.""" + # The MyQ API can time out if state updates are too frequent; therefore, + # if back-to-back requests occur within a threshold, respond to only the first + # Ensure only 1 update task can run at a time. + async with self._update: + call_dt = datetime.utcnow() + if not self.last_state_update: + self.last_state_update = call_dt - DEFAULT_STATE_UPDATE_INTERVAL + next_available_call_dt = ( + self.last_state_update + DEFAULT_STATE_UPDATE_INTERVAL + ) + + # Ensure we're within our minimum update interval + if call_dt < next_available_call_dt: + _LOGGER.debug( + "Ignoring device update request for account %s as it is within throttle window", + self.name or self.id, + ) + return + + await self._get_devices() + self.last_state_update = datetime.utcnow() diff --git a/pymyq/api.py b/pymyq/api.py index 0a0be9c..bb7991b 100644 --- a/pymyq/api.py +++ b/pymyq/api.py @@ -1,35 +1,31 @@ """Define the MyQ API.""" import asyncio -import logging -from bs4 import BeautifulSoup from datetime import datetime, timedelta -from typing import Dict, Optional, Union, Tuple -from urllib.parse import urlsplit, parse_qs -from random import choices -import string +import logging +from typing import Dict, List, Optional, Tuple, Union +from urllib.parse import parse_qs, urlsplit -from aiohttp import ClientSession, ClientResponse +from aiohttp import ClientResponse, ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError +from bs4 import BeautifulSoup from pkce import generate_code_verifier, get_code_challenge +from yarl import URL +from .account import MyQAccount from .const import ( ACCOUNTS_ENDPOINT, - DEVICES_ENDPOINT, - DEVICE_FAMILY_GARAGEDOOR, - DEVICE_FAMILY_GATEWAY, - DEVICE_FAMLY_LAMP, - OAUTH_CLIENT_ID, - OAUTH_CLIENT_SECRET, OAUTH_AUTHORIZE_URI, OAUTH_BASE_URI, - OAUTH_TOKEN_URI, + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, + OAUTH_TOKEN_URI, ) from .device import MyQDevice -from .errors import AuthenticationError, InvalidCredentialsError, RequestError +from .errors import AuthenticationError, InvalidCredentialsError, MyQError, RequestError from .garagedoor import MyQGaragedoor from .lamp import MyQLamp -from .request import MyQRequest, REQUEST_METHODS +from .request import REQUEST_METHODS, MyQRequest _LOGGER = logging.getLogger(__name__) @@ -45,13 +41,10 @@ def __init__( username: str, password: str, websession: ClientSession = None, - useragent: Optional[str] = None, ) -> None: """Initialize.""" self.__credentials = {"username": username, "password": password} - self._myqrequests = MyQRequest( - websession or ClientSession(), useragent=useragent - ) + self._myqrequests = MyQRequest(websession or ClientSession()) self._authentication_task = None # type:Optional[asyncio.Task] self._codeverifier = None # type: Optional[str] self._invalid_credentials = False # type: bool @@ -63,36 +56,40 @@ def __init__( None, ) # type: Tuple[Optional[str], Optional[datetime], Optional[datetime]] - self.accounts = {} # type: Dict[str, str] - self.devices = {} # type: Dict[str, MyQDevice] + self.accounts = {} # type: Dict[str, MyQAccount] self.last_state_update = None # type: Optional[datetime] + @property + def devices(self) -> Dict[str, Union[MyQDevice, MyQGaragedoor, MyQLamp]]: + """Return all devices.""" + devices = {} + for account in self.accounts.values(): + devices.update(account.devices) + return devices + @property def covers(self) -> Dict[str, MyQGaragedoor]: """Return only those devices that are covers.""" - return { - device_id: device - for device_id, device in self.devices.items() - if device.device_json["device_family"] == DEVICE_FAMILY_GARAGEDOOR - } + covers = {} + for account in self.accounts.values(): + covers.update(account.covers) + return covers @property - def lamps(self) -> Dict[str, MyQDevice]: + def lamps(self) -> Dict[str, MyQLamp]: """Return only those devices that are covers.""" - return { - device_id: device - for device_id, device in self.devices.items() - if device.device_json["device_family"] == DEVICE_FAMLY_LAMP - } + lamps = {} + for account in self.accounts.values(): + lamps.update(account.lamps) + return lamps @property def gateways(self) -> Dict[str, MyQDevice]: """Return only those devices that are covers.""" - return { - device_id: device - for device_id, device in self.devices.items() - if device.device_json["device_family"] == DEVICE_FAMILY_GATEWAY - } + gateways = {} + for account in self.accounts.values(): + gateways.update(account.gateways) + return gateways @property def _code_verifier(self) -> str: @@ -102,27 +99,91 @@ def _code_verifier(self) -> str: @property def username(self) -> str: + """Username used to authenticate with on MyQ + + Returns: + str: username + """ return self.__credentials["username"] @username.setter - def username(self, username: str) -> None: + def username(self, username: str): + """Set username to use for authentication + + Args: + username (str): Username to authenticate with + """ self._invalid_credentials = False self.__credentials["username"] = username @property - def password(self) -> None: + def password(self) -> Optional[str]: + """Will return None, password retrieval is not possible + + Returns: + None + """ return None @password.setter - def password(self, password: str) -> None: + def password(self, password: str): + """Set password used to authenticate with + + Args: + password (str): password + """ self._invalid_credentials = False self.__credentials["password"] = password + async def _authentication_task_completed(self) -> None: + # If we had something for an authentication task and + # it is done then get the result and clear it out. + if self._authentication_task is not None: + authentication_task = await self.authenticate(wait=False) + if authentication_task.done(): + _LOGGER.debug( + "Scheduled token refresh completed, ensuring no exception." + ) + self._authentication_task = None + try: + # Get the result so any exception is raised. + authentication_task.result() + except asyncio.CancelledError: + pass + except (RequestError, AuthenticationError) as auth_err: + message = f"Scheduled token refresh failed: {str(auth_err)}" + _LOGGER.debug(message) + + async def _refresh_token(self) -> None: + # Check if token has to be refreshed. + if ( + self._security_token[1] is None + or self._security_token[1] <= datetime.utcnow() + ): + # Token has to be refreshed, get authentication task if running otherwise + # start a new one. + if self._security_token[0] is None: + # Wait for authentication task to be completed. + _LOGGER.debug( + "Waiting for updated token, last refresh was %s", + self._security_token[2], + ) + try: + await self.authenticate(wait=True) + except AuthenticationError as auth_err: + message = f"Error trying to re-authenticate to myQ service: {str(auth_err)}" + _LOGGER.debug(message) + raise AuthenticationError(message) from auth_err + else: + # We still have a token, we can continue this request with + # that token and schedule task to refresh token unless one is already running + await self.authenticate(wait=False) + async def request( self, method: str, returns: str, - url: str, + url: Union[URL, str], websession: ClientSession = None, headers: dict = None, params: dict = None, @@ -130,7 +191,7 @@ async def request( json: dict = None, allow_redirects: bool = True, login_request: bool = False, - ) -> Tuple[ClientResponse, Union[dict, str, None]]: + ) -> Tuple[Optional[ClientResponse], Optional[Union[dict, str]]]: """Make a request.""" # Determine the method to call based on what is to be returned. @@ -158,12 +219,12 @@ async def request( f"Error requesting data from {url}: {err.status} - {err.message}" ) _LOGGER.debug(message) - raise RequestError(message) + raise RequestError(message) from err except ClientError as err: message = f"Error requesting data from {url}: {str(err)}" _LOGGER.debug(message) - raise RequestError(message) + raise RequestError(message) from err # The MyQ API can time out if multiple concurrent requests are made, so # ensure that only one gets through at a time. @@ -171,54 +232,20 @@ async def request( # we're sending the request anyways as we know there is no active request now. async with self._lock: - # If we had something for an authentication task and it is done then get the result and clear it out. - if self._authentication_task is not None: - authentication_task = await self.authenticate(wait=False) - if authentication_task.done(): - _LOGGER.debug( - "Scheduled token refresh completed, ensuring no exception." - ) - self._authentication_task = None - try: - # Get the result so any exception is raised. - authentication_task.result() - except asyncio.CancelledError: - pass - except (RequestError, AuthenticationError) as auth_err: - message = f"Scheduled token refresh failed: {str(auth_err)}" - _LOGGER.debug(message) + # Check if an authentication task was running and if so, if it has completed. + await self._authentication_task_completed() - # Check if token has to be refreshed. - if ( - self._security_token[1] is None - or self._security_token[1] <= datetime.utcnow() - ): - # Token has to be refreshed, get authentication task if running otherwise start a new one. - if self._security_token[0] is None: - # Wait for authentication task to be completed. - _LOGGER.debug( - f"Waiting for updated token, last refresh was {self._security_token[2]}" - ) - try: - await self.authenticate(wait=True) - except AuthenticationError as auth_err: - message = f"Error trying to re-authenticate to myQ service: {str(auth_err)}" - _LOGGER.debug(message) - raise AuthenticationError(message) - else: - # We still have a token, we can continue this request with that token and schedule - # task to refresh token unless one is already running - await self.authenticate(wait=False) + # Check if token has to be refreshed and start task to refresh, wait if required now. + await self._refresh_token() if not headers: headers = {} headers["Authorization"] = self._security_token[0] - _LOGGER.debug(f"Sending {method} request to {url}.") - # Do the request - try: - # First try + _LOGGER.debug("Sending %s request to %s.", method, url) + # Do the request. We will try 2 times based on response. + for attempt in range(2): try: return await call_method( method=method, @@ -231,51 +258,30 @@ async def request( allow_redirects=allow_redirects, ) except ClientResponseError as err: - # Handle only if status is 401, we then re-authenticate and retry the request - if err.status == 401: - self._security_token = (None, None, self._security_token[2]) - _LOGGER.debug("Status 401 received, re-authenticating.") - try: - await self.authenticate(wait=True) - except AuthenticationError as auth_err: - # Raise authentication error, we need a new token to continue and not getting it right - # now. - message = f"Error trying to re-authenticate to myQ service: {str(auth_err)}" - _LOGGER.debug(message) - raise AuthenticationError(message) - else: - # Some other error, re-raise. - raise err + message = f"Error requesting data from {url}: {err.status} - {err.message}" - # Re-authentication worked, resend request that had failed. - return await call_method( - method=method, - url=url, - websession=websession, - headers=headers, - params=params, - data=data, - json=json, - allow_redirects=allow_redirects, - ) - - except ClientResponseError as err: - message = ( - f"Error requesting data from {url}: {err.status} - {err.message}" - ) - _LOGGER.debug(message) - if getattr(err, "status") and err.status == 401: - # Received unauthorized, reset token and start task to get a new one. - self._security_token = (None, None, self._security_token[2]) - await self.authenticate(wait=False) - raise AuthenticationError(message) + if getattr(err, "status") and err.status == 401: + if attempt == 0: + self._security_token = (None, None, self._security_token[2]) + _LOGGER.debug("Status 401 received, re-authenticating.") - raise RequestError(message) + await self._refresh_token() + else: + # Received unauthorized again, + # reset token and start task to get a new one. + _LOGGER.debug(message) + self._security_token = (None, None, self._security_token[2]) + await self.authenticate(wait=False) + raise AuthenticationError(message) from err + else: + _LOGGER.debug(message) + raise RequestError(message) from err - except ClientError as err: - message = f"Error requesting data from {url}: {str(err)}" - _LOGGER.debug(message) - raise RequestError(message) + except ClientError as err: + message = f"Error requesting data from {url}: {str(err)}" + _LOGGER.debug(message) + raise RequestError(message) from err + return None, None async def _oauth_authenticate(self) -> Tuple[str, int]: @@ -305,7 +311,8 @@ async def _oauth_authenticate(self) -> Tuple[str, int]: _LOGGER.debug("Scanning login page for fields to return") soup = BeautifulSoup(html, "html.parser") - # Go through all potential forms in the page returned. This is in case multiple forms are returned. + # Go through all potential forms in the page returned. + # This is in case multiple forms are returned. forms = soup.find_all("form") data = {} for form in forms: @@ -420,18 +427,26 @@ async def _oauth_authenticate(self) -> Tuple[str, int]: login_request=True, ) + if not isinstance(data, dict): + raise MyQError( + f"Received object data of type {type(data)} but expecting type dict" + ) + token = f"{data.get('token_type')} {data.get('access_token')}" try: expires = int(data.get("expires_in", DEFAULT_TOKEN_REFRESH)) except ValueError: _LOGGER.debug( - f"Expires {data.get('expires_in')} received is not an integer, using default." + "Expires %s received is not an integer, using default.", + data.get("expires_in"), ) expires = DEFAULT_TOKEN_REFRESH * 2 if expires < DEFAULT_TOKEN_REFRESH * 2: _LOGGER.debug( - f"Expires {expires} is less then default {DEFAULT_TOKEN_REFRESH}, setting to default instead." + "Expires %s is less then default %s, setting to default instead.", + expires, + DEFAULT_TOKEN_REFRESH, ) expires = DEFAULT_TOKEN_REFRESH * 2 @@ -452,7 +467,7 @@ async def authenticate(self, wait: bool = True) -> Optional[asyncio.Task]: if self._authentication_task is None: # No authentication task is currently running, start one _LOGGER.debug( - f"Scheduling token refresh, last refresh was {self._security_token[2]}" + "Scheduling token refresh, last refresh was %s", self._security_token[2] ) self._authentication_task = asyncio.create_task( self._authenticate(), name="MyQ_Authenticate" @@ -462,10 +477,10 @@ async def authenticate(self, wait: bool = True) -> Optional[asyncio.Task]: try: await self._authentication_task except (RequestError, AuthenticationError) as auth_err: - # Raise authentication error, we need a new token to continue and not getting it right - # now. + # Raise authentication error, we need a new token to continue + # and not getting it right now. self._authentication_task = None - raise AuthenticationError(str(auth_err)) + raise AuthenticationError(str(auth_err)) from auth_err self._authentication_task = None return self._authentication_task @@ -481,14 +496,14 @@ async def _authenticate(self) -> None: "Authentication response did not contain a security token yet one is expected." ) - _LOGGER.debug(f"Received token that will expire in {expires} seconds") + _LOGGER.debug("Received token that will expire in %s seconds", expires) self._security_token = ( token, datetime.utcnow() + timedelta(seconds=int(expires / 2)), datetime.now(), ) - async def _get_accounts(self) -> Optional[dict]: + async def _get_accounts(self) -> List: _LOGGER.debug("Retrieving account information") @@ -497,104 +512,15 @@ async def _get_accounts(self) -> Optional[dict]: method="get", returns="json", url=ACCOUNTS_ENDPOINT ) - if accounts_resp is not None and accounts_resp.get("accounts") is not None: - accounts = {} - for account in accounts_resp["accounts"]: - account_id = account.get("id") - if account_id is not None: - _LOGGER.debug( - f"Got account {account_id} with name {account.get('name')}" - ) - accounts.update({account_id: account.get("name")}) - else: - _LOGGER.debug(f"No accounts found") - accounts = None - - return accounts - - async def _get_devices_for_account(self, account) -> None: - - _LOGGER.debug(f"Retrieving devices for account {self.accounts[account]}") - - _, devices_resp = await self.request( - method="get", - returns="json", - url=DEVICES_ENDPOINT.format(account_id=account), - ) - - state_update_timestmp = datetime.utcnow() - if devices_resp is not None and devices_resp.get("items") is not None: - for device in devices_resp.get("items"): - serial_number = device.get("serial_number") - if serial_number is None: - _LOGGER.debug( - f"No serial number for device with name {device.get('name')}." - ) - continue - - if serial_number in self.devices: - _LOGGER.debug( - f"Updating information for device with serial number {serial_number}" - ) - myqdevice = self.devices[serial_number] - - # When performing commands we might update the state temporary, need to ensure - # that the state is not set back to something else if MyQ does not yet have updated - # state - last_update = myqdevice.device_json["state"].get("last_update") - myqdevice.device_json = device - - if ( - myqdevice.device_json["state"].get("last_update") is not None - and myqdevice.device_json["state"].get("last_update") - != last_update - ): - # MyQ has updated device state, reset ours ensuring we have the one from MyQ. - myqdevice.state = None - _LOGGER.debug( - f"State for device {myqdevice.name} was updated to {myqdevice.state}" - ) + if accounts_resp is not None and not isinstance(accounts_resp, dict): + raise MyQError( + f"Received object accounts_resp of type {type(accounts_resp)}" + f"but expecting type dict" + ) - myqdevice.state_update = state_update_timestmp - else: - if device.get("device_family") == DEVICE_FAMILY_GARAGEDOOR: - _LOGGER.debug( - f"Adding new garage door with serial number {serial_number}" - ) - self.devices[serial_number] = MyQGaragedoor( - api=self, - account=account, - device_json=device, - state_update=state_update_timestmp, - ) - elif device.get("device_family") == DEVICE_FAMLY_LAMP: - _LOGGER.debug( - f"Adding new lamp with serial number {serial_number}" - ) - self.devices[serial_number] = MyQLamp( - api=self, - account=account, - device_json=device, - state_update=state_update_timestmp, - ) - elif device.get("device_family") == DEVICE_FAMILY_GATEWAY: - _LOGGER.debug( - f"Adding new gateway with serial number {serial_number}" - ) - self.devices[serial_number] = MyQDevice( - api=self, - account=account, - device_json=device, - state_update=state_update_timestmp, - ) - else: - _LOGGER.warning( - f"Unknown device family {device.get('device_family')}" - ) - else: - _LOGGER.debug(f"No devices found for account {self.accounts[account]}") + return accounts_resp.get("accounts", []) if accounts_resp is not None else [] - async def update_device_info(self, for_account: str = None) -> None: + async def update_device_info(self) -> None: """Get up-to-date device info.""" # The MyQ API can time out if state updates are too frequent; therefore, # if back-to-back requests occur within a threshold, respond to only the first @@ -607,98 +533,64 @@ async def update_device_info(self, for_account: str = None) -> None: self.last_state_update + DEFAULT_STATE_UPDATE_INTERVAL ) - # Ensure we're within our minimum update interval AND update request is not for a specific device - if call_dt < next_available_call_dt and for_account is None: - _LOGGER.debug( - "Ignoring device update request as it is within throttle window" - ) + # Ensure we're within our minimum update interval AND + # update request is not for a specific device + if call_dt < next_available_call_dt: + _LOGGER.debug("Ignoring update request as it is within throttle window") return - _LOGGER.debug("Updating device information") + _LOGGER.debug("Updating account information") # If update request is for a specific account then do not retrieve account information. - if for_account is None: - self.accounts = await self._get_accounts() - - if self.accounts is None: - _LOGGER.debug(f"No accounts found") - self.devices = {} - accounts = {} - else: - accounts = self.accounts - else: - # Request is for specific account, thus restrict retrieval to the 1 account. - if self.accounts.get(for_account) is None: - # Checking to ensure we know the account, but this should never happen. - _LOGGER.debug( - f"Unable to perform update request for account {for_account} as it is not known." - ) - accounts = {} - else: - accounts = {for_account: self.accounts.get(for_account)} + accounts = await self._get_accounts() - for account in accounts: - await self._get_devices_for_account(account=account) - - # Update our last update timestamp UNLESS this is for a specific account - if for_account is None: - self.last_state_update = datetime.utcnow() + if len(accounts) == 0: + _LOGGER.debug("No accounts found") + self.accounts = {} + return + for account in accounts: + print(account) + account_id = account.get("id") + if account_id is not None: + if self.accounts.get(account_id): + # Account already existed, update information. + _LOGGER.debug( + "Updating account %s with name %s", + account_id, + account.get("name"), + ) -async def login(username: str, password: str, websession: ClientSession = None) -> API: - """Log in to the API.""" + self.accounts.get(account_id).account_json = account + else: + # This is a new account. + _LOGGER.debug( + "New account %s with name %s", + account_id, + account.get("name"), + ) + self.accounts.update( + {account_id: MyQAccount(api=self, account_json=account)} + ) - # Retrieve user agent from GitHub if not provided for login. - _LOGGER.debug("No user agent provided, trying to retrieve from GitHub.") - url = f"https://raw.githubusercontent.com/arraylabs/pymyq/master/.USER_AGENT" + # Perform a device update for this account. + await self.accounts.get(account_id).update() - try: - async with ClientSession() as session: - async with session.get(url) as resp: - useragent = await resp.text() - resp.raise_for_status() - _LOGGER.debug(f"Retrieved user agent {useragent} from GitHub.") - - except ClientError as exc: - # Default user agent to random string with length of 5 if failure to retrieve it from GitHub. - useragent = "#RANDOM:5" - _LOGGER.warning( - f"Failed retrieving user agent from GitHub, will use randomized user agent " - f"instead: {str(exc)}" - ) + self.last_state_update = datetime.utcnow() - # Check if value for useragent is to create a random user agent. - useragent_list = useragent.split(":") - if useragent_list[0] == "#RANDOM": - # Create a random string, check if length is provided for the random string, if not then default is 5. - try: - randomlength = int(useragent_list[1]) if len(useragent_list) == 2 else 5 - except ValueError: - _LOGGER.debug( - f"Random length value {useragent_list[1]} in user agent {useragent} is not an integer. " - f"Setting to 5 instead." - ) - randomlength = 5 - # Create the random user agent. - useragent = "".join( - choices(string.ascii_letters + string.digits, k=randomlength) - ) - _LOGGER.debug(f"User agent set to randomized value: {useragent}.") +async def login(username: str, password: str, websession: ClientSession = None) -> API: + """Log in to the API.""" # Set the user agent in the headers. - api = API( - username=username, password=password, websession=websession, useragent=useragent - ) + api = API(username=username, password=password, websession=websession) _LOGGER.debug("Performing initial authentication into MyQ") try: await api.authenticate(wait=True) except InvalidCredentialsError as err: - _LOGGER.error( - f"Username and/or password are invalid. Update username/password." - ) + _LOGGER.error("Username and/or password are invalid. Update username/password.") raise err except AuthenticationError as err: - _LOGGER.error(f"Authentication failed: {str(err)}") + _LOGGER.error("Authentication failed: %s", str(err)) raise err # Retrieve and store initial set of devices: diff --git a/pymyq/const.py b/pymyq/const.py index fe7af12..ee6aa48 100644 --- a/pymyq/const.py +++ b/pymyq/const.py @@ -8,7 +8,9 @@ OAUTH_TOKEN_URI = f"{OAUTH_BASE_URI}/connect/token" ACCOUNTS_ENDPOINT = "https://accounts.myq-cloud.com/api/v6.0/accounts" -DEVICES_ENDPOINT = "https://devices.myq-cloud.com/api/v5.2/Accounts/{account_id}/Devices" +DEVICES_ENDPOINT = ( + "https://devices.myq-cloud.com/api/v5.2/Accounts/{account_id}/Devices" +) WAIT_TIMEOUT = 60 diff --git a/pymyq/device.py b/pymyq/device.py index a891d3c..929e27f 100644 --- a/pymyq/device.py +++ b/pymyq/device.py @@ -1,14 +1,14 @@ """Define MyQ devices.""" import asyncio -import logging from datetime import datetime -from typing import TYPE_CHECKING, Optional, List +import logging +from typing import TYPE_CHECKING, List, Optional, Union from .const import DEVICE_TYPE, WAIT_TIMEOUT -from .errors import RequestError, MyQError +from .errors import MyQError, RequestError if TYPE_CHECKING: - from .api import API + from .account import MyQAccount _LOGGER = logging.getLogger(__name__) @@ -17,41 +17,46 @@ class MyQDevice: """Define a generic device.""" def __init__( - self, api: "API", device_json: dict, account: str, state_update: datetime + self, + device_json: dict, + account: "MyQAccount", + state_update: datetime, ) -> None: """Initialize. :type account: str """ - self._api = api # type: "API" - self._account = account # type: str - self.device_json = device_json # type: dict - self.state_update = state_update # type: datetime + self._account = account + self.device_json = device_json + self.last_state_update = state_update + self.state_update = None self._device_state = None # Type: Optional[str] + self._send_command_lock = asyncio.Lock() # type: asyncio.Lock + self._wait_for_state_task = None @property - def account(self) -> str: + def account(self) -> "MyQAccount": """Return account associated with device""" return self._account @property - def device_family(self) -> str: + def device_family(self) -> Optional[str]: """Return the family in which this device lives.""" - return self.device_json["device_family"] + return self.device_json.get("device_family") @property - def device_id(self) -> str: + def device_id(self) -> Optional[str]: """Return the device ID (serial number).""" - return self.device_json["serial_number"] + return self.device_json.get("serial_number") @property - def device_platform(self) -> str: + def device_platform(self) -> Optional[str]: """Return the device platform.""" - return self.device_json["device_platform"] + return self.device_json.get("device_platform") @property - def device_type(self) -> str: + def device_type(self) -> Optional[str]: """Return the device type.""" - return self.device_json[DEVICE_TYPE] + return self.device_json.get(DEVICE_TYPE) @property def firmware_version(self) -> Optional[str]: @@ -59,14 +64,18 @@ def firmware_version(self) -> Optional[str]: return self.device_json["state"].get("firmware_version") @property - def name(self) -> bool: + def name(self) -> Optional[str]: """Return the device name.""" - return self.device_json["name"] + return self.device_json.get("name") @property def online(self) -> bool: """Return whether the device is online.""" - return self.device_json["state"].get("online") is True + state = self.device_json.get("state") + if state is None: + return False + + return state.get("online") is True @property def parent_device_id(self) -> Optional[str]: @@ -80,10 +89,15 @@ def href(self) -> Optional[str]: @property def state(self) -> Optional[str]: + """Return current state + + Returns: + Optional[str]: State for the device + """ return self._device_state or self.device_state @state.setter - def state(self, value: str) -> None: + def state(self, value: str): """Set the current state of the device.""" self._device_state = value @@ -101,24 +115,119 @@ def open_allowed(self) -> bool: """Return whether the device can be opened unattended.""" return False - async def _send_state_command(self, url: str, command: str) -> None: - """Instruct the API to change the state of the device.""" + async def close(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: + raise NotImplementedError + + async def open(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: + raise NotImplementedError + + async def turnoff(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: + raise NotImplementedError + + async def turnon(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: + raise NotImplementedError + + async def update_device(self, device_json: dict, state_update_timestmp: datetime): + """Update state of device depending on last update in MyQ is after last state set + + by us + + Args: + device_json (dict): device json + state_update_timestmp (datetime): [description] + """ + # When performing commands we might update the state temporary, need to ensure + # that the state is not set back to something else if MyQ does not yet have updated + # state + last_update = self.device_json["state"].get("last_update") + self.device_json = device_json + + if ( + self.device_json["state"].get("last_update") is not None + and self.device_json["state"].get("last_update") != last_update + ): + # MyQ has updated device state, reset ours ensuring we have the one from MyQ. + self._device_state = None + _LOGGER.debug( + "State for device %s was updated to %s", self.name, self.state + ) + + self.state_update = state_update_timestmp + + async def _send_state_command( + self, + to_state: str, + intermediate_state: str, + url: str, + command: str, + wait_for_state: bool = False, + ) -> Union[asyncio.Task, bool]: + """Send command to device to change state.""" + # If the user tries to open or close, say, a gateway, throw an exception: if not self.state: raise RequestError( f"Cannot change state of device type: {self.device_type}" ) - _LOGGER.debug(f"Sending command {command} for {self.name}") - await self._api.request( - method="put", - returns="response", - url=url, - ) + # If currently there is a wait_for_state task running, + # then wait until it completes first. + if self._wait_for_state_task is not None: + # Return wait task if we're currently waiting for same task to be completed + if self.state == intermediate_state and not wait_for_state: + _LOGGER.debug( + "Command %s for %s was already send, returning wait task for it instead", + command, + self.name, + ) + return self._wait_for_state_task + + _LOGGER.debug( + "Another command for %s is still in progress, waiting for it to complete first before issuing command %s", + self.name, + command, + ) + await self._wait_for_state_task + + # We return true if state is already closed. + if self.state == to_state: + _LOGGER.debug( + "Device %s is in state %s, nothing to do.", self.name, to_state + ) + return True + + async with self._send_command_lock: + _LOGGER.debug("Sending command %s for %s", command, self.name) + await self.account.api.request( + method="put", + returns="response", + url=url, + ) + + self.state = intermediate_state + + self._wait_for_state_task = asyncio.create_task( + self.wait_for_state( + current_state=[self.state], + new_state=[to_state], + last_state_update=self.device_json["state"].get("last_update"), + timeout=60, + ), + name="MyQ_WaitFor" + to_state, + ) + + # Make sure our wait task starts + await asyncio.sleep(0) + + if not wait_for_state: + return self._wait_for_state_task + + _LOGGER.debug("Waiting till device is %s", to_state) + return await self._wait_for_state_task async def update(self) -> None: """Get the latest info for this device.""" - await self._api.update_device_info() + await self.account.update() async def wait_for_state( self, @@ -127,8 +236,20 @@ async def wait_for_state( last_state_update: datetime, timeout: int = WAIT_TIMEOUT, ) -> bool: + """Wait until device has reached new state + + Args: + current_state (List): List of possible current states + new_state (List): List of new states to wait for + last_state_update (datetime): Last time state was updated + timeout (int, optional): Timeout in seconds to wait for new state. + Defaults to WAIT_TIMEOUT. + + Returns: + bool: True if new state reached, False if new state was not reached + """ # First wait until door state is actually updated. - _LOGGER.debug(f"Waiting until device state has been updated for {self.name}") + _LOGGER.debug("Waiting until device state has been updated for %s", self.name) wait_timeout = timeout while ( last_state_update @@ -136,28 +257,30 @@ async def wait_for_state( and wait_timeout > 0 ): wait_timeout = wait_timeout - 5 - await asyncio.sleep(5) try: - await self._api.update_device_info(for_account=self.account) + await self._account.update() except MyQError: # Ignoring pass + await asyncio.sleep(5) # Wait until the state is to what we want it to be - _LOGGER.debug(f"Waiting until device state for {self.name} is {new_state}") + _LOGGER.debug("Waiting until device state for %s is %s", self.name, new_state) wait_timeout = timeout - while self.state in current_state and wait_timeout > 0: + while self.device_state not in new_state and wait_timeout > 0: wait_timeout = wait_timeout - 5 - await asyncio.sleep(5) try: - await self._api.update_device_info(for_account=self.account) + await self._account.update() except MyQError: # Ignoring pass + await asyncio.sleep(5) - # Reset self.state ensuring it reflects actual device state. Only do this if state is still what it would - # have been, this to ensure if something else had updated it to something else we don't override. - if self._device_state == current_state: + # Reset self.state ensuring it reflects actual device state. + # Only do this if state is still what it would have been, + # this to ensure if something else had updated it to something else we don't override. + if self._device_state in current_state or self._device_state in new_state: self._device_state = None + self._wait_for_state_task = None return self.state in new_state diff --git a/pymyq/garagedoor.py b/pymyq/garagedoor.py index efbf5f9..126abfe 100644 --- a/pymyq/garagedoor.py +++ b/pymyq/garagedoor.py @@ -1,19 +1,20 @@ """Define MyQ devices.""" import asyncio -import logging from datetime import datetime +import logging from typing import TYPE_CHECKING, Optional, Union from .device import MyQDevice -from .errors import RequestError if TYPE_CHECKING: - from .api import API + from .account import MyQAccount _LOGGER = logging.getLogger(__name__) -COMMAND_URI = \ - "https://account-devices-gdo.myq-cloud.com/api/v5.2/Accounts/{account_id}/door_openers/{device_serial}/{command}" +COMMAND_URI = ( + "https://account-devices-gdo.myq-cloud.com/api/v5.2/Accounts/{account_id}" + "/door_openers/{device_serial}/{command}" +) COMMAND_CLOSE = "close" COMMAND_OPEN = "open" STATE_CLOSED = "closed" @@ -21,18 +22,24 @@ STATE_OPEN = "open" STATE_OPENING = "opening" STATE_STOPPED = "stopped" -STATE_TRANSITION = "transition" STATE_UNKNOWN = "unknown" class MyQGaragedoor(MyQDevice): """Define a generic device.""" - def __init__(self, api: "API", device_json: dict, account: str, state_update: datetime) -> None: + def __init__( + self, + device_json: dict, + account: "MyQAccount", + state_update: datetime, + ) -> None: """Initialize. :type account: str """ - super().__init__(api=api, account=account, device_json=device_json, state_update=state_update) + super().__init__( + account=account, device_json=device_json, state_update=state_update + ) @property def close_allowed(self) -> bool: @@ -55,63 +62,30 @@ def device_state(self) -> Optional[str]: async def close(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: """Close the device.""" - if self.state != self.device_state: - raise RequestError(f"Device is currently {self.state}, wait until complete.") - if self.state not in (STATE_CLOSED, STATE_CLOSING): - # If our state is different from device state then it means an action is already being performed. - if self.state != self.device_state: - raise RequestError(f"Device is currently {self.state}, wait until complete.") - - # Device is currently not closed or closing, send command to close - await self._send_state_command( - url=COMMAND_URI.format( - account_id=self.account, - device_serial=self.device_id, - command=COMMAND_CLOSE, - ), + return await self._send_state_command( + to_state=STATE_CLOSED, + intermediate_state=STATE_CLOSING, + url=COMMAND_URI.format( + account_id=self.account.id, + device_serial=self.device_id, command=COMMAND_CLOSE, - ) - self.state = STATE_CLOSING - - wait_for_state_task = asyncio.create_task(self.wait_for_state( - current_state=[STATE_CLOSING], - new_state=[STATE_CLOSED], - last_state_update=self.device_json["state"].get("last_update"), - timeout=60, - ), name="MyQ_WaitForClose", + ), + command=COMMAND_CLOSE, + wait_for_state=wait_for_state, ) - if not wait_for_state: - return wait_for_state_task - - _LOGGER.debug("Waiting till garage is closed") - return await wait_for_state_task async def open(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: """Open the device.""" - if self.state not in (STATE_OPEN, STATE_OPENING): - # Set the current state to "opening" right away (in case the user doesn't - # run update() before checking): - await self._send_state_command( - url=COMMAND_URI.format( - account_id=self.account, - device_serial=self.device_id, - command=COMMAND_OPEN, - ), - command=COMMAND_OPEN, - ) - self.state = STATE_OPENING - wait_for_state_task = asyncio.create_task(self.wait_for_state( - current_state=[STATE_OPENING], - new_state=[STATE_OPEN], - last_state_update=self.device_json["state"].get("last_update"), - timeout=60, - ), name="MyQ_WaitForOpen", + return await self._send_state_command( + to_state=STATE_OPEN, + intermediate_state=STATE_OPENING, + url=COMMAND_URI.format( + account_id=self.account.id, + device_serial=self.device_id, + command=COMMAND_OPEN, + ), + command=COMMAND_OPEN, + wait_for_state=wait_for_state, ) - - if not wait_for_state: - return wait_for_state_task - - _LOGGER.debug("Waiting till garage is open") - return await wait_for_state_task diff --git a/pymyq/lamp.py b/pymyq/lamp.py index 32bcecd..1199ceb 100644 --- a/pymyq/lamp.py +++ b/pymyq/lamp.py @@ -1,17 +1,20 @@ """Define MyQ devices.""" import asyncio -import logging from datetime import datetime +import logging from typing import TYPE_CHECKING, Optional, Union from .device import MyQDevice if TYPE_CHECKING: - from .api import API + from .account import MyQAccount _LOGGER = logging.getLogger(__name__) -COMMAND_URI = "https://account-devices-lamp.myq-cloud.com/api/v5.2/Accounts/{account_id}/lamps/{device_serial}/{command}" +COMMAND_URI = ( + "https://account-devices-lamp.myq-cloud.com/api/v5.2/Accounts/{account_id}" + "/lamps/{device_serial}/{command}" +) COMMAND_ON = "on" COMMAND_OFF = "off" STATE_ON = "on" @@ -22,13 +25,16 @@ class MyQLamp(MyQDevice): """Define a generic device.""" def __init__( - self, api: "API", device_json: dict, account: str, state_update: datetime + self, + device_json: dict, + account: "MyQAccount", + state_update: datetime, ) -> None: """Initialize. :type account: str """ super().__init__( - api=api, account=account, device_json=device_json, state_update=state_update + account=account, device_json=device_json, state_update=state_update ) @property @@ -43,55 +49,29 @@ def device_state(self) -> Optional[str]: async def turnoff(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: """Turn light off.""" - await self._send_state_command( + return await self._send_state_command( + to_state=STATE_OFF, + intermediate_state=STATE_OFF, url=COMMAND_URI.format( - account_id=self.account, + account_id=self.account.id, device_serial=self.device_id, command=COMMAND_OFF, ), command=COMMAND_OFF, + wait_for_state=wait_for_state, ) - self.state = STATE_OFF - - wait_for_state_task = asyncio.create_task( - self.wait_for_state( - current_state=[STATE_ON], - new_state=[STATE_OFF], - last_state_update=self.device_json["state"].get("last_update"), - timeout=30, - ), - name="MyQ_WaitForOff", - ) - if not wait_for_state: - return wait_for_state_task - - _LOGGER.debug("Waiting till light is off") - return await wait_for_state_task async def turnon(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: """Turn light on.""" - await self._send_state_command( + return await self._send_state_command( + to_state=STATE_ON, + intermediate_state=STATE_ON, url=COMMAND_URI.format( - account_id=self.account, + account_id=self.account.id, device_serial=self.device_id, command=COMMAND_ON, ), command=COMMAND_ON, + wait_for_state=wait_for_state, ) - self.state = STATE_ON - - wait_for_state_task = asyncio.create_task( - self.wait_for_state( - current_state=[STATE_ON], - new_state=[STATE_OFF], - last_state_update=self.device_json["state"].get("last_update"), - timeout=30, - ), - name="MyQ_WaitForOn", - ) - if not wait_for_state: - return wait_for_state_task - - _LOGGER.debug("Waiting till light is on") - return await wait_for_state_task diff --git a/pymyq/request.py b/pymyq/request.py index a2ccbee..7a581bc 100644 --- a/pymyq/request.py +++ b/pymyq/request.py @@ -1,9 +1,13 @@ """Handle requests to MyQ.""" import asyncio -import logging +from datetime import datetime, timedelta from json import JSONDecodeError +import logging +from random import choices +import string +from typing import Optional, Tuple -from aiohttp import ClientSession, ClientResponse +from aiohttp import ClientResponse, ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError from .errors import RequestError @@ -14,13 +18,79 @@ json="request_json", text="request_text", response="request_response" ) DEFAULT_REQUEST_RETRIES = 5 +USER_AGENT_REFRESH = timedelta(hours=1) class MyQRequest: # pylint: disable=too-many-instance-attributes """Define a class to handle requests to MyQ""" - def __init__(self, websession: ClientSession = None, useragent: str = None) -> None: + def __init__(self, websession: ClientSession = None) -> None: self._websession = websession or ClientSession() + self._useragent = None + self._last_useragent_update = None + + async def _get_useragent(self) -> None: + """Retrieve a user agent to use in headers.""" + + # Only see to retrieve a user agent if currently we do not have one, + # we do not have an datetime on when we last retrieved one, + # or we're passed the minimum time between requests. + if ( + self._useragent is not None + and self._last_useragent_update is not None + and self._last_useragent_update + USER_AGENT_REFRESH > datetime.utcnow() + ): + _LOGGER.debug( + "Ignoring user agent update request as it is within throttle window" + ) + return + + self._last_useragent_update = datetime.utcnow() + + # Retrieve user agent from GitHub if not provided for login. + _LOGGER.debug("Retrieving user agent from GitHub.") + url = "https://raw.githubusercontent.com/arraylabs/pymyq/master/.USER_AGENT" + + try: + async with ClientSession() as session: + async with session.get(url) as resp: + useragent = await resp.text() + resp.raise_for_status() + _LOGGER.debug("Retrieved user agent %s from GitHub.", useragent) + + except ClientError as exc: + # Default user agent to random string with length of 5 + # if failure to retrieve it from GitHub. + useragent = "#RANDOM:5" + _LOGGER.warning( + "Failed retrieving user agent from GitHub, will use randomized user agent " + "instead: %s", + str(exc), + ) + + useragent = useragent.strip() + # Check if value for useragent is to create a random user agent. + useragent_list = useragent.split(":") + if useragent_list[0] == "#RANDOM": + # Create a random string, check if length is provided for the random string, + # if not then default is 5. + try: + randomlength = int(useragent_list[1]) if len(useragent_list) == 2 else 5 + except ValueError: + _LOGGER.debug( + "Random length value %s in user agent %s is not an integer. " + "Setting to 5 instead.", + useragent_list[1], + useragent, + ) + randomlength = 5 + + # Create the random user agent. + useragent = "".join( + choices(string.ascii_letters + string.digits, k=randomlength) + ) + _LOGGER.debug("User agent set to randomized value: %s.", useragent) + self._useragent = useragent async def _send_request( @@ -33,29 +103,38 @@ async def _send_request( data: dict = None, json: dict = None, allow_redirects: bool = False, - ) -> ClientResponse: + ) -> Optional[ClientResponse]: attempt = 0 + resp = None resp_exc = None last_status = "" last_error = "" - if self._useragent is not None: - headers.update({"User-Agent": self._useragent}) + for attempt in range(DEFAULT_REQUEST_RETRIES): + if self._useragent is None: + await self._get_useragent() + + if self._useragent is not None and self._useragent != "": + headers.update({"User-Agent": self._useragent}) - while attempt < DEFAULT_REQUEST_RETRIES: if attempt != 0: wait_for = min(2 ** attempt, 5) _LOGGER.debug( - f'Request failed with "{last_status} {last_error}" ' - f'(attempt #{attempt}/{DEFAULT_REQUEST_RETRIES})"; trying again in {wait_for} seconds' + 'Request failed with "%s %s" (attempt #%s/%s)"; trying again in %s seconds', + last_status, + last_error, + attempt, + DEFAULT_REQUEST_RETRIES, + wait_for, ) await asyncio.sleep(wait_for) - attempt += 1 try: _LOGGER.debug( - f"Sending myq api request {url} and headers {headers} with connection pooling" + "Sending myq api request %s and headers %s with connection pooling", + url, + headers, ) resp = await websession.request( method, @@ -70,28 +149,42 @@ async def _send_request( ) _LOGGER.debug("Response:") - _LOGGER.debug(f" Response Code: {resp.status}") - _LOGGER.debug(f" Headers: {resp.raw_headers}") - _LOGGER.debug(f" Body: {await resp.text()}") + _LOGGER.debug(" Response Code: %s", resp.status) + _LOGGER.debug(" Headers: %s", resp.raw_headers) + _LOGGER.debug(" Body: %s", await resp.text()) return resp except ClientResponseError as err: _LOGGER.debug( - f"Attempt {attempt} request failed with exception : {err.status} - {err.message}" + "Attempt %s request failed with exception : %s - %s", + attempt + 1, + err.status, + err.message, ) if err.status == 401: raise err + last_status = err.status last_error = err.message resp_exc = err + + if err.status == 400 and attempt == 0: + _LOGGER.debug( + "Received error status 400, bad request. Will refresh user agent." + ) + await self._get_useragent() + except ClientError as err: _LOGGER.debug( - f"Attempt {attempt} request failed with exception:: {str(err)}" + "Attempt %s request failed with exception: %s", attempt, str(err) ) last_status = "" last_error = str(err) resp_exc = err - raise resp_exc + if resp_exc is not None: + raise resp_exc + + return resp async def request_json( self, @@ -103,9 +196,28 @@ async def request_json( data: dict = None, json: dict = None, allow_redirects: bool = False, - ) -> (ClientResponse, dict): + ) -> Tuple[Optional[ClientResponse], Optional[dict]]: + """Send request and retrieve json response + + Args: + method (str): [description] + url (str): [description] + websession (ClientSession, optional): [description]. Defaults to None. + headers (dict, optional): [description]. Defaults to None. + params (dict, optional): [description]. Defaults to None. + data (dict, optional): [description]. Defaults to None. + json (dict, optional): [description]. Defaults to None. + allow_redirects (bool, optional): [description]. Defaults to False. + + Raises: + RequestError: [description] + + Returns: + Tuple[Optional[ClientResponse], Optional[dict]]: [description] + """ websession = websession or self._websession + json_data = None resp = await self._send_request( method=method, @@ -118,17 +230,18 @@ async def request_json( websession=websession, ) - try: - data = await resp.json(content_type=None) - except JSONDecodeError as err: - message = ( - f"JSON Decoder error {err.msg} in response at line {err.lineno} column {err.colno}. Response " - f"received was:\n{err.doc}" - ) - _LOGGER.error(message) - raise RequestError(message) + if resp is not None: + try: + json_data = await resp.json(content_type=None) + except JSONDecodeError as err: + message = ( + f"JSON Decoder error {err.msg} in response at line {err.lineno}" + f" column {err.colno}. Response received was:\n{err.doc}" + ) + _LOGGER.error(message) + raise RequestError(message) from err - return resp, data + return resp, json_data async def request_text( self, @@ -140,10 +253,25 @@ async def request_text( data: dict = None, json: dict = None, allow_redirects: bool = False, - ) -> (ClientResponse, str): + ) -> Tuple[Optional[ClientResponse], Optional[str]]: + """Send request and retrieve text - websession = websession or self._websession + Args: + method (str): [description] + url (str): [description] + websession (ClientSession, optional): [description]. Defaults to None. + headers (dict, optional): [description]. Defaults to None. + params (dict, optional): [description]. Defaults to None. + data (dict, optional): [description]. Defaults to None. + json (dict, optional): [description]. Defaults to None. + allow_redirects (bool, optional): [description]. Defaults to False. + + Returns: + Tuple[Optional[ClientResponse], Optional[str]]: [description] + """ + websession = websession or self._websession + data_text = None resp = await self._send_request( method=method, url=url, @@ -155,7 +283,10 @@ async def request_text( websession=websession, ) - return resp, await resp.text() + if resp is not None: + data_text = await resp.text() + + return resp, data_text async def request_response( self, @@ -167,7 +298,22 @@ async def request_response( data: dict = None, json: dict = None, allow_redirects: bool = False, - ) -> (ClientResponse, None): + ) -> Tuple[Optional[ClientResponse], None]: + """Send request and just receive the ClientResponse object + + Args: + method (str): [description] + url (str): [description] + websession (ClientSession, optional): [description]. Defaults to None. + headers (dict, optional): [description]. Defaults to None. + params (dict, optional): [description]. Defaults to None. + data (dict, optional): [description]. Defaults to None. + json (dict, optional): [description]. Defaults to None. + allow_redirects (bool, optional): [description]. Defaults to False. + + Returns: + Tuple[Optional[ClientResponse], None]: [description] + """ websession = websession or self._websession diff --git a/requirements_dev.txt b/requirements_dev.txt index 398fa69..81f0c52 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,8 +1,8 @@ -r requirements.txt -pre-commit==2.10.1 -black==20.8b1 +pre-commit==2.14.0 +black==21.7b0 flake8==3.8.4 -isort==5.7.0 +isort==5.9.3 pylint>=2.6.0 setuptools>=53.0.0 twine>=3.3.0 diff --git a/setup.py b/setup.py index f0eea80..773ddbf 100644 --- a/setup.py +++ b/setup.py @@ -8,24 +8,22 @@ import io import os -import sys from shutil import rmtree +import sys -from setuptools import find_packages, setup, Command # type: ignore +from setuptools import Command, find_packages, setup # type: ignore # Package meta-data. -NAME = 'pymyq' -DESCRIPTION = 'Python package for controlling MyQ-Enabled Garage Door' -URL = 'https://github.com/arraylabs/pymyq' -EMAIL = 'chris@arraylabs.com' -AUTHOR = 'Chris Campbell' -REQUIRES_PYTHON = '>=3.8' +NAME = "pymyq" +DESCRIPTION = "Python package for controlling MyQ-Enabled Garage Door" +URL = "https://github.com/arraylabs/pymyq" +EMAIL = "chris@arraylabs.com" +AUTHOR = "Chris Campbell" +REQUIRES_PYTHON = ">=3.8" VERSION = None # What packages are required for this module to be executed? -REQUIRED = [ # type: ignore - 'aiohttp', 'beautifulsoup4', 'pkce' -] +REQUIRED = ["aiohttp", "beautifulsoup4", "pkce"] # type: ignore # The rest you shouldn't have to touch too much :) # ------------------------------------------------ @@ -37,28 +35,28 @@ # Import the README and use it as the long-description. # Note: this will only work if 'README.md' is present in your MANIFEST.in file! -with io.open(os.path.join(HERE, 'README.md'), encoding='utf-8') as f: - LONG_DESC = '\n' + f.read() +with io.open(os.path.join(HERE, "README.md"), encoding="utf-8") as f: + LONG_DESC = "\n" + f.read() # Load the package's __version__.py module as a dictionary. ABOUT = {} # type: ignore if not VERSION: - with open(os.path.join(HERE, NAME, '__version__.py')) as f: + with open(os.path.join(HERE, NAME, "__version__.py")) as f: exec(f.read(), ABOUT) # pylint: disable=exec-used else: - ABOUT['__version__'] = VERSION + ABOUT["__version__"] = VERSION class UploadCommand(Command): """Support setup.py upload.""" - description = 'Build and publish the package.' + description = "Build and publish the package." user_options = [] # type: ignore @staticmethod def status(string): """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(string)) + print("\033[1m{0}\033[0m".format(string)) def initialize_options(self): """Add options for initialization.""" @@ -71,21 +69,20 @@ def finalize_options(self): def run(self): """Run.""" try: - self.status('Removing previous builds…') - rmtree(os.path.join(HERE, 'dist')) + self.status("Removing previous builds…") + rmtree(os.path.join(HERE, "dist")) except OSError: pass - self.status('Building Source and Wheel (universal) distribution…') - os.system('{0} setup.py sdist bdist_wheel --universal'.format( - sys.executable)) + self.status("Building Source and Wheel (universal) distribution…") + os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) - self.status('Uploading the package to PyPi via Twine…') - os.system('twine upload dist/*') + self.status("Uploading the package to PyPi via Twine…") + os.system("twine upload dist/*") - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(ABOUT['__version__'])) - os.system('git push --tags') + self.status("Pushing git tags…") + os.system("git tag v{0}".format(ABOUT["__version__"])) + os.system("git push --tags") sys.exit() @@ -93,37 +90,36 @@ def run(self): # Where the magic happens: setup( name=NAME, - version=ABOUT['__version__'], + version=ABOUT["__version__"], description=DESCRIPTION, long_description=LONG_DESC, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", author=AUTHOR, # author_email=EMAIL, python_requires=REQUIRES_PYTHON, url=URL, - packages=find_packages(exclude=('tests',)), + packages=find_packages(exclude=("tests",)), # If your package is a single module, use this instead of 'packages': # py_modules=['mypackage'], - # entry_points={ # 'console_scripts': ['mycli=mymodule:cli'], # }, install_requires=REQUIRED, include_package_data=True, - license='MIT', + license="MIT", classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy' + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], # $ setup.py publish support. cmdclass={ - 'upload': UploadCommand, + "upload": UploadCommand, }, )