├── .USER_AGENT ├── .flake8 ├── .github └── dependabot.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Pipfile ├── README.md ├── example.py ├── pylintrc ├── pymyq ├── __init__.py ├── __version__.py ├── account.py ├── api.py ├── const.py ├── device.py ├── errors.py ├── garagedoor.py ├── lamp.py ├── lock.py └── request.py ├── requirements.txt ├── requirements_dev.txt ├── requirements_test.txt └── setup.py /.USER_AGENT: -------------------------------------------------------------------------------- 1 | #RANDOM:5 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | # Check for updates every weekday 6 | schedule: 7 | interval: "daily" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | Pipfile.lock 3 | pymyq.egg-info 4 | .DS_Store 5 | .idea/ 6 | __pycache__/ 7 | venv*/ 8 | build/* 9 | dist/* 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 arraylabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install pip pipenv 3 | pipenv lock 4 | pipenv install --dev 5 | lint: 6 | pipenv run flake8 pymyq 7 | pipenv run pydocstyle pymyq 8 | pipenv run pylint pymyq 9 | publish: 10 | pipenv run python setup.py sdist bdist_wheel 11 | pipenv run twine upload dist/* 12 | rm -rf dist/ build/ .egg simplisafe_python.egg-info/ 13 | typing: 14 | pipenv run mypy --ignore-missing-imports pymyq 15 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | 5 | [dev-packages] 6 | "flake8" = "*" 7 | mypy = "*" 8 | pydocstyle = "*" 9 | pylint = "*" 10 | twine = "*" 11 | 12 | [packages] 13 | aiodns = "*" 14 | aiohttp = "*" 15 | async-timeout = "*" 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is a Python 3.8+ module aiming to interact with the Chamberlain MyQ API. 4 | 5 | Code is licensed under the MIT license. 6 | 7 | # [Homeassistant](https://home-assistant.io) 8 | 9 | [Homeassistant](https://home-assistant.io) has a [core myQ component](https://www.home-assistant.io/integrations/myq/) leveraging this package. 10 | 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. 11 | 12 | # Getting Started 13 | 14 | ## Installation 15 | 16 | ```python 17 | pip install pymyq 18 | ``` 19 | 20 | ## Usage 21 | 22 | `pymyq` starts within an [aiohttp](https://aiohttp.readthedocs.io/en/stable/) 23 | `ClientSession`: 24 | 25 | ```python 26 | import asyncio 27 | 28 | from aiohttp import ClientSession 29 | 30 | 31 | async def main() -> None: 32 | """Create the aiohttp session and run.""" 33 | async with ClientSession() as websession: 34 | # YOUR CODE HERE 35 | 36 | 37 | asyncio.get_event_loop().run_until_complete(main()) 38 | ``` 39 | 40 | To get all MyQ devices associated with an account: 41 | 42 | ```python 43 | import asyncio 44 | 45 | from aiohttp import ClientSession 46 | 47 | import pymyq 48 | 49 | 50 | async def main() -> None: 51 | """Create the aiohttp session and run.""" 52 | async with ClientSession() as websession: 53 | myq = await pymyq.login('', '', websession) 54 | 55 | # Return only cover devices: 56 | devices = myq.covers 57 | # >>> {"serial_number123": } 58 | 59 | # Return only lamps devices: 60 | devices = myq.lamps 61 | # >>> {"serial_number123": } 62 | 63 | # Return only locks devices: 64 | devices = myq.locks 65 | # >>> {"serial_number123": } 66 | 67 | # Return only gateway devices: 68 | devices = myq.gateways 69 | # >>> {"serial_number123": } 70 | 71 | # Return *all* devices: 72 | devices = myq.devices 73 | # >>> {"serial_number123": , "serial_number456": } 74 | 75 | 76 | asyncio.get_event_loop().run_until_complete(main()) 77 | ``` 78 | 79 | ## API Properties 80 | 81 | - `accounts`: dictionary with all accounts (MyQAccount) 82 | - `covers`: dictionary with all covers (MyQGarageDoor) 83 | - `devices`: dictionary with all devices (MyQDevice) 84 | - `gateways`: dictionary with all gateways (MyQDevice) 85 | - `lamps`: dictionary with all lamps (MyQLamp) 86 | - `locks`: dictionary with all locks (MyQLock) 87 | - `last_state_update`: datetime (in UTC) last state update was retrieved for all items 88 | - `password`: password used for authentication. Can only be set, not retrieved 89 | - `username`: username for authentication. 90 | 91 | ## Account Properties (MyQAccount) 92 | 93 | - `api`: Associated API object 94 | - `id`: ID for the account 95 | - `name`: Name of the account 96 | - `covers`: dictionary with all covers for account (MyQGarageDoor) 97 | - `devices`: dictionary with all devices for account (MyQDevice) 98 | - `gateways`: dictionary with all gateways for account (MyQDevice) 99 | - `lamps`: dictionary with all lamps for account (MyQLamp) 100 | - `locks`: dictionary with all locks for account (MyQLock) 101 | - `account_json`: Dictionary containing all account information as retrieved from MyQ 102 | - `last_state_update`: datetime (in UTC) last state update was retrieved for all devices within this account 103 | 104 | ## Device Properties 105 | 106 | - `account`: Return account associated with device (MyQAccount) 107 | - `close_allowed`: Return whether the device can be closed unattended. 108 | - `device_family`: Return the family in which this device lives. 109 | - `device_id`: Return the device ID (serial number). 110 | - `device_json`: Dictionary containing all device information as retrieved from MyQ 111 | - `device_platform`: Return the device platform. 112 | - `device_type`: Return the device type. 113 | - `firmware_version`: Return the family in which this device lives. 114 | - `href`: URI for device 115 | - `name`: Return the device name. 116 | - `online`: Return whether the device is online. 117 | - `open_allowed`: Return whether the device can be opened unattended. 118 | - `parent_device_id`: Return the device ID (serial number) of this device's parent. 119 | - `state`: Return the current state of the device. 120 | - `state_update`: Returns datetime when device was last updated 121 | - `low_battery`: Returns if the garage has a low battery or not. 122 | 123 | ## API Methods 124 | 125 | These are coroutines and need to be `await`ed – see `example.py` for examples. 126 | 127 | - `authenticate`: Authenticate (or re-authenticate) to MyQ. Call this to 128 | re-authenticate immediately after changing username and/or password otherwise 129 | new username/password will only be used when token has to be refreshed. 130 | - `update_device_info`: Retrieve info and status for all accounts and devices 131 | 132 | ## Account Methods 133 | 134 | All of the routines on the `MyQAccount` class are coroutines and need to be 135 | `await`ed – see `example.py` for examples. 136 | 137 | - `update`: get the latest device info (state, etc.) for all devices associated with this account. 138 | 139 | ## Device Methods 140 | 141 | All of the routines on the `MyQDevice` class are coroutines and need to be 142 | `await`ed – see `example.py` for examples. 143 | 144 | - `update`: get the latest device info (state, etc.). Note that 145 | this runs MyQAccount.update and thus all devices within account will be updated 146 | 147 | ## Cover Methods 148 | 149 | All Device methods in addition to: 150 | 151 | - `close`: close the cover 152 | - `open`: open the cover 153 | 154 | ## Lamp Methods 155 | 156 | All Device methods in addition to: 157 | 158 | - `turnon`: turn lamp on 159 | - `turnoff`: turn lamp off 160 | 161 | # Acknowledgement 162 | 163 | Huge thank you to [hjdhjd](https://github.com/hjdhjd) for figuring out the updated V6 API and 164 | sharing his work with us. 165 | 166 | # Disclaimer 167 | 168 | The code here is based off of an unsupported API from 169 | [Chamberlain](http://www.chamberlain.com/) and is subject to change without 170 | notice. The authors claim no responsibility for damages to your garage door or 171 | property by use of the code within. 172 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | """Run an example script to quickly test.""" 2 | import asyncio 3 | import logging 4 | 5 | from aiohttp import ClientSession 6 | 7 | from pymyq import login 8 | from pymyq.account import MyQAccount 9 | from pymyq.errors import MyQError, RequestError 10 | from pymyq.garagedoor import STATE_CLOSED, STATE_OPEN 11 | 12 | _LOGGER = logging.getLogger() 13 | 14 | EMAIL = "" 15 | PASSWORD = "" 16 | ISSUE_COMMANDS = True 17 | # LOGLEVEL = logging.DEBUG 18 | LOGLEVEL = logging.WARNING 19 | 20 | 21 | def print_info(number: int, device): 22 | """Print the device information 23 | 24 | Args: 25 | number (int): [description] 26 | device ([type]): [description] 27 | """ 28 | print(f" Device {number + 1}: {device.name}") 29 | print(f" Device Online: {device.online}") 30 | print(f" Device ID: {device.device_id}") 31 | print( 32 | f" Parent Device ID: {device.parent_device_id}", 33 | ) 34 | print(f" Device Family: {device.device_family}") 35 | print( 36 | f" Device Platform: {device.device_platform}", 37 | ) 38 | print(f" Device Type: {device.device_type}") 39 | print(f" Firmware Version: {device.firmware_version}") 40 | print(f" Open Allowed: {device.open_allowed}") 41 | print(f" Close Allowed: {device.close_allowed}") 42 | print(f" Current State: {device.state}") 43 | print(" ---------") 44 | 45 | 46 | async def print_garagedoors(account: MyQAccount): # noqa: C901 47 | """Print garage door information and open/close if requested 48 | 49 | Args: 50 | account (MyQAccount): Account for which to retrieve garage doors 51 | """ 52 | print(f" GarageDoors: {len(account.covers)}") 53 | print(" ---------------") 54 | if len(account.covers) != 0: 55 | for idx, device in enumerate(account.covers.values()): 56 | print_info(number=idx, device=device) 57 | 58 | if ISSUE_COMMANDS: 59 | try: 60 | open_task = None 61 | opened = closed = False 62 | if device.open_allowed: 63 | if device.state == STATE_OPEN: 64 | print(f"Garage door {device.name} is already open") 65 | else: 66 | print(f"Opening garage door {device.name}") 67 | try: 68 | open_task = await device.open(wait_for_state=False) 69 | except MyQError as err: 70 | _LOGGER.error( 71 | "Error when trying to open %s: %s", 72 | device.name, 73 | str(err), 74 | ) 75 | print(f"Garage door {device.name} is {device.state}") 76 | 77 | else: 78 | print(f"Opening of garage door {device.name} is not allowed.") 79 | 80 | # We're not waiting for opening to be completed before we do call to close. 81 | # The API will wait automatically for the open to complete before then 82 | # processing the command to close. 83 | 84 | if device.close_allowed: 85 | if device.state == STATE_CLOSED: 86 | print(f"Garage door {device.name} is already closed") 87 | else: 88 | if open_task is None: 89 | print(f"Closing garage door {device.name}") 90 | else: 91 | print( 92 | f"Already requesting closing garage door {device.name}" 93 | ) 94 | 95 | try: 96 | closed = await device.close(wait_for_state=True) 97 | except MyQError as err: 98 | _LOGGER.error( 99 | "Error when trying to close %s: %s", 100 | device.name, 101 | str(err), 102 | ) 103 | 104 | if open_task is not None and not isinstance(open_task, bool): 105 | opened = await open_task 106 | 107 | if opened and closed: 108 | print( 109 | f"Garage door {device.name} was opened and then closed again." 110 | ) 111 | elif opened: 112 | print(f"Garage door {device.name} was opened but not closed.") 113 | elif closed: 114 | print(f"Garage door {device.name} was closed but not opened.") 115 | else: 116 | print(f"Garage door {device.name} was not opened nor closed.") 117 | 118 | except RequestError as err: 119 | _LOGGER.error(err) 120 | print(" ------------------------------") 121 | 122 | 123 | async def print_lamps(account: MyQAccount): 124 | """Print lamp information and turn on/off if requested 125 | 126 | Args: 127 | account (MyQAccount): Account for which to retrieve lamps 128 | """ 129 | print(f" Lamps: {len(account.lamps)}") 130 | print(" ---------") 131 | if len(account.lamps) != 0: 132 | for idx, device in enumerate(account.lamps.values()): 133 | print_info(number=idx, device=device) 134 | 135 | if ISSUE_COMMANDS: 136 | try: 137 | print(f"Turning lamp {device.name} on") 138 | await device.turnon(wait_for_state=True) 139 | await asyncio.sleep(5) 140 | print(f"Turning lamp {device.name} off") 141 | await device.turnoff(wait_for_state=True) 142 | except RequestError as err: 143 | _LOGGER.error(err) 144 | print(" ------------------------------") 145 | 146 | 147 | async def print_locks(account: MyQAccount): 148 | """Print lock information - currently unable to control locks 149 | 150 | Args: 151 | account (MyQAccount): Account for which to retrieve locks 152 | """ 153 | print(f" Locks: {len(account.locks)}") 154 | print(" ---------") 155 | if len(account.locks) != 0: 156 | for idx, device in enumerate(account.locks.values()): 157 | print_info(number=idx, device=device) 158 | print(" ------------------------------") 159 | 160 | 161 | async def print_gateways(account: MyQAccount): 162 | """Print gateways for account 163 | 164 | Args: 165 | account (MyQAccount): Account for which to retrieve gateways 166 | """ 167 | print(f" Gateways: {len(account.gateways)}") 168 | print(" ------------") 169 | if len(account.gateways) != 0: 170 | for idx, device in enumerate(account.gateways.values()): 171 | print_info(number=idx, device=device) 172 | 173 | print("------------------------------") 174 | 175 | 176 | async def print_other(account: MyQAccount): 177 | """Print unknown/other devices for account 178 | 179 | Args: 180 | account (MyQAccount): Account for which to retrieve unknown devices 181 | """ 182 | print(f" Other: {len(account.other)}") 183 | print(" ------------") 184 | if len(account.other) != 0: 185 | for idx, device in enumerate(account.other.values()): 186 | print_info(number=idx, device=device) 187 | 188 | print("------------------------------") 189 | 190 | 191 | async def main() -> None: 192 | """Create the aiohttp session and run the example.""" 193 | logging.basicConfig(level=LOGLEVEL) 194 | async with ClientSession() as websession: 195 | try: 196 | # Create an API object: 197 | print(f"{EMAIL} {PASSWORD}") 198 | api = await login(EMAIL, PASSWORD, websession) 199 | 200 | for account in api.accounts.values(): 201 | print(f"Account ID: {account.id}") 202 | print(f"Account Name: {account.name}") 203 | 204 | # Get all devices listed with this account – note that you can use 205 | # api.covers to only examine covers or api.lamps for only lamps. 206 | await print_garagedoors(account=account) 207 | 208 | await print_lamps(account=account) 209 | 210 | await print_locks(account=account) 211 | 212 | await print_gateways(account=account) 213 | 214 | await print_other(account=account) 215 | 216 | except MyQError as err: 217 | _LOGGER.error("There was an error: %s", err) 218 | 219 | 220 | asyncio.get_event_loop().run_until_complete(main()) 221 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | # Reasons disabled: 3 | # bad-continuation - Invalid attack on black 4 | # unnecessary-pass - This can hurt readability 5 | disable= 6 | bad-continuation, 7 | unnecessary-pass 8 | 9 | [REPORTS] 10 | reports=no 11 | 12 | [FORMAT] 13 | expected-line-ending-format=LF 14 | -------------------------------------------------------------------------------- /pymyq/__init__.py: -------------------------------------------------------------------------------- 1 | """Define module-level imports.""" 2 | from .api import login # noqa 3 | -------------------------------------------------------------------------------- /pymyq/__version__.py: -------------------------------------------------------------------------------- 1 | """Define a version constant.""" 2 | __version__ = "3.1.6" 3 | -------------------------------------------------------------------------------- /pymyq/account.py: -------------------------------------------------------------------------------- 1 | """Define MyQ accounts.""" 2 | 3 | import asyncio 4 | from datetime import datetime, timedelta 5 | import logging 6 | from typing import TYPE_CHECKING, Dict, Optional 7 | 8 | from .const import ( 9 | DEVICE_FAMILY_GARAGEDOOR, 10 | DEVICE_FAMILY_GATEWAY, 11 | DEVICE_FAMILY_LAMP, 12 | DEVICE_FAMILY_LOCK, 13 | DEVICES_ENDPOINT, 14 | ) 15 | from .device import MyQDevice 16 | from .errors import MyQError 17 | from .garagedoor import MyQGaragedoor 18 | from .lamp import MyQLamp 19 | from .lock import MyQLock 20 | 21 | if TYPE_CHECKING: 22 | from .api import API 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | DEFAULT_STATE_UPDATE_INTERVAL = timedelta(seconds=5) 27 | 28 | 29 | class MyQAccount: 30 | """Define an account.""" 31 | 32 | def __init__(self, api: "API", account_json: dict, devices: Optional[dict] = None) -> None: 33 | 34 | self._api = api 35 | self.account_json = account_json 36 | if devices is None: 37 | devices = {} 38 | self._devices = devices 39 | self.last_state_update = None # type: Optional[datetime] 40 | self._update = asyncio.Lock() # type: asyncio.Lock 41 | 42 | @property 43 | def api(self) -> "API": 44 | """Return API object""" 45 | return self._api 46 | 47 | @property 48 | def id(self) -> Optional[str]: 49 | """Return account id """ 50 | return self.account_json.get("id") 51 | 52 | @property 53 | def name(self) -> Optional[str]: 54 | """Return account name""" 55 | return self.account_json.get("name") 56 | 57 | @property 58 | def devices(self) -> Dict[str, MyQDevice]: 59 | """Return all devices within account""" 60 | return self._devices 61 | 62 | @property 63 | def covers(self) -> Dict[str, MyQGaragedoor]: 64 | """Return only those devices that are covers.""" 65 | return { 66 | device_id: device 67 | for device_id, device in self.devices.items() 68 | if isinstance(device, MyQGaragedoor) 69 | } 70 | 71 | @property 72 | def lamps(self) -> Dict[str, MyQLamp]: 73 | """Return only those devices that are lamps.""" 74 | return { 75 | device_id: device 76 | for device_id, device in self.devices.items() 77 | if isinstance(device, MyQLamp) 78 | } 79 | 80 | @property 81 | def gateways(self) -> Dict[str, MyQDevice]: 82 | """Return only those devices that are gateways.""" 83 | return { 84 | device_id: device 85 | for device_id, device in self.devices.items() 86 | if device.device_json["device_family"] == DEVICE_FAMILY_GATEWAY 87 | } 88 | 89 | @property 90 | def locks(self) -> Dict[str, MyQDevice]: 91 | """Return only those devices that are locks.""" 92 | return { 93 | device_id: device 94 | for device_id, device in self.devices.items() 95 | if device.device_json["device_family"] == DEVICE_FAMILY_LOCK 96 | } 97 | 98 | @property 99 | def other(self) -> Dict[str, MyQDevice]: 100 | """Return only those devices that are others.""" 101 | return { 102 | device_id: device 103 | for device_id, device in self.devices.items() 104 | if type(device) is MyQDevice 105 | and device.device_json["device_family"] != DEVICE_FAMILY_GATEWAY 106 | } 107 | 108 | async def _get_devices(self) -> None: 109 | 110 | _LOGGER.debug("Retrieving devices for account %s", self.name or self.id) 111 | 112 | _, devices_resp = await self._api.request( 113 | method="get", 114 | returns="json", 115 | url=DEVICES_ENDPOINT.format(account_id=self.id), 116 | ) 117 | 118 | if devices_resp is not None and not isinstance(devices_resp, dict): 119 | raise MyQError( 120 | f"Received object devices_resp of type {type(devices_resp)} but expecting type dict" 121 | ) 122 | 123 | state_update_timestmp = datetime.utcnow() 124 | if devices_resp is not None and devices_resp.get("items") is not None: 125 | for device in devices_resp.get("items"): 126 | serial_number = device.get("serial_number") 127 | if serial_number is None: 128 | _LOGGER.debug( 129 | "No serial number for device with name %s.", device.get("name") 130 | ) 131 | continue 132 | 133 | if serial_number in self._devices: 134 | _LOGGER.debug( 135 | "Updating information for device with serial number %s", 136 | serial_number, 137 | ) 138 | myqdevice = self._devices[serial_number] 139 | myqdevice.device_json = device 140 | myqdevice.last_state_update = state_update_timestmp 141 | 142 | else: 143 | if device.get("device_family") == DEVICE_FAMILY_GARAGEDOOR: 144 | _LOGGER.debug( 145 | "Adding new garage door with serial number %s", 146 | serial_number, 147 | ) 148 | new_device = MyQGaragedoor( 149 | account=self, 150 | device_json=device, 151 | state_update=state_update_timestmp, 152 | ) 153 | elif device.get("device_family") == DEVICE_FAMILY_LAMP: 154 | _LOGGER.debug( 155 | "Adding new lamp with serial number %s", serial_number 156 | ) 157 | new_device = MyQLamp( 158 | account=self, 159 | device_json=device, 160 | state_update=state_update_timestmp, 161 | ) 162 | elif device.get("device_family") == DEVICE_FAMILY_LOCK: 163 | _LOGGER.debug( 164 | "Adding new lock with serial number %s", serial_number 165 | ) 166 | new_device = MyQLock( 167 | account=self, 168 | device_json=device, 169 | state_update=state_update_timestmp, 170 | ) 171 | else: 172 | if device.get("device_family") == DEVICE_FAMILY_GATEWAY: 173 | _LOGGER.debug( 174 | "Adding new gateway with serial number %s", 175 | serial_number, 176 | ) 177 | else: 178 | _LOGGER.debug( 179 | "Adding unknown device family %s with serial number %s", 180 | device.get("device_family"), 181 | serial_number, 182 | ) 183 | 184 | new_device = MyQDevice( 185 | account=self, 186 | device_json=device, 187 | state_update=state_update_timestmp, 188 | ) 189 | 190 | if new_device: 191 | self._devices[serial_number] = new_device 192 | else: 193 | _LOGGER.debug("No devices found for account %s", self.name or self.id) 194 | 195 | async def update(self) -> None: 196 | """Get up-to-date device info.""" 197 | # The MyQ API can time out if state updates are too frequent; therefore, 198 | # if back-to-back requests occur within a threshold, respond to only the first 199 | # Ensure only 1 update task can run at a time. 200 | async with self._update: 201 | call_dt = datetime.utcnow() 202 | if not self.last_state_update: 203 | self.last_state_update = call_dt - DEFAULT_STATE_UPDATE_INTERVAL 204 | next_available_call_dt = ( 205 | self.last_state_update + DEFAULT_STATE_UPDATE_INTERVAL 206 | ) 207 | 208 | # Ensure we're within our minimum update interval 209 | if call_dt < next_available_call_dt: 210 | _LOGGER.debug( 211 | "Ignoring device update request for account %s as it is within throttle window", 212 | self.name or self.id, 213 | ) 214 | return 215 | 216 | await self._get_devices() 217 | self.last_state_update = datetime.utcnow() 218 | -------------------------------------------------------------------------------- /pymyq/api.py: -------------------------------------------------------------------------------- 1 | """Define the MyQ API.""" 2 | import asyncio 3 | from datetime import datetime, timedelta 4 | import logging 5 | from typing import Dict, List, Optional, Tuple, Union 6 | from urllib.parse import parse_qs, urlsplit 7 | 8 | from aiohttp import ClientResponse, ClientSession 9 | from aiohttp.client_exceptions import ClientError, ClientResponseError 10 | from bs4 import BeautifulSoup 11 | from pkce import generate_code_verifier, get_code_challenge 12 | from yarl import URL 13 | 14 | from .account import MyQAccount 15 | from .const import ( 16 | ACCOUNTS_ENDPOINT, 17 | OAUTH_AUTHORIZE_URI, 18 | OAUTH_BASE_URI, 19 | OAUTH_CLIENT_ID, 20 | OAUTH_CLIENT_SECRET, 21 | OAUTH_REDIRECT_URI, 22 | OAUTH_TOKEN_URI, 23 | ) 24 | from .device import MyQDevice 25 | from .errors import AuthenticationError, InvalidCredentialsError, MyQError, RequestError 26 | from .garagedoor import MyQGaragedoor 27 | from .lamp import MyQLamp 28 | from .lock import MyQLock 29 | from .request import REQUEST_METHODS, MyQRequest 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | DEFAULT_STATE_UPDATE_INTERVAL = timedelta(seconds=10) 34 | DEFAULT_TOKEN_REFRESH = 10 * 60 # 10 minutes 35 | 36 | 37 | class API: # pylint: disable=too-many-instance-attributes 38 | """Define a class for interacting with the MyQ iOS App API.""" 39 | 40 | def __init__( 41 | self, 42 | username: str, 43 | password: str, 44 | websession: ClientSession = None, 45 | ) -> None: 46 | """Initialize.""" 47 | self.__credentials = {"username": username, "password": password} 48 | self._myqrequests = MyQRequest(websession or ClientSession()) 49 | self._authentication_task = None # type:Optional[asyncio.Task] 50 | self._codeverifier = None # type: Optional[str] 51 | self._invalid_credentials = False # type: bool 52 | self._lock = asyncio.Lock() # type: asyncio.Lock 53 | self._update = asyncio.Lock() # type: asyncio.Lock 54 | self._security_token = ( 55 | None, 56 | None, 57 | None, 58 | ) # type: Tuple[Optional[str], Optional[datetime], Optional[datetime]] 59 | 60 | self.accounts = {} # type: Dict[str, MyQAccount] 61 | self.last_state_update = None # type: Optional[datetime] 62 | 63 | @property 64 | def devices(self) -> Dict[str, Union[MyQDevice, MyQGaragedoor, MyQLamp, MyQLock]]: 65 | """Return all devices.""" 66 | devices = {} 67 | for account in self.accounts.values(): 68 | devices.update(account.devices) 69 | return devices 70 | 71 | @property 72 | def covers(self) -> Dict[str, MyQGaragedoor]: 73 | """Return only those devices that are covers.""" 74 | covers = {} 75 | for account in self.accounts.values(): 76 | covers.update(account.covers) 77 | return covers 78 | 79 | @property 80 | def lamps(self) -> Dict[str, MyQLamp]: 81 | """Return only those devices that are lamps.""" 82 | lamps = {} 83 | for account in self.accounts.values(): 84 | lamps.update(account.lamps) 85 | return lamps 86 | 87 | @property 88 | def locks(self) -> Dict[str, MyQLock]: 89 | """Return only those devices that are locks.""" 90 | locks = {} 91 | for account in self.accounts.values(): 92 | locks.update(account.locks) 93 | return locks 94 | 95 | @property 96 | def gateways(self) -> Dict[str, MyQDevice]: 97 | """Return only those devices that are gateways.""" 98 | gateways = {} 99 | for account in self.accounts.values(): 100 | gateways.update(account.gateways) 101 | return gateways 102 | 103 | @property 104 | def _code_verifier(self) -> str: 105 | if self._codeverifier is None: 106 | self._codeverifier = generate_code_verifier(length=43) 107 | return self._codeverifier 108 | 109 | @property 110 | def username(self) -> str: 111 | """Username used to authenticate with on MyQ 112 | 113 | Returns: 114 | str: username 115 | """ 116 | return self.__credentials["username"] 117 | 118 | @username.setter 119 | def username(self, username: str): 120 | """Set username to use for authentication 121 | 122 | Args: 123 | username (str): Username to authenticate with 124 | """ 125 | self._invalid_credentials = False 126 | self.__credentials["username"] = username 127 | 128 | @property 129 | def password(self) -> Optional[str]: 130 | """Will return None, password retrieval is not possible 131 | 132 | Returns: 133 | None 134 | """ 135 | return None 136 | 137 | @password.setter 138 | def password(self, password: str): 139 | """Set password used to authenticate with 140 | 141 | Args: 142 | password (str): password 143 | """ 144 | self._invalid_credentials = False 145 | self.__credentials["password"] = password 146 | 147 | async def _authentication_task_completed(self) -> None: 148 | # If we had something for an authentication task and 149 | # it is done then get the result and clear it out. 150 | if self._authentication_task is not None: 151 | authentication_task = await self.authenticate(wait=False) 152 | if authentication_task.done(): 153 | _LOGGER.debug( 154 | "Scheduled token refresh completed, ensuring no exception." 155 | ) 156 | self._authentication_task = None 157 | try: 158 | # Get the result so any exception is raised. 159 | authentication_task.result() 160 | except asyncio.CancelledError: 161 | pass 162 | except (RequestError, AuthenticationError) as auth_err: 163 | message = f"Scheduled token refresh failed: {str(auth_err)}" 164 | _LOGGER.debug(message) 165 | 166 | async def _refresh_token(self) -> None: 167 | # Check if token has to be refreshed. 168 | if ( 169 | self._security_token[1] is None 170 | or self._security_token[1] <= datetime.utcnow() 171 | ): 172 | # Token has to be refreshed, get authentication task if running otherwise 173 | # start a new one. 174 | if self._security_token[0] is None: 175 | # Wait for authentication task to be completed. 176 | _LOGGER.debug( 177 | "Waiting for updated token, last refresh was %s", 178 | self._security_token[2], 179 | ) 180 | try: 181 | await self.authenticate(wait=True) 182 | except AuthenticationError as auth_err: 183 | message = f"Error trying to re-authenticate to myQ service: {str(auth_err)}" 184 | _LOGGER.debug(message) 185 | raise AuthenticationError(message) from auth_err 186 | else: 187 | # We still have a token, we can continue this request with 188 | # that token and schedule task to refresh token unless one is already running 189 | await self.authenticate(wait=False) 190 | 191 | async def request( 192 | self, 193 | method: str, 194 | returns: str, 195 | url: Union[URL, str], 196 | websession: ClientSession = None, 197 | headers: dict = None, 198 | params: dict = None, 199 | data: dict = None, 200 | json: dict = None, 201 | allow_redirects: bool = True, 202 | login_request: bool = False, 203 | ) -> Tuple[Optional[ClientResponse], Optional[Union[dict, str]]]: 204 | """Make a request.""" 205 | 206 | # Determine the method to call based on what is to be returned. 207 | call_method = REQUEST_METHODS.get(returns) 208 | if call_method is None: 209 | raise RequestError(f"Invalid return object requested: {returns}") 210 | 211 | call_method = getattr(self._myqrequests, call_method) 212 | 213 | # if this is a request as part of authentication to have it go through in parallel. 214 | if login_request: 215 | try: 216 | return await call_method( 217 | method=method, 218 | url=url, 219 | websession=websession, 220 | headers=headers, 221 | params=params, 222 | data=data, 223 | json=json, 224 | allow_redirects=allow_redirects, 225 | ) 226 | except ClientResponseError as err: 227 | message = ( 228 | f"Error requesting data from {url}: {err.status} - {err.message}" 229 | ) 230 | _LOGGER.debug(message) 231 | raise RequestError(message) from err 232 | 233 | except ClientError as err: 234 | message = f"Error requesting data from {url}: {str(err)}" 235 | _LOGGER.debug(message) 236 | raise RequestError(message) from err 237 | 238 | # The MyQ API can time out if multiple concurrent requests are made, so 239 | # ensure that only one gets through at a time. 240 | # Exception is when this is a login request AND there is already a lock, in that case 241 | # we're sending the request anyways as we know there is no active request now. 242 | async with self._lock: 243 | 244 | # Check if an authentication task was running and if so, if it has completed. 245 | await self._authentication_task_completed() 246 | 247 | # Check if token has to be refreshed and start task to refresh, wait if required now. 248 | await self._refresh_token() 249 | 250 | if not headers: 251 | headers = {} 252 | 253 | headers["Authorization"] = self._security_token[0] 254 | 255 | _LOGGER.debug("Sending %s request to %s.", method, url) 256 | # Do the request. We will try 2 times based on response. 257 | for attempt in range(2): 258 | try: 259 | return await call_method( 260 | method=method, 261 | url=url, 262 | websession=websession, 263 | headers=headers, 264 | params=params, 265 | data=data, 266 | json=json, 267 | allow_redirects=allow_redirects, 268 | ) 269 | except ClientResponseError as err: 270 | message = f"Error requesting data from {url}: {err.status} - {err.message}" 271 | 272 | if getattr(err, "status") and err.status == 401: 273 | if attempt == 0: 274 | self._security_token = (None, None, self._security_token[2]) 275 | _LOGGER.debug("Status 401 received, re-authenticating.") 276 | 277 | await self._refresh_token() 278 | else: 279 | # Received unauthorized again, 280 | # reset token and start task to get a new one. 281 | _LOGGER.debug(message) 282 | self._security_token = (None, None, self._security_token[2]) 283 | await self.authenticate(wait=False) 284 | raise AuthenticationError(message) from err 285 | else: 286 | _LOGGER.debug(message) 287 | raise RequestError(message) from err 288 | 289 | except ClientError as err: 290 | message = f"Error requesting data from {url}: {str(err)}" 291 | _LOGGER.debug(message) 292 | raise RequestError(message) from err 293 | return None, None 294 | 295 | async def _oauth_authenticate(self) -> Tuple[str, int]: 296 | 297 | async with ClientSession() as session: 298 | # retrieve authentication page 299 | _LOGGER.debug("Retrieving authentication page") 300 | resp, html = await self.request( 301 | method="get", 302 | returns="text", 303 | url=OAUTH_AUTHORIZE_URI, 304 | websession=session, 305 | headers={ 306 | "redirect": "follow", 307 | }, 308 | params={ 309 | "client_id": OAUTH_CLIENT_ID, 310 | "code_challenge": get_code_challenge(self._code_verifier), 311 | "code_challenge_method": "S256", 312 | "redirect_uri": OAUTH_REDIRECT_URI, 313 | "response_type": "code", 314 | "scope": "MyQ_Residential offline_access", 315 | }, 316 | login_request=True, 317 | ) 318 | 319 | # Scanning returned web page for required fields. 320 | _LOGGER.debug("Scanning login page for fields to return") 321 | soup = BeautifulSoup(html, "html.parser") 322 | 323 | # Go through all potential forms in the page returned. 324 | # This is in case multiple forms are returned. 325 | forms = soup.find_all("form") 326 | data = {} 327 | for form in forms: 328 | have_email = False 329 | have_password = False 330 | have_submit = False 331 | # Go through all the input fields. 332 | for field in form.find_all("input"): 333 | if field.get("type"): 334 | # Hidden value, include so we return back 335 | if field.get("type").lower() == "hidden": 336 | data.update( 337 | { 338 | field.get("name", "NONAME"): field.get( 339 | "value", "NOVALUE" 340 | ) 341 | } 342 | ) 343 | # Email field 344 | elif field.get("type").lower() == "email": 345 | data.update({field.get("name", "Email"): self.username}) 346 | have_email = True 347 | # Password field 348 | elif field.get("type").lower() == "password": 349 | data.update( 350 | { 351 | field.get( 352 | "name", "Password" 353 | ): self.__credentials.get("password") 354 | } 355 | ) 356 | have_password = True 357 | # To confirm this form also has a submit button 358 | elif field.get("type").lower() == "submit": 359 | have_submit = True 360 | 361 | # Confirm we found email, password, and submit in the form to be submitted 362 | if have_email and have_password and have_submit: 363 | break 364 | 365 | # If we're here then this is not the form to submit. 366 | data = {} 367 | 368 | # If data is empty then we did not find the valid form and are unable to continue. 369 | if len(data) == 0: 370 | _LOGGER.debug("Form with required fields not found") 371 | raise RequestError( 372 | "Form containing fields for email, password and submit not found." 373 | "Unable to continue login process." 374 | ) 375 | 376 | # Perform login to MyQ 377 | _LOGGER.debug("Performing login to MyQ") 378 | resp, _ = await self.request( 379 | method="post", 380 | returns="response", 381 | url=resp.url, 382 | websession=session, 383 | headers={ 384 | "Content-Type": "application/x-www-form-urlencoded", 385 | "Cookie": resp.cookies.output(attrs=[]), 386 | }, 387 | data=data, 388 | allow_redirects=False, 389 | login_request=True, 390 | ) 391 | 392 | # We're supposed to receive back at least 2 cookies. If not then authentication failed. 393 | if len(resp.cookies) < 2: 394 | message = "Invalid MyQ credentials provided. Please recheck login and password." 395 | self._invalid_credentials = True 396 | _LOGGER.debug(message) 397 | raise InvalidCredentialsError(message) 398 | 399 | # Intercept redirect back to MyQ iOS app 400 | _LOGGER.debug("Calling redirect page") 401 | resp, _ = await self.request( 402 | method="get", 403 | returns="response", 404 | url=f"{OAUTH_BASE_URI}{resp.headers['Location']}", 405 | websession=session, 406 | headers={ 407 | "Cookie": resp.cookies.output(attrs=[]), 408 | }, 409 | allow_redirects=False, 410 | login_request=True, 411 | ) 412 | 413 | # Retrieve token 414 | _LOGGER.debug("Getting token") 415 | redirect_url = f"{OAUTH_BASE_URI}{resp.headers['Location']}" 416 | 417 | resp, data = await self.request( 418 | returns="json", 419 | method="post", 420 | url=OAUTH_TOKEN_URI, 421 | websession=session, 422 | headers={ 423 | "Content-Type": "application/x-www-form-urlencoded", 424 | }, 425 | data={ 426 | "client_id": OAUTH_CLIENT_ID, 427 | "client_secret": OAUTH_CLIENT_SECRET, 428 | "code": parse_qs(urlsplit(redirect_url).query).get("code", ""), 429 | "code_verifier": self._code_verifier, 430 | "grant_type": "authorization_code", 431 | "redirect_uri": OAUTH_REDIRECT_URI, 432 | "scope": parse_qs(urlsplit(redirect_url).query).get( 433 | "code", "MyQ_Residential offline_access" 434 | ), 435 | }, 436 | login_request=True, 437 | ) 438 | 439 | if not isinstance(data, dict): 440 | raise MyQError( 441 | f"Received object data of type {type(data)} but expecting type dict" 442 | ) 443 | 444 | token = f"{data.get('token_type')} {data.get('access_token')}" 445 | try: 446 | expires = int(data.get("expires_in", DEFAULT_TOKEN_REFRESH)) 447 | except ValueError: 448 | _LOGGER.debug( 449 | "Expires %s received is not an integer, using default.", 450 | data.get("expires_in"), 451 | ) 452 | expires = DEFAULT_TOKEN_REFRESH * 2 453 | 454 | if expires < DEFAULT_TOKEN_REFRESH * 2: 455 | _LOGGER.debug( 456 | "Expires %s is less then default %s, setting to default instead.", 457 | expires, 458 | DEFAULT_TOKEN_REFRESH, 459 | ) 460 | expires = DEFAULT_TOKEN_REFRESH * 2 461 | 462 | return token, expires 463 | 464 | async def authenticate(self, wait: bool = True) -> Optional[asyncio.Task]: 465 | """Authenticate and get a security token.""" 466 | if self.username is None or self.__credentials["password"] is None: 467 | message = "No username/password, most likely due to previous failed authentication." 468 | _LOGGER.debug(message) 469 | raise InvalidCredentialsError(message) 470 | 471 | if self._invalid_credentials: 472 | message = "Credentials are invalid, update username/password to re-try authentication." 473 | _LOGGER.debug(message) 474 | raise InvalidCredentialsError(message) 475 | 476 | if self._authentication_task is None: 477 | # No authentication task is currently running, start one 478 | _LOGGER.debug( 479 | "Scheduling token refresh, last refresh was %s", self._security_token[2] 480 | ) 481 | self._authentication_task = asyncio.create_task( 482 | self._authenticate(), name="MyQ_Authenticate" 483 | ) 484 | 485 | if wait: 486 | try: 487 | await self._authentication_task 488 | except (RequestError, AuthenticationError) as auth_err: 489 | # Raise authentication error, we need a new token to continue 490 | # and not getting it right now. 491 | self._authentication_task = None 492 | raise AuthenticationError(str(auth_err)) from auth_err 493 | self._authentication_task = None 494 | 495 | return self._authentication_task 496 | 497 | async def _authenticate(self) -> None: 498 | # Retrieve and store the initial security token: 499 | _LOGGER.debug("Initiating OAuth authentication") 500 | token, expires = await self._oauth_authenticate() 501 | 502 | if token is None: 503 | _LOGGER.debug("No security token received.") 504 | raise AuthenticationError( 505 | "Authentication response did not contain a security token yet one is expected." 506 | ) 507 | 508 | _LOGGER.debug("Received token that will expire in %s seconds", expires) 509 | self._security_token = ( 510 | token, 511 | datetime.utcnow() + timedelta(seconds=int(expires / 2)), 512 | datetime.now(), 513 | ) 514 | 515 | async def _get_accounts(self) -> List: 516 | 517 | _LOGGER.debug("Retrieving account information") 518 | 519 | # Retrieve the accounts 520 | _, accounts_resp = await self.request( 521 | method="get", returns="json", url=ACCOUNTS_ENDPOINT 522 | ) 523 | 524 | if accounts_resp is not None and not isinstance(accounts_resp, dict): 525 | raise MyQError( 526 | f"Received object accounts_resp of type {type(accounts_resp)}" 527 | f"but expecting type dict" 528 | ) 529 | 530 | return accounts_resp.get("accounts", []) if accounts_resp is not None else [] 531 | 532 | async def update_device_info(self) -> None: 533 | """Get up-to-date device info.""" 534 | # The MyQ API can time out if state updates are too frequent; therefore, 535 | # if back-to-back requests occur within a threshold, respond to only the first 536 | # Ensure only 1 update task can run at a time. 537 | async with self._update: 538 | call_dt = datetime.utcnow() 539 | if not self.last_state_update: 540 | self.last_state_update = call_dt - DEFAULT_STATE_UPDATE_INTERVAL 541 | next_available_call_dt = ( 542 | self.last_state_update + DEFAULT_STATE_UPDATE_INTERVAL 543 | ) 544 | 545 | # Ensure we're within our minimum update interval AND 546 | # update request is not for a specific device 547 | if call_dt < next_available_call_dt: 548 | _LOGGER.debug("Ignoring update request as it is within throttle window") 549 | return 550 | 551 | _LOGGER.debug("Updating account information") 552 | # If update request is for a specific account then do not retrieve account information. 553 | accounts = await self._get_accounts() 554 | 555 | if len(accounts) == 0: 556 | _LOGGER.debug("No accounts found") 557 | self.accounts = {} 558 | return 559 | 560 | for account in accounts: 561 | account_id = account.get("id") 562 | if account_id is not None: 563 | if self.accounts.get(account_id): 564 | # Account already existed, update information. 565 | _LOGGER.debug( 566 | "Updating account %s with name %s", 567 | account_id, 568 | account.get("name"), 569 | ) 570 | 571 | self.accounts.get(account_id).account_json = account 572 | else: 573 | # This is a new account. 574 | _LOGGER.debug( 575 | "New account %s with name %s", 576 | account_id, 577 | account.get("name"), 578 | ) 579 | self.accounts.update( 580 | {account_id: MyQAccount(api=self, account_json=account)} 581 | ) 582 | 583 | # Perform a device update for this account. 584 | await self.accounts.get(account_id).update() 585 | 586 | self.last_state_update = datetime.utcnow() 587 | 588 | 589 | async def login( 590 | username: str, 591 | password: str, 592 | websession: ClientSession = None, 593 | auth_only: bool = False, 594 | ) -> API: 595 | """Log in to the API.""" 596 | 597 | # Set the user agent in the headers. 598 | api = API(username=username, password=password, websession=websession) 599 | _LOGGER.debug("Performing initial authentication into MyQ") 600 | try: 601 | await api.authenticate(wait=True) 602 | except InvalidCredentialsError as err: 603 | _LOGGER.error("Username and/or password are invalid. Update username/password.") 604 | raise err 605 | except AuthenticationError as err: 606 | _LOGGER.error("Authentication failed: %s", str(err)) 607 | raise err 608 | 609 | if not auth_only: 610 | # Retrieve and store initial set of devices: 611 | _LOGGER.debug("Retrieving MyQ information") 612 | await api.update_device_info() 613 | 614 | return api 615 | -------------------------------------------------------------------------------- /pymyq/const.py: -------------------------------------------------------------------------------- 1 | """The myq constants.""" 2 | 3 | OAUTH_CLIENT_ID = "IOS_CGI_MYQ" 4 | OAUTH_CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==" 5 | OAUTH_BASE_URI = "https://partner-identity.myq-cloud.com" 6 | OAUTH_AUTHORIZE_URI = f"{OAUTH_BASE_URI}/connect/authorize" 7 | OAUTH_REDIRECT_URI = "com.myqops://ios" 8 | OAUTH_TOKEN_URI = f"{OAUTH_BASE_URI}/connect/token" 9 | 10 | ACCOUNTS_ENDPOINT = "https://accounts.myq-cloud.com/api/v6.0/accounts" 11 | DEVICES_ENDPOINT = ( 12 | "https://devices.myq-cloud.com/api/v5.2/Accounts/{account_id}/Devices" 13 | ) 14 | 15 | WAIT_TIMEOUT = 60 16 | 17 | DEVICE_TYPE = "device_type" 18 | DEVICE_TYPE_GATE = "gate" 19 | DEVICE_FAMILY = "device_family" 20 | DEVICE_FAMILY_GATEWAY = "gateway" 21 | DEVICE_FAMILY_GARAGEDOOR = "garagedoor" 22 | DEVICE_FAMILY_LAMP = "lamp" 23 | DEVICE_FAMILY_LOCK = "locks" 24 | DEVICE_STATE = "state" 25 | DEVICE_STATE_ONLINE = "online" 26 | 27 | MANUFACTURER = "The Chamberlain Group Inc." 28 | 29 | KNOWN_MODELS = { 30 | "00": "Chamberlain Ethernet Gateway", 31 | "01": "LiftMaster Ethernet Gateway", 32 | "02": "Craftsman Ethernet Gateway", 33 | "03": "Chamberlain Wi-Fi hub", 34 | "04": "LiftMaster Wi-Fi hub", 35 | "05": "Craftsman Wi-Fi hub", 36 | "08": "LiftMaster Wi-Fi GDO DC w/Battery Backup", 37 | "09": "Chamberlain Wi-Fi GDO DC w/Battery Backup", 38 | "10": "Craftsman Wi-Fi GDO DC 3/4HP", 39 | "11": "MyQ Replacement Logic Board Wi-Fi GDO DC 3/4HP", 40 | "12": "Chamberlain Wi-Fi GDO DC 1.25HP", 41 | "13": "LiftMaster Wi-Fi GDO DC 1.25HP", 42 | "14": "Craftsman Wi-Fi GDO DC 1.25HP", 43 | "15": "MyQ Replacement Logic Board Wi-Fi GDO DC 1.25HP", 44 | "0A": "Chamberlain Wi-Fi GDO or Gate Operator AC", 45 | "0B": "LiftMaster Wi-Fi GDO or Gate Operator AC", 46 | "0C": "Craftsman Wi-Fi GDO or Gate Operator AC", 47 | "0D": "MyQ Replacement Logic Board Wi-Fi GDO or Gate Operator AC", 48 | "0E": "Chamberlain Wi-Fi GDO DC 3/4HP", 49 | "0F": "LiftMaster Wi-Fi GDO DC 3/4HP", 50 | "20": "Chamberlain MyQ Home Bridge", 51 | "21": "LiftMaster MyQ Home Bridge", 52 | "23": "Chamberlain Smart Garage Hub", 53 | "24": "LiftMaster Smart Garage Hub", 54 | "27": "LiftMaster Wi-Fi Wall Mount opener", 55 | "28": "LiftMaster Commercial Wi-Fi Wall Mount operator", 56 | "80": "EU LiftMaster Ethernet Gateway", 57 | "81": "EU Chamberlain Ethernet Gateway", 58 | } 59 | -------------------------------------------------------------------------------- /pymyq/device.py: -------------------------------------------------------------------------------- 1 | """Define MyQ devices.""" 2 | import asyncio 3 | from datetime import datetime 4 | import logging 5 | from typing import TYPE_CHECKING, List, Optional, Union 6 | 7 | from .const import DEVICE_TYPE, WAIT_TIMEOUT 8 | from .errors import MyQError, RequestError 9 | 10 | if TYPE_CHECKING: 11 | from .account import MyQAccount 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class MyQDevice: 17 | """Define a generic device.""" 18 | 19 | def __init__( 20 | self, 21 | device_json: dict, 22 | account: "MyQAccount", 23 | state_update: datetime, 24 | ) -> None: 25 | """Initialize. 26 | :type account: str 27 | """ 28 | self._account = account 29 | self.device_json = device_json 30 | self.last_state_update = state_update 31 | self.state_update = None 32 | self._device_state = None # Type: Optional[str] 33 | self._send_command_lock = asyncio.Lock() # type: asyncio.Lock 34 | self._wait_for_state_task = None 35 | 36 | @property 37 | def account(self) -> "MyQAccount": 38 | """Return account associated with device""" 39 | return self._account 40 | 41 | @property 42 | def device_family(self) -> Optional[str]: 43 | """Return the family in which this device lives.""" 44 | return self.device_json.get("device_family") 45 | 46 | @property 47 | def device_id(self) -> Optional[str]: 48 | """Return the device ID (serial number).""" 49 | return self.device_json.get("serial_number") 50 | 51 | @property 52 | def device_platform(self) -> Optional[str]: 53 | """Return the device platform.""" 54 | return self.device_json.get("device_platform") 55 | 56 | @property 57 | def device_type(self) -> Optional[str]: 58 | """Return the device type.""" 59 | return self.device_json.get(DEVICE_TYPE) 60 | 61 | @property 62 | def firmware_version(self) -> Optional[str]: 63 | """Return the family in which this device lives.""" 64 | return self.device_json["state"].get("firmware_version") 65 | 66 | @property 67 | def name(self) -> Optional[str]: 68 | """Return the device name.""" 69 | return self.device_json.get("name") 70 | 71 | @property 72 | def online(self) -> bool: 73 | """Return whether the device is online.""" 74 | state = self.device_json.get("state") 75 | if state is None: 76 | return False 77 | 78 | return state.get("online") is True 79 | 80 | @property 81 | def parent_device_id(self) -> Optional[str]: 82 | """Return the device ID (serial number) of this device's parent.""" 83 | return self.device_json.get("parent_device_id") 84 | 85 | @property 86 | def href(self) -> Optional[str]: 87 | """Return the hyperlinks of the device.""" 88 | return self.device_json.get("href") 89 | 90 | @property 91 | def state(self) -> Optional[str]: 92 | """Return current state 93 | 94 | Returns: 95 | Optional[str]: State for the device 96 | """ 97 | return self._device_state or self.device_state 98 | 99 | @state.setter 100 | def state(self, value: str): 101 | """Set the current state of the device.""" 102 | self._device_state = value 103 | 104 | @property 105 | def device_state(self) -> Optional[str]: 106 | return None 107 | 108 | @property 109 | def close_allowed(self) -> bool: 110 | """Return whether the device can be closed unattended.""" 111 | return False 112 | 113 | @property 114 | def open_allowed(self) -> bool: 115 | """Return whether the device can be opened unattended.""" 116 | return False 117 | 118 | async def close(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 119 | raise NotImplementedError 120 | 121 | async def open(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 122 | raise NotImplementedError 123 | 124 | async def turnoff(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 125 | raise NotImplementedError 126 | 127 | async def turnon(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 128 | raise NotImplementedError 129 | 130 | async def update_device(self, device_json: dict, state_update_timestmp: datetime): 131 | """Update state of device depending on last update in MyQ is after last state set 132 | 133 | by us 134 | 135 | Args: 136 | device_json (dict): device json 137 | state_update_timestmp (datetime): [description] 138 | """ 139 | # When performing commands we might update the state temporary, need to ensure 140 | # that the state is not set back to something else if MyQ does not yet have updated 141 | # state 142 | last_update = self.device_json["state"].get("last_update") 143 | self.device_json = device_json 144 | 145 | if ( 146 | self.device_json["state"].get("last_update") is not None 147 | and self.device_json["state"].get("last_update") != last_update 148 | ): 149 | # MyQ has updated device state, reset ours ensuring we have the one from MyQ. 150 | self._device_state = None 151 | _LOGGER.debug( 152 | "State for device %s was updated to %s", self.name, self.state 153 | ) 154 | 155 | self.state_update = state_update_timestmp 156 | 157 | async def _send_state_command( 158 | self, 159 | to_state: str, 160 | intermediate_state: str, 161 | url: str, 162 | command: str, 163 | wait_for_state: bool = False, 164 | ) -> Union[asyncio.Task, bool]: 165 | """Send command to device to change state.""" 166 | 167 | # If the user tries to open or close, say, a gateway, throw an exception: 168 | if not self.state: 169 | raise RequestError( 170 | f"Cannot change state of device type: {self.device_type}" 171 | ) 172 | 173 | # If currently there is a wait_for_state task running, 174 | # then wait until it completes first. 175 | if self._wait_for_state_task is not None: 176 | # Return wait task if we're currently waiting for same task to be completed 177 | if self.state == intermediate_state and not wait_for_state: 178 | _LOGGER.debug( 179 | "Command %s for %s was already send, returning wait task for it instead", 180 | command, 181 | self.name, 182 | ) 183 | return self._wait_for_state_task 184 | 185 | _LOGGER.debug( 186 | "Another command for %s is still in progress, waiting for it to complete first before issuing command %s", 187 | self.name, 188 | command, 189 | ) 190 | await self._wait_for_state_task 191 | 192 | # We return true if current state is already in new state. 193 | if self.state == to_state: 194 | _LOGGER.debug( 195 | "Device %s is in state %s, nothing to do.", self.name, to_state 196 | ) 197 | return True 198 | 199 | async with self._send_command_lock: 200 | _LOGGER.debug("Sending command %s for %s", command, self.name) 201 | await self.account.api.request( 202 | method="put", 203 | returns="response", 204 | url=url, 205 | ) 206 | 207 | self.state = intermediate_state 208 | 209 | self._wait_for_state_task = asyncio.create_task( 210 | self.wait_for_state( 211 | current_state=[self.state], 212 | new_state=[to_state], 213 | last_state_update=self.device_json["state"].get("last_update"), 214 | timeout=60, 215 | ), 216 | name="MyQ_WaitFor" + to_state, 217 | ) 218 | 219 | # Make sure our wait task starts 220 | await asyncio.sleep(0) 221 | 222 | if not wait_for_state: 223 | return self._wait_for_state_task 224 | 225 | _LOGGER.debug("Waiting till device is %s", to_state) 226 | return await self._wait_for_state_task 227 | 228 | async def update(self) -> None: 229 | """Get the latest info for this device.""" 230 | await self.account.update() 231 | 232 | async def wait_for_state( 233 | self, 234 | current_state: List, 235 | new_state: List, 236 | last_state_update: datetime, 237 | timeout: int = WAIT_TIMEOUT, 238 | ) -> bool: 239 | """Wait until device has reached new state 240 | 241 | Args: 242 | current_state (List): List of possible current states 243 | new_state (List): List of new states to wait for 244 | last_state_update (datetime): Last time state was updated 245 | timeout (int, optional): Timeout in seconds to wait for new state. 246 | Defaults to WAIT_TIMEOUT. 247 | 248 | Returns: 249 | bool: True if new state reached, False if new state was not reached 250 | """ 251 | # First wait until door state is actually updated. 252 | _LOGGER.debug("Waiting until device state has been updated for %s", self.name) 253 | wait_timeout = timeout 254 | while ( 255 | last_state_update 256 | == self.device_json["state"].get("last_update", datetime.utcnow()) 257 | and wait_timeout > 0 258 | ): 259 | wait_timeout = wait_timeout - 5 260 | try: 261 | await self._account.update() 262 | except MyQError: 263 | # Ignoring 264 | pass 265 | await asyncio.sleep(5) 266 | 267 | # Wait until the state is to what we want it to be 268 | _LOGGER.debug("Waiting until device state for %s is %s", self.name, new_state) 269 | wait_timeout = timeout 270 | while self.device_state not in new_state and wait_timeout > 0: 271 | wait_timeout = wait_timeout - 5 272 | try: 273 | await self._account.update() 274 | except MyQError: 275 | # Ignoring 276 | pass 277 | await asyncio.sleep(5) 278 | 279 | # Reset self.state ensuring it reflects actual device state. 280 | # Only do this if state is still what it would have been, 281 | # this to ensure if something else had updated it to something else we don't override. 282 | if self._device_state in current_state or self._device_state in new_state: 283 | self._device_state = None 284 | 285 | self._wait_for_state_task = None 286 | return self.state in new_state 287 | -------------------------------------------------------------------------------- /pymyq/errors.py: -------------------------------------------------------------------------------- 1 | """Define exceptions.""" 2 | 3 | 4 | class MyQError(Exception): 5 | """Define a base exception.""" 6 | 7 | pass 8 | 9 | 10 | class InvalidCredentialsError(MyQError): 11 | """Define an exception related to invalid credentials.""" 12 | 13 | pass 14 | 15 | 16 | class AuthenticationError(MyQError): 17 | """Define an exception related to invalid credentials.""" 18 | 19 | pass 20 | 21 | 22 | class RequestError(MyQError): 23 | """Define an exception related to bad HTTP requests.""" 24 | 25 | pass 26 | -------------------------------------------------------------------------------- /pymyq/garagedoor.py: -------------------------------------------------------------------------------- 1 | """Define MyQ devices.""" 2 | import asyncio 3 | from datetime import datetime 4 | import logging 5 | from typing import TYPE_CHECKING, Optional, Union 6 | 7 | from .device import MyQDevice 8 | 9 | if TYPE_CHECKING: 10 | from .account import MyQAccount 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | COMMAND_URI = ( 15 | "https://account-devices-gdo.myq-cloud.com/api/v5.2/Accounts/{account_id}" 16 | "/door_openers/{device_serial}/{command}" 17 | ) 18 | COMMAND_CLOSE = "close" 19 | COMMAND_OPEN = "open" 20 | STATE_CLOSED = "closed" 21 | STATE_CLOSING = "closing" 22 | STATE_OPEN = "open" 23 | STATE_OPENING = "opening" 24 | STATE_STOPPED = "stopped" 25 | STATE_UNKNOWN = "unknown" 26 | 27 | 28 | class MyQGaragedoor(MyQDevice): 29 | """Define a generic device.""" 30 | 31 | def __init__( 32 | self, 33 | device_json: dict, 34 | account: "MyQAccount", 35 | state_update: datetime, 36 | ) -> None: 37 | """Initialize. 38 | :type account: str 39 | """ 40 | super().__init__( 41 | account=account, device_json=device_json, state_update=state_update 42 | ) 43 | 44 | @property 45 | def close_allowed(self) -> bool: 46 | """Return whether the device can be closed unattended.""" 47 | return self.device_json["state"].get("is_unattended_close_allowed") is True 48 | 49 | @property 50 | def open_allowed(self) -> bool: 51 | """Return whether the device can be opened unattended.""" 52 | return self.device_json["state"].get("is_unattended_open_allowed") is True 53 | 54 | @property 55 | def low_battery(self) -> bool: 56 | """Return whether the device has low battery.""" 57 | return self.device_json["state"].get("dps_low_battery_mode") is True 58 | 59 | @property 60 | def device_state(self) -> Optional[str]: 61 | """Return the current state of the device.""" 62 | return ( 63 | self.device_json["state"].get("door_state") 64 | if self.device_json.get("state") is not None 65 | else None 66 | ) 67 | 68 | async def close(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 69 | """Close the device.""" 70 | 71 | return await self._send_state_command( 72 | to_state=STATE_CLOSED, 73 | intermediate_state=STATE_CLOSING, 74 | url=COMMAND_URI.format( 75 | account_id=self.account.id, 76 | device_serial=self.device_id, 77 | command=COMMAND_CLOSE, 78 | ), 79 | command=COMMAND_CLOSE, 80 | wait_for_state=wait_for_state, 81 | ) 82 | 83 | async def open(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 84 | """Open the device.""" 85 | 86 | return await self._send_state_command( 87 | to_state=STATE_OPEN, 88 | intermediate_state=STATE_OPENING, 89 | url=COMMAND_URI.format( 90 | account_id=self.account.id, 91 | device_serial=self.device_id, 92 | command=COMMAND_OPEN, 93 | ), 94 | command=COMMAND_OPEN, 95 | wait_for_state=wait_for_state, 96 | ) 97 | -------------------------------------------------------------------------------- /pymyq/lamp.py: -------------------------------------------------------------------------------- 1 | """Define MyQ devices.""" 2 | import asyncio 3 | from datetime import datetime 4 | import logging 5 | from typing import TYPE_CHECKING, Optional, Union 6 | 7 | from .device import MyQDevice 8 | 9 | if TYPE_CHECKING: 10 | from .account import MyQAccount 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | COMMAND_URI = ( 15 | "https://account-devices-lamp.myq-cloud.com/api/v5.2/Accounts/{account_id}" 16 | "/lamps/{device_serial}/{command}" 17 | ) 18 | COMMAND_ON = "on" 19 | COMMAND_OFF = "off" 20 | STATE_ON = "on" 21 | STATE_OFF = "off" 22 | 23 | 24 | class MyQLamp(MyQDevice): 25 | """Define a generic device.""" 26 | 27 | def __init__( 28 | self, 29 | device_json: dict, 30 | account: "MyQAccount", 31 | state_update: datetime, 32 | ) -> None: 33 | """Initialize. 34 | :type account: str 35 | """ 36 | super().__init__( 37 | account=account, device_json=device_json, state_update=state_update 38 | ) 39 | 40 | @property 41 | def device_state(self) -> Optional[str]: 42 | """Return the current state of the device.""" 43 | return ( 44 | self.device_json["state"].get("lamp_state") 45 | if self.device_json.get("state") is not None 46 | else None 47 | ) 48 | 49 | async def turnoff(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 50 | """Turn light off.""" 51 | 52 | return await self._send_state_command( 53 | to_state=STATE_OFF, 54 | intermediate_state=STATE_OFF, 55 | url=COMMAND_URI.format( 56 | account_id=self.account.id, 57 | device_serial=self.device_id, 58 | command=COMMAND_OFF, 59 | ), 60 | command=COMMAND_OFF, 61 | wait_for_state=wait_for_state, 62 | ) 63 | 64 | async def turnon(self, wait_for_state: bool = False) -> Union[asyncio.Task, bool]: 65 | """Turn light on.""" 66 | 67 | return await self._send_state_command( 68 | to_state=STATE_ON, 69 | intermediate_state=STATE_ON, 70 | url=COMMAND_URI.format( 71 | account_id=self.account.id, 72 | device_serial=self.device_id, 73 | command=COMMAND_ON, 74 | ), 75 | command=COMMAND_ON, 76 | wait_for_state=wait_for_state, 77 | ) 78 | -------------------------------------------------------------------------------- /pymyq/lock.py: -------------------------------------------------------------------------------- 1 | """Define MyQ devices.""" 2 | import asyncio 3 | from datetime import datetime 4 | import logging 5 | from typing import TYPE_CHECKING, Optional, Union 6 | 7 | from .device import MyQDevice 8 | 9 | if TYPE_CHECKING: 10 | from .account import MyQAccount 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | COMMAND_URI = ( 15 | "https://account-devices-lock.myq-cloud.com/api/v5.2/Accounts/{account_id}" 16 | "/locks/{device_serial}/{command}" 17 | ) 18 | 19 | 20 | class MyQLock(MyQDevice): 21 | """Define a generic device.""" 22 | 23 | def __init__( 24 | self, 25 | device_json: dict, 26 | account: "MyQAccount", 27 | state_update: datetime, 28 | ) -> None: 29 | """Initialize. 30 | :type account: str 31 | """ 32 | super().__init__( 33 | account=account, device_json=device_json, state_update=state_update 34 | ) 35 | 36 | @property 37 | def device_state(self) -> Optional[str]: 38 | """Return the current state of the device.""" 39 | return ( 40 | self.device_json["state"].get("lock_state") 41 | if self.device_json.get("state") is not None 42 | else None 43 | ) 44 | 45 | -------------------------------------------------------------------------------- /pymyq/request.py: -------------------------------------------------------------------------------- 1 | """Handle requests to MyQ.""" 2 | import asyncio 3 | from datetime import datetime, timedelta 4 | from json import JSONDecodeError 5 | import logging 6 | from random import choices 7 | import string 8 | from typing import Optional, Tuple 9 | 10 | from aiohttp import ClientResponse, ClientSession 11 | from aiohttp.client_exceptions import ( 12 | ClientError, 13 | ClientOSError, 14 | ClientResponseError, 15 | ServerDisconnectedError, 16 | ) 17 | 18 | from .errors import RequestError 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | REQUEST_METHODS = dict( 23 | json="request_json", text="request_text", response="request_response" 24 | ) 25 | DEFAULT_REQUEST_RETRIES = 5 26 | USER_AGENT_REFRESH = timedelta(hours=1) 27 | 28 | 29 | class MyQRequest: # pylint: disable=too-many-instance-attributes 30 | """Define a class to handle requests to MyQ""" 31 | 32 | def __init__(self, websession: ClientSession = None) -> None: 33 | self._websession = websession or ClientSession() 34 | self._useragent = None 35 | self._last_useragent_update = None 36 | 37 | async def _get_useragent(self) -> None: 38 | """Retrieve a user agent to use in headers.""" 39 | 40 | # Only see to retrieve a user agent if currently we do not have one, 41 | # we do not have an datetime on when we last retrieved one, 42 | # or we're passed the minimum time between requests. 43 | if ( 44 | self._useragent is not None 45 | and self._last_useragent_update is not None 46 | and self._last_useragent_update + USER_AGENT_REFRESH > datetime.utcnow() 47 | ): 48 | _LOGGER.debug( 49 | "Ignoring user agent update request as it is within throttle window" 50 | ) 51 | return 52 | 53 | self._last_useragent_update = datetime.utcnow() 54 | 55 | # Retrieve user agent from GitHub if not provided for login. 56 | _LOGGER.debug("Retrieving user agent from GitHub.") 57 | url = "https://raw.githubusercontent.com/arraylabs/pymyq/master/.USER_AGENT" 58 | 59 | try: 60 | async with ClientSession() as session: 61 | async with session.get(url) as resp: 62 | useragent = await resp.text() 63 | resp.raise_for_status() 64 | _LOGGER.debug("Retrieved user agent %s from GitHub.", useragent) 65 | 66 | except ClientError as exc: 67 | # Default user agent to random string with length of 5 68 | # if failure to retrieve it from GitHub. 69 | useragent = "#RANDOM:5" 70 | _LOGGER.warning( 71 | "Failed retrieving user agent from GitHub, will use randomized user agent " 72 | "instead: %s", 73 | str(exc), 74 | ) 75 | 76 | useragent = useragent.strip() 77 | # Check if value for useragent is to create a random user agent. 78 | useragent_list = useragent.split(":") 79 | if useragent_list[0] == "#RANDOM": 80 | # Create a random string, check if length is provided for the random string, 81 | # if not then default is 5. 82 | try: 83 | randomlength = int(useragent_list[1]) if len(useragent_list) == 2 else 5 84 | except ValueError: 85 | _LOGGER.debug( 86 | "Random length value %s in user agent %s is not an integer. " 87 | "Setting to 5 instead.", 88 | useragent_list[1], 89 | useragent, 90 | ) 91 | randomlength = 5 92 | 93 | # Create the random user agent. 94 | useragent = "".join( 95 | choices(string.ascii_letters + string.digits, k=randomlength) 96 | ) 97 | _LOGGER.debug("User agent set to randomized value: %s.", useragent) 98 | 99 | self._useragent = useragent 100 | 101 | async def _send_request( 102 | self, 103 | method: str, 104 | url: str, 105 | websession: ClientSession, 106 | headers: dict = None, 107 | params: dict = None, 108 | data: dict = None, 109 | json: dict = None, 110 | allow_redirects: bool = False, 111 | ) -> Optional[ClientResponse]: 112 | 113 | attempt = 0 114 | resp = None 115 | resp_exc = None 116 | last_status = "" 117 | last_error = "" 118 | 119 | for attempt in range(DEFAULT_REQUEST_RETRIES): 120 | if self._useragent is not None and self._useragent != "": 121 | headers.update({"User-Agent": self._useragent}) 122 | 123 | if attempt != 0: 124 | wait_for = min(2 ** attempt, 5) 125 | _LOGGER.debug( 126 | 'Request failed with "%s %s" (attempt #%s/%s)"; trying again in %s seconds', 127 | last_status, 128 | last_error, 129 | attempt, 130 | DEFAULT_REQUEST_RETRIES, 131 | wait_for, 132 | ) 133 | await asyncio.sleep(wait_for) 134 | 135 | try: 136 | _LOGGER.debug( 137 | "Sending myq api request %s and headers %s with connection pooling", 138 | url, 139 | headers, 140 | ) 141 | resp = await websession.request( 142 | method, 143 | url, 144 | headers=headers, 145 | params=params, 146 | data=data, 147 | json=json, 148 | skip_auto_headers={"USER-AGENT"}, 149 | allow_redirects=allow_redirects, 150 | raise_for_status=True, 151 | ) 152 | 153 | _LOGGER.debug("Response:") 154 | _LOGGER.debug(" Response Code: %s", resp.status) 155 | _LOGGER.debug(" Headers: %s", resp.raw_headers) 156 | _LOGGER.debug(" Body: %s", await resp.text()) 157 | return resp 158 | except ClientResponseError as err: 159 | _LOGGER.debug( 160 | "Attempt %s request failed with exception : %s - %s", 161 | attempt + 1, 162 | err.status, 163 | err.message, 164 | ) 165 | if err.status == 401: 166 | raise err 167 | 168 | last_status = err.status 169 | last_error = err.message 170 | resp_exc = err 171 | 172 | if err.status == 400 and attempt == 0: 173 | _LOGGER.debug( 174 | "Received error status 400, bad request. Will refresh user agent." 175 | ) 176 | await self._get_useragent() 177 | 178 | except (ClientOSError, ServerDisconnectedError) as err: 179 | errno = getattr(err, "errno", -1) 180 | if errno in (-1, 54, 104) and attempt == 0: 181 | _LOGGER.debug( 182 | "Received error status %s, connection reset. Will refresh user agent.", 183 | errno, 184 | ) 185 | await self._get_useragent() 186 | else: 187 | _LOGGER.debug( 188 | "Attempt %s request failed with exception: %s", 189 | attempt, 190 | str(err), 191 | ) 192 | last_status = "" 193 | last_error = str(err) 194 | resp_exc = err 195 | 196 | except ClientError as err: 197 | _LOGGER.debug( 198 | "Attempt %s request failed with exception: %s", 199 | attempt, 200 | str(err), 201 | ) 202 | last_status = "" 203 | last_error = str(err) 204 | resp_exc = err 205 | 206 | if resp_exc is not None: 207 | raise resp_exc 208 | 209 | return resp 210 | 211 | async def request_json( 212 | self, 213 | method: str, 214 | url: str, 215 | websession: ClientSession = None, 216 | headers: dict = None, 217 | params: dict = None, 218 | data: dict = None, 219 | json: dict = None, 220 | allow_redirects: bool = False, 221 | ) -> Tuple[Optional[ClientResponse], Optional[dict]]: 222 | """Send request and retrieve json response 223 | 224 | Args: 225 | method (str): [description] 226 | url (str): [description] 227 | websession (ClientSession, optional): [description]. Defaults to None. 228 | headers (dict, optional): [description]. Defaults to None. 229 | params (dict, optional): [description]. Defaults to None. 230 | data (dict, optional): [description]. Defaults to None. 231 | json (dict, optional): [description]. Defaults to None. 232 | allow_redirects (bool, optional): [description]. Defaults to False. 233 | 234 | Raises: 235 | RequestError: [description] 236 | 237 | Returns: 238 | Tuple[Optional[ClientResponse], Optional[dict]]: [description] 239 | """ 240 | 241 | websession = websession or self._websession 242 | json_data = None 243 | 244 | resp = await self._send_request( 245 | method=method, 246 | url=url, 247 | headers=headers, 248 | params=params, 249 | data=data, 250 | json=json, 251 | allow_redirects=allow_redirects, 252 | websession=websession, 253 | ) 254 | 255 | if resp is not None: 256 | try: 257 | json_data = await resp.json(content_type=None) 258 | except JSONDecodeError as err: 259 | message = ( 260 | f"JSON Decoder error {err.msg} in response at line {err.lineno}" 261 | f" column {err.colno}. Response received was:\n{err.doc}" 262 | ) 263 | _LOGGER.error(message) 264 | raise RequestError(message) from err 265 | 266 | return resp, json_data 267 | 268 | async def request_text( 269 | self, 270 | method: str, 271 | url: str, 272 | websession: ClientSession = None, 273 | headers: dict = None, 274 | params: dict = None, 275 | data: dict = None, 276 | json: dict = None, 277 | allow_redirects: bool = False, 278 | ) -> Tuple[Optional[ClientResponse], Optional[str]]: 279 | """Send request and retrieve text 280 | 281 | Args: 282 | method (str): [description] 283 | url (str): [description] 284 | websession (ClientSession, optional): [description]. Defaults to None. 285 | headers (dict, optional): [description]. Defaults to None. 286 | params (dict, optional): [description]. Defaults to None. 287 | data (dict, optional): [description]. Defaults to None. 288 | json (dict, optional): [description]. Defaults to None. 289 | allow_redirects (bool, optional): [description]. Defaults to False. 290 | 291 | Returns: 292 | Tuple[Optional[ClientResponse], Optional[str]]: [description] 293 | """ 294 | 295 | websession = websession or self._websession 296 | data_text = None 297 | resp = await self._send_request( 298 | method=method, 299 | url=url, 300 | headers=headers, 301 | params=params, 302 | data=data, 303 | json=json, 304 | allow_redirects=allow_redirects, 305 | websession=websession, 306 | ) 307 | 308 | if resp is not None: 309 | data_text = await resp.text() 310 | 311 | return resp, data_text 312 | 313 | async def request_response( 314 | self, 315 | method: str, 316 | url: str, 317 | websession: ClientSession = None, 318 | headers: dict = None, 319 | params: dict = None, 320 | data: dict = None, 321 | json: dict = None, 322 | allow_redirects: bool = False, 323 | ) -> Tuple[Optional[ClientResponse], None]: 324 | """Send request and just receive the ClientResponse object 325 | 326 | Args: 327 | method (str): [description] 328 | url (str): [description] 329 | websession (ClientSession, optional): [description]. Defaults to None. 330 | headers (dict, optional): [description]. Defaults to None. 331 | params (dict, optional): [description]. Defaults to None. 332 | data (dict, optional): [description]. Defaults to None. 333 | json (dict, optional): [description]. Defaults to None. 334 | allow_redirects (bool, optional): [description]. Defaults to False. 335 | 336 | Returns: 337 | Tuple[Optional[ClientResponse], None]: [description] 338 | """ 339 | 340 | websession = websession or self._websession 341 | 342 | return ( 343 | await self._send_request( 344 | method=method, 345 | url=url, 346 | headers=headers, 347 | params=params, 348 | data=data, 349 | json=json, 350 | allow_redirects=allow_redirects, 351 | websession=websession, 352 | ), 353 | None, 354 | ) 355 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.python.org/simple 2 | aiohttp>=3.7 3 | beautifulsoup4>=4.9.3 4 | pkce>=1.0.2 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pre-commit==2.20.0 3 | black==22.12.0 4 | flake8==6.0.0 5 | isort==5.11.2 6 | pylint>=2.6.0 7 | setuptools>=53.0.0 8 | twine>=3.3.0 9 | wheel>=0.36.2 10 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pytest>=6.2.2 3 | pytest-cov>=2.11.1 4 | pytest-timeout>=1.4.2 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Define publication options.""" 5 | 6 | # Note: To use the 'upload' functionality of this file, you must: 7 | # $ pip install twine 8 | 9 | import io 10 | import os 11 | from shutil import rmtree 12 | import sys 13 | 14 | from setuptools import Command, find_packages, setup # type: ignore 15 | 16 | # Package meta-data. 17 | NAME = "pymyq" 18 | DESCRIPTION = "Python package for controlling MyQ-Enabled Garage Door" 19 | URL = "https://github.com/arraylabs/pymyq" 20 | EMAIL = "chris@arraylabs.com" 21 | AUTHOR = "Chris Campbell" 22 | REQUIRES_PYTHON = ">=3.8" 23 | VERSION = None 24 | 25 | # What packages are required for this module to be executed? 26 | REQUIRED = ["aiohttp", "beautifulsoup4", "pkce"] # type: ignore 27 | 28 | # The rest you shouldn't have to touch too much :) 29 | # ------------------------------------------------ 30 | # Except, perhaps the License and Trove Classifiers! 31 | # If you do change the License, remember to change the Trove Classifier for 32 | # that! 33 | 34 | HERE = os.path.abspath(os.path.dirname(__file__)) 35 | 36 | # Import the README and use it as the long-description. 37 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 38 | with io.open(os.path.join(HERE, "README.md"), encoding="utf-8") as f: 39 | LONG_DESC = "\n" + f.read() 40 | 41 | # Load the package's __version__.py module as a dictionary. 42 | ABOUT = {} # type: ignore 43 | if not VERSION: 44 | with open(os.path.join(HERE, NAME, "__version__.py")) as f: 45 | exec(f.read(), ABOUT) # pylint: disable=exec-used 46 | else: 47 | ABOUT["__version__"] = VERSION 48 | 49 | 50 | class UploadCommand(Command): 51 | """Support setup.py upload.""" 52 | 53 | description = "Build and publish the package." 54 | user_options = [] # type: ignore 55 | 56 | @staticmethod 57 | def status(string): 58 | """Prints things in bold.""" 59 | print("\033[1m{0}\033[0m".format(string)) 60 | 61 | def initialize_options(self): 62 | """Add options for initialization.""" 63 | pass 64 | 65 | def finalize_options(self): 66 | """Add options for finalization.""" 67 | pass 68 | 69 | def run(self): 70 | """Run.""" 71 | try: 72 | self.status("Removing previous builds…") 73 | rmtree(os.path.join(HERE, "dist")) 74 | except OSError: 75 | pass 76 | 77 | self.status("Building Source and Wheel (universal) distribution…") 78 | os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) 79 | 80 | self.status("Uploading the package to PyPi via Twine…") 81 | os.system("twine upload dist/*") 82 | 83 | self.status("Pushing git tags…") 84 | os.system("git tag v{0}".format(ABOUT["__version__"])) 85 | os.system("git push --tags") 86 | 87 | sys.exit() 88 | 89 | 90 | # Where the magic happens: 91 | setup( 92 | name=NAME, 93 | version=ABOUT["__version__"], 94 | description=DESCRIPTION, 95 | long_description=LONG_DESC, 96 | long_description_content_type="text/markdown", 97 | author=AUTHOR, 98 | # author_email=EMAIL, 99 | python_requires=REQUIRES_PYTHON, 100 | url=URL, 101 | packages=find_packages(exclude=("tests",)), 102 | # If your package is a single module, use this instead of 'packages': 103 | # py_modules=['mypackage'], 104 | # entry_points={ 105 | # 'console_scripts': ['mycli=mymodule:cli'], 106 | # }, 107 | install_requires=REQUIRED, 108 | include_package_data=True, 109 | license="MIT", 110 | classifiers=[ 111 | # Trove classifiers 112 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 113 | "License :: OSI Approved :: MIT License", 114 | "Programming Language :: Python", 115 | "Programming Language :: Python :: 3", 116 | "Programming Language :: Python :: 3.8", 117 | "Programming Language :: Python :: 3.9", 118 | "Programming Language :: Python :: Implementation :: CPython", 119 | "Programming Language :: Python :: Implementation :: PyPy", 120 | ], 121 | # $ setup.py publish support. 122 | cmdclass={ 123 | "upload": UploadCommand, 124 | }, 125 | ) 126 | --------------------------------------------------------------------------------