From 21a61abdddae075398432e2d7614c5c40deda8b8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 21 Oct 2018 22:24:39 -0600 Subject: [PATCH 1/6] Initial code in place --- .coveragerc | 6 ++ .gitignore | 3 + Makefile | 14 +++ Pipfile | 15 +++ example.py | 43 ++++++++ pymyq/__init__.py | 247 +------------------------------------------ pymyq/__version__.py | 2 + pymyq/api.py | 110 +++++++++++++++++++ pymyq/device.py | 175 ++++++++++++++++++++++++++++++ pymyq/errors.py | 19 ++++ requirements.txt | 11 +- requirements_dev.txt | 34 ++++++ setup.py | 139 +++++++++++++++++++++--- 13 files changed, 560 insertions(+), 258 deletions(-) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 Pipfile create mode 100644 example.py create mode 100644 pymyq/__version__.py create mode 100644 pymyq/api.py create mode 100644 pymyq/device.py create mode 100644 pymyq/errors.py create mode 100644 requirements_dev.txt diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..6ea7ce7 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = pymyq + +omit = + pymyq/__version__.py + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a912b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.mypy_cache +Pipfile.lock +pymyq.egg-info diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..56b2411 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +init: + pip install pip pipenv + pipenv lock + pipenv install --dev +lint: + pipenv run flake8 pymyq + pipenv run pydocstyle pymyq + pipenv run pylint pymyq +publish: + pipenv run python setup.py sdist bdist_wheel + pipenv run twine upload dist/* + rm -rf dist/ build/ .egg simplisafe_python.egg-info/ +typing: + pipenv run mypy --ignore-missing-imports pymyq diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..09deceb --- /dev/null +++ b/Pipfile @@ -0,0 +1,15 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true + +[dev-packages] +"flake8" = "*" +mypy = "*" +pydocstyle = "*" +pylint = "*" +twine = "*" + +[packages] +aiodns = "*" +aiohttp = "*" +async-timeout = "*" diff --git a/example.py b/example.py new file mode 100644 index 0000000..bfaf6a3 --- /dev/null +++ b/example.py @@ -0,0 +1,43 @@ +"""Run an example script to quickly test any MyQ account.""" +import asyncio + +from aiohttp import ClientSession + +import pymyq +from pymyq.errors import MyQError + + +async def main() -> None: + """Create the aiohttp session and run the example.""" + async with ClientSession() as websession: + try: + myq = await pymyq.login( + 'bachya1208@gmail.com', 'b6Q29b62iAFoBRC8EPYw', 'chamberlain', + websession) + + devices = await myq.get_devices() + for idx, device in enumerate(devices): + if device.type != 'GarageDoorOpener': + continue + + print('Device #{0}: {1}'.format(idx + 1, device.name)) + print('--------') + print('Brand: {0}'.format(device.brand)) + print('Type: {0}'.format(device.type)) + print('Serial: {0}'.format(device.serial)) + print('Device ID: {0}'.format(device.device_id)) + print('Parent ID: {0}'.format(device.parent_id)) + print('Current State: {0}'.format(device.state)) + print() + print('Opening the device...') + await device.open() + print('Current State: {0}'.format(device.state)) + asyncio.sleep(3) + print('Closing the device...') + await device.close() + print('Current State: {0}'.format(device.state)) + except MyQError as err: + print(err) + + +asyncio.get_event_loop().run_until_complete(main()) diff --git a/pymyq/__init__.py b/pymyq/__init__.py index 1b0a6c1..4c9aaef 100644 --- a/pymyq/__init__.py +++ b/pymyq/__init__.py @@ -1,245 +1,2 @@ -import requests -import logging -from time import sleep - - -class MyQAPI: - """Class for interacting with the MyQ iOS App API.""" - - LIFTMASTER = 'liftmaster' - CHAMBERLAIN = 'chamberlain' - CRAFTSMAN = 'craftsman' - MERLIN = 'merlin' - - SUPPORTED_BRANDS = [LIFTMASTER, CHAMBERLAIN, CRAFTSMAN, MERLIN] - SUPPORTED_DEVICE_TYPE_NAMES = ['GarageDoorOpener', 'Garage Door Opener WGDO', 'VGDO', 'Gate'] - - APP_ID = 'app_id' - HOST_URI = 'myqexternal.myqdevice.com' - - BRAND_MAPPINGS = { - LIFTMASTER: { - APP_ID: 'Vj8pQggXLhLy0WHahglCD4N1nAkkXQtGYpq2HrHD7H1nvmbT55KqtN6RSF4ILB/i' - }, - CHAMBERLAIN: { - APP_ID: 'OA9I/hgmPHFp9RYKJqCKfwnhh28uqLJzZ9KOJf1DXoo8N2XAaVX6A1wcLYyWsnnv' - }, - CRAFTSMAN: { - APP_ID: 'YmiMRRS1juXdSd0KWsuKtHmQvh5RftEp5iewHdCvsNB77FnQbY+vjCVn2nMdIeN8' - }, - MERLIN: { - APP_ID: '3004cac4e920426c823fa6c2ecf0cc28ef7d4a7b74b6470f8f0d94d6c39eb718' - } - } - - STATE_OPEN = 'open' - STATE_CLOSED = 'closed' - STATE_STOPPED = 'stopped' - STATE_OPENING = 'opening' - STATE_CLOSING = 'closing' - STATE_UNKNOWN = 'unknown' - STATE_TRANSITION = 'transition' - - LOGIN_ENDPOINT = "api/v4/User/Validate" - DEVICE_LIST_ENDPOINT = "api/v4/UserDeviceDetails/Get" - DEVICE_SET_ENDPOINT = "api/v4/DeviceAttribute/PutDeviceAttribute" - DEVICE_ATTRIBUTE_GET_ENDPOINT = "api/v4/DeviceAttribute/getDeviceAttribute" - USERAGENT = "Chamberlain/3773 (iPhone; iOS 11.0.3; Scale/2.00)" - - REQUEST_TIMEOUT = 3.0 - - DOOR_STATE = { - '1': STATE_OPEN, - '2': STATE_CLOSED, - '3': STATE_STOPPED, - '4': STATE_OPENING, - '5': STATE_CLOSING, - '6': STATE_UNKNOWN, - '7': STATE_UNKNOWN, - '8': STATE_TRANSITION, - '9': STATE_OPEN, - '0': STATE_UNKNOWN - } - - logger = logging.getLogger(__name__) - - def __init__(self, username, password, brand): - """Initialize the API object.""" - self.username = username - self.password = password - self.brand = brand - self.security_token = None - self._logged_in = False - self._valid_brand = False - - def is_supported_brand(self): - try: - brand = self.BRAND_MAPPINGS[self.brand]; - except KeyError: - return False - - return True - - def is_login_valid(self): - """Log in to the MyQ service.""" - params = { - 'username': self.username, - 'password': self.password - } - - try: - login = requests.post( - 'https://{host_uri}/{login_endpoint}'.format( - host_uri=self.HOST_URI, - login_endpoint=self.LOGIN_ENDPOINT), - json=params, - headers={ - 'MyQApplicationId': self.BRAND_MAPPINGS[self.brand][self.APP_ID], - 'User-Agent': self.USERAGENT - }, - timeout=self.REQUEST_TIMEOUT - ) - - login.raise_for_status() - except requests.exceptions.HTTPError as ex: - self.logger.error("MyQ - API Error[is_login_valid] %s", ex) - return False - - try: - self.security_token = login.json()['SecurityToken'] - except KeyError: - return False - - return True - - def get_devices(self): - """List all MyQ devices.""" - if not self._logged_in: - self._logged_in = self.is_login_valid() - - try: - devices = requests.get( - 'https://{host_uri}/{device_list_endpoint}'.format( - host_uri=self.HOST_URI, - device_list_endpoint=self.DEVICE_LIST_ENDPOINT), - headers={ - 'MyQApplicationId': self.BRAND_MAPPINGS[self.brand][self.APP_ID], - 'SecurityToken': self.security_token, - 'User-Agent': self.USERAGENT - } - ) - - devices.raise_for_status() - - except requests.exceptions.HTTPError as ex: - self.logger.error("MyQ - API Error[get_devices] %s", ex) - return False - - try: - devices = devices.json()['Devices'] - return devices - except KeyError: - self.logger.error("MyQ - Login security token may have expired, will attempt relogin on next update") - self._logged_in = False - - - def get_garage_doors(self): - """List only MyQ garage door devices.""" - devices = self.get_devices() - - if devices != False: - garage_doors = [] - - try: - for device in devices: - if device['MyQDeviceTypeName'] in self.SUPPORTED_DEVICE_TYPE_NAMES: - dev = {} - for attribute in device['Attributes']: - if attribute['AttributeDisplayName'] == 'desc': - dev['deviceid'] = device['MyQDeviceId'] - dev['name'] = attribute['Value'] - garage_doors.append(dev) - - return garage_doors - except TypeError: - return False - else: - return False; - - def get_status(self, device_id): - """Get only door states""" - - if not self._logged_in: - self._logged_in = self.is_login_valid() - - garage_state = False - - get_status_attempt = 0 - for get_status_attempt in range(0, 2): - try: - doorstate = requests.get( - 'https://{host_uri}/{device_attribute_get_endpoint}'.format( - host_uri=self.HOST_URI, - device_attribute_get_endpoint=self.DEVICE_ATTRIBUTE_GET_ENDPOINT), - headers={ - 'MyQApplicationId': self.BRAND_MAPPINGS[self.brand][self.APP_ID], - 'SecurityToken': self.security_token - }, - params={ - 'AttributeName': 'doorstate', - 'MyQDeviceId': device_id - } - ) - - doorstate.raise_for_status() - break - - except requests.exceptions.HTTPError as ex: - get_status_attempt = get_status_attempt + 1 - sleep(5) - - else: - self.logger.error("MyQ - API Error[get_status] - Failed to get return from API after 3 attempts.") - return False - - doorstate = doorstate.json()['AttributeValue'] - - garage_state = self.DOOR_STATE[doorstate] - - return garage_state - - def close_device(self, device_id): - """Close MyQ Device.""" - return self.set_state(device_id, '0') - - def open_device(self, device_id): - """Open MyQ Device.""" - return self.set_state(device_id, '1') - - def set_state(self, device_id, state): - """Set device state.""" - payload = { - 'attributeName': 'desireddoorstate', - 'myQDeviceId': device_id, - 'AttributeValue': state, - } - - try: - device_action = requests.put( - 'https://{host_uri}/{device_set_endpoint}'.format( - host_uri=self.HOST_URI, - device_set_endpoint=self.DEVICE_SET_ENDPOINT), - data=payload, - headers={ - 'MyQApplicationId': self.BRAND_MAPPINGS[self.brand][self.APP_ID], - 'SecurityToken': self.security_token, - 'User-Agent': self.USERAGENT - } - ) - - device_action.raise_for_status() - except (NameError, requests.exceptions.HTTPError) as ex: - self.logger.error("MyQ - API Error[set_state] %s", ex) - return False - - return device_action.status_code == 200 +"""Define module-level imports.""" +from .api import login # noqa diff --git a/pymyq/__version__.py b/pymyq/__version__.py new file mode 100644 index 0000000..ff814b7 --- /dev/null +++ b/pymyq/__version__.py @@ -0,0 +1,2 @@ +"""Define a version constant.""" +__version__ = '0.0.16' diff --git a/pymyq/api.py b/pymyq/api.py new file mode 100644 index 0000000..9af834e --- /dev/null +++ b/pymyq/api.py @@ -0,0 +1,110 @@ +"""Define the MyQ API.""" +import logging + +from aiohttp import BasicAuth, ClientSession +from aiohttp.client_exceptions import ClientError + +from .device import MyQDevice +from .errors import RequestError, UnsupportedBrandError + +_LOGGER = logging.getLogger(__name__) + +API_BASE = 'https://myqexternal.myqdevice.com' +LOGIN_ENDPOINT = "api/v4/User/Validate" +DEVICE_LIST_ENDPOINT = "api/v4/UserDeviceDetails/Get" + +DEFAULT_TIMEOUT = 10 +DEFAULT_USER_AGENT = "Chamberlain/3773 (iPhone; iOS 11.0.3; Scale/2.00)" + +BRAND_MAPPINGS = { + 'liftmaster': { + 'app_id': + 'Vj8pQggXLhLy0WHahglCD4N1nAkkXQtGYpq2HrHD7H1nvmbT55KqtN6RSF4ILB/i' + }, + 'chamberlain': { + 'app_id': + 'OA9I/hgmPHFp9RYKJqCKfwnhh28uqLJzZ9KOJf1DXoo8N2XAaVX6A1wcLYyWsnnv' + }, + 'craftsman': { + 'app_id': + 'YmiMRRS1juXdSd0KWsuKtHmQvh5RftEp5iewHdCvsNB77FnQbY+vjCVn2nMdIeN8' + }, + 'merlin': { + 'app_id': + '3004cac4e920426c823fa6c2ecf0cc28ef7d4a7b74b6470f8f0d94d6c39eb718' + } +} + + +class API: + """Define a class for interacting with the MyQ iOS App API.""" + + def __init__(self, brand: str, websession: ClientSession) -> None: + """Initialize the API object.""" + if brand not in BRAND_MAPPINGS: + raise UnsupportedBrandError('Unknown brand: {0}'.format(brand)) + + self._brand = brand + self._security_token = None + self._websession = websession + + async def _request( + self, + method: str, + endpoint: str, + *, + headers: dict = None, + params: dict = None, + data: dict = None, + json: dict = None, + **kwargs) -> dict: + """Make a request.""" + url = '{0}/{1}'.format(API_BASE, endpoint) + + if not headers: + headers = {} + if self._security_token: + headers['SecurityToken'] = self._security_token + headers.update({ + 'MyQApplicationId': BRAND_MAPPINGS[self._brand]['app_id'], + 'User-Agent': DEFAULT_USER_AGENT, + }) + + try: + async with self._websession.request( + method, url, headers=headers, params=params, data=data, + json=json, timeout=DEFAULT_TIMEOUT, **kwargs) as resp: + resp.raise_for_status() + return await resp.json(content_type=None) + except ClientError as err: + raise RequestError( + 'Error requesting data from {0}: {1}'.format(endpoint, err)) + + async def authenticate(self, username: str, password: str) -> None: + """Authenticate against the API.""" + login_resp = await self._request( + 'post', + LOGIN_ENDPOINT, + json={ + 'username': username, + 'password': password + }) + + self._security_token = login_resp['SecurityToken'] + + async def get_devices(self) -> list: + """Get a list of all devices associated with the account.""" + devices_resp = await self._request('get', DEVICE_LIST_ENDPOINT) + return [ + MyQDevice(item, self._brand, self._request) + for item in devices_resp['Devices'] + ] + + +async def login( + username: str, password: str, brand: str, + websession: ClientSession) -> API: + """Log in to the API.""" + api = API(brand, websession) + await api.authenticate(username, password) + return api diff --git a/pymyq/device.py b/pymyq/device.py new file mode 100644 index 0000000..29ef92f --- /dev/null +++ b/pymyq/device.py @@ -0,0 +1,175 @@ +"""Define a generic MyQ device.""" +import asyncio +import logging +from typing import Callable, Union + +from .errors import RequestError + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_UPDATE_RETRIES = 3 + +DEVICE_ATTRIBUTE_GET_ENDPOINT = "api/v4/DeviceAttribute/getDeviceAttribute" +DEVICE_SET_ENDPOINT = "api/v4/DeviceAttribute/PutDeviceAttribute" + +STATE_OPEN = 'open' +STATE_CLOSED = 'closed' +STATE_STOPPED = 'stopped' +STATE_OPENING = 'opening' +STATE_CLOSING = 'closing' +STATE_UNKNOWN = 'unknown' +STATE_TRANSITION = 'transition' + +STATE_MAP = { + 1: STATE_OPEN, + 2: STATE_CLOSED, + 3: STATE_STOPPED, + 4: STATE_OPENING, + 5: STATE_CLOSING, + 6: STATE_UNKNOWN, + 7: STATE_UNKNOWN, + 8: STATE_TRANSITION, + 9: STATE_OPEN, + 0: STATE_UNKNOWN +} + +SUPPORTED_DEVICE_TYPE_NAMES = [ + 'Garage Door Opener WGDO', + 'GarageDoorOpener', + 'Gate', + 'Gateway', + 'VGDO', +] + + +class MyQDevice: + """Define a generic MyQ device.""" + + def __init__( + self, device_json: dict, brand: str, request: Callable) -> None: + """Initialize.""" + self._brand = brand + self._device_json = device_json + self._request = request + + self._device_type = device_json['MyQDeviceTypeName'] + if self._device_type not in SUPPORTED_DEVICE_TYPE_NAMES: + _LOGGER.warning('Unknown device type: %s', self._device_type) + + try: + raw_state = next( + attr['Value'] for attr in device_json['Attributes'] + if attr['AttributeDisplayName'] == 'doorstate') + + self._state = self._coerce_state_from_string(raw_state) + except StopIteration: + self._state = STATE_UNKNOWN + + @property + def brand(self) -> str: + """Return the brand of this device.""" + return self._brand + + @property + def device_id(self) -> int: + """Return the device ID.""" + return self._device_json['MyQDeviceId'] + + @property + def parent_id(self) -> Union[None, int]: + """Return the ID of the parent device (if it exists).""" + return self._device_json.get('ParentMyQDeviceId') + + @property + def name(self) -> str: + """Return the device name.""" + return next( + attr['Value'] for attr in self._device_json['Attributes'] + if attr['AttributeDisplayName'] == 'desc') + + @property + def serial(self) -> str: + """Return the device serial number.""" + return self._device_json['SerialNumber'] + + @property + def state(self) -> str: + """Return the current state of the device (if it exists).""" + return self._state + + @property + def type(self) -> str: + """Return the device type.""" + return self._device_type + + @staticmethod + def _coerce_state_from_string(value: Union[int, str]) -> str: + """Return a proper state from a string input.""" + try: + return STATE_MAP[int(value)] + except KeyError: + _LOGGER.error('Unknown state: %s', value) + return STATE_UNKNOWN + + async def _set_state(self, state: int) -> None: + """Set the state of the device.""" + set_state_resp = await self._request( + 'put', + DEVICE_SET_ENDPOINT, + json={ + 'attributeName': 'desireddoorstate', + 'myQDeviceId': self.device_id, + 'AttributeValue': state, + }) + + if int(set_state_resp['ReturnCode']) != 0: + _LOGGER.error( + 'There was an error while setting the device state: %s', + set_state_resp['ErrorMessage']) + return + + # MyQ devices can sometimes take a moment to get started; for instance, + # garage doors will beep for a bit (to warn anyone nearby) before thea + # actual closing begins. Once we request a state change, wait for a bit + # before re-querying the status: + await asyncio.sleep(4) + + await self.update() + + async def close(self) -> None: + """Close the device.""" + return await self._set_state(0) + + async def open(self) -> None: + """Open the device.""" + return await self._set_state(1) + + async def update(self) -> None: + """Update the device info from the MyQ cloud.""" + for attempt in range(0, DEFAULT_UPDATE_RETRIES - 1): + try: + update_resp = await self._request( + 'get', + DEVICE_ATTRIBUTE_GET_ENDPOINT, + params={ + 'AttributeName': 'doorstate', + 'MyQDeviceId': self.device_id + }) + + break + except RequestError as err: + if attempt == DEFAULT_UPDATE_RETRIES - 1: + _LOGGER.error('Update failed (and halting): %s', err) + return + + _LOGGER.error('Update failed; retrying') + await asyncio.sleep(4) + + if int(update_resp['ReturnCode']) != 0: + _LOGGER.error( + 'There was an error while updating: %s', + update_resp['ErrorMessage']) + return + + self._state = self._coerce_state_from_string( + update_resp['AttributeValue']) diff --git a/pymyq/errors.py b/pymyq/errors.py new file mode 100644 index 0000000..8517ec2 --- /dev/null +++ b/pymyq/errors.py @@ -0,0 +1,19 @@ +"""Define exceptions.""" + + +class MyQError(Exception): + """Define a base exception.""" + + pass + + +class RequestError(MyQError): + """Define an exception related to bad HTTP requests.""" + + pass + + +class UnsupportedBrandError(MyQError): + """Define an exception related to unsupported brands.""" + + pass diff --git a/requirements.txt b/requirements.txt index f229360..2becfdb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,10 @@ -requests +-i https://pypi.python.org/simple +aiodns==1.1.1 +aiohttp==3.4.4 +async-timeout==3.0.1 +attrs==18.2.0 +chardet==3.0.4 +idna==2.7 +multidict==4.4.2 +pycares==2.3.0 +yarl==1.2.6 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..891b3a3 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,34 @@ +-i https://pypi.python.org/simple +astroid==2.0.4 +bleach==3.0.2 +certifi==2018.10.15 +cffi==1.11.5 +chardet==3.0.4 +cmarkgfm==0.4.2 +docutils==0.14 +flake8==3.5.0 +future==0.16.0 +idna==2.7 +isort==4.3.4 +lazy-object-proxy==1.3.1 +mccabe==0.6.1 +mypy-extensions==0.4.1 +mypy==0.641 +pkginfo==1.4.2 +pycodestyle==2.3.1 +pycparser==2.19 +pydocstyle==3.0.0 +pyflakes==1.6.0 +pygments==2.2.0 +pylint==2.1.1 +readme-renderer==22.0 +requests-toolbelt==0.8.0 +requests==2.20.0 +six==1.11.0 +snowballstemmer==1.2.1 +tqdm==4.28.1 +twine==1.12.1 +typed-ast==1.1.0 +urllib3==1.24 +webencodings==0.5.1 +wrapt==1.10.11 diff --git a/setup.py b/setup.py index a605b0c..f5fe346 100644 --- a/setup.py +++ b/setup.py @@ -1,17 +1,132 @@ -from setuptools import setup +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Define publication options.""" -with open('LICENSE') as f: - license = f.read() +# Note: To use the 'upload' functionality of this file, you must: +# $ pip install twine +import io +import os +import sys +from shutil import rmtree + +from setuptools import find_packages, setup, Command # 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.5.3' +VERSION = None + +# What packages are required for this module to be executed? +REQUIRED = [ # type: ignore + 'aiodns', + 'aiohttp', + 'async-timeout', +] + +# The rest you shouldn't have to touch too much :) +# ------------------------------------------------ +# Except, perhaps the License and Trove Classifiers! +# If you do change the License, remember to change the Trove Classifier for +# that! + +HERE = os.path.abspath(os.path.dirname(__file__)) + +# 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() + +# Load the package's __version__.py module as a dictionary. +ABOUT = {} # type: ignore +if not VERSION: + with open(os.path.join(HERE, '__version__.py')) as f: + exec(f.read(), ABOUT) # pylint: disable=exec-used +else: + ABOUT['__version__'] = VERSION + + +class UploadCommand(Command): + """Support setup.py upload.""" + + 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)) + + def initialize_options(self): + """Add options for initialization.""" + pass + + def finalize_options(self): + """Add options for finalization.""" + pass + + def run(self): + """Run.""" + try: + 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('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') + + sys.exit() + + +# Where the magic happens: setup( - name='pymyq', - version='0.0.16', - description='Python package for controlling MyQ-Enabled Garage Door', - author='Chris Campbell', - author_email='chris@arraylabs.com', - url='https://github.com/arraylabs/pymyq', - license=license, - packages=['pymyq'], - package_dir={'pymyq': 'pymyq'} + name=NAME, + version=ABOUT['__version__'], + description=DESCRIPTION, + long_description=LONG_DESC, + long_description_content_type='text/markdown', + author=AUTHOR, + # author_email=EMAIL, + python_requires=REQUIRES_PYTHON, + url=URL, + 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', + 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.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy' + ], + # $ setup.py publish support. + cmdclass={ + 'upload': UploadCommand, + }, ) From 2ad86be1b787e5b0dc6516ef8d24b4f4d0fbd602 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 Oct 2018 19:13:35 -0600 Subject: [PATCH 2/6] Final code in place --- example.py | 5 +---- pymyq/__version__.py | 2 +- pymyq/api.py | 19 +++++++++++++++---- pymyq/device.py | 22 +--------------------- setup.py | 2 +- 5 files changed, 19 insertions(+), 31 deletions(-) diff --git a/example.py b/example.py index bfaf6a3..37e9885 100644 --- a/example.py +++ b/example.py @@ -17,9 +17,6 @@ async def main() -> None: devices = await myq.get_devices() for idx, device in enumerate(devices): - if device.type != 'GarageDoorOpener': - continue - print('Device #{0}: {1}'.format(idx + 1, device.name)) print('--------') print('Brand: {0}'.format(device.brand)) @@ -32,7 +29,7 @@ async def main() -> None: print('Opening the device...') await device.open() print('Current State: {0}'.format(device.state)) - asyncio.sleep(3) + await asyncio.sleep(15) print('Closing the device...') await device.close() print('Current State: {0}'.format(device.state)) diff --git a/pymyq/__version__.py b/pymyq/__version__.py index ff814b7..b1ead0d 100644 --- a/pymyq/__version__.py +++ b/pymyq/__version__.py @@ -1,2 +1,2 @@ """Define a version constant.""" -__version__ = '0.0.16' +__version__ = '1.0.0' diff --git a/pymyq/api.py b/pymyq/api.py index 9af834e..b7b5033 100644 --- a/pymyq/api.py +++ b/pymyq/api.py @@ -5,7 +5,7 @@ from aiohttp.client_exceptions import ClientError from .device import MyQDevice -from .errors import RequestError, UnsupportedBrandError +from .errors import MyQError, RequestError, UnsupportedBrandError _LOGGER = logging.getLogger(__name__) @@ -35,6 +35,13 @@ } } +SUPPORTED_DEVICE_TYPE_NAMES = [ + 'Garage Door Opener WGDO', + 'GarageDoorOpener', + 'Gate', + 'VGDO', +] + class API: """Define a class for interacting with the MyQ iOS App API.""" @@ -90,14 +97,18 @@ async def authenticate(self, username: str, password: str) -> None: 'password': password }) + if int(login_resp['ReturnCode']) != 0: + raise MyQError(login_resp['ErrorMessage']) + self._security_token = login_resp['SecurityToken'] - async def get_devices(self) -> list: + async def get_devices(self, covers_only: bool = True) -> list: """Get a list of all devices associated with the account.""" devices_resp = await self._request('get', DEVICE_LIST_ENDPOINT) return [ - MyQDevice(item, self._brand, self._request) - for item in devices_resp['Devices'] + MyQDevice(device, self._brand, self._request) + for device in devices_resp['Devices'] if not covers_only + or device['MyQDeviceTypeName'] in SUPPORTED_DEVICE_TYPE_NAMES ] diff --git a/pymyq/device.py b/pymyq/device.py index 29ef92f..1e1fed6 100644 --- a/pymyq/device.py +++ b/pymyq/device.py @@ -33,14 +33,6 @@ 0: STATE_UNKNOWN } -SUPPORTED_DEVICE_TYPE_NAMES = [ - 'Garage Door Opener WGDO', - 'GarageDoorOpener', - 'Gate', - 'Gateway', - 'VGDO', -] - class MyQDevice: """Define a generic MyQ device.""" @@ -52,10 +44,6 @@ def __init__( self._device_json = device_json self._request = request - self._device_type = device_json['MyQDeviceTypeName'] - if self._device_type not in SUPPORTED_DEVICE_TYPE_NAMES: - _LOGGER.warning('Unknown device type: %s', self._device_type) - try: raw_state = next( attr['Value'] for attr in device_json['Attributes'] @@ -100,7 +88,7 @@ def state(self) -> str: @property def type(self) -> str: """Return the device type.""" - return self._device_type + return self._device_json['MyQDeviceTypeName'] @staticmethod def _coerce_state_from_string(value: Union[int, str]) -> str: @@ -128,14 +116,6 @@ async def _set_state(self, state: int) -> None: set_state_resp['ErrorMessage']) return - # MyQ devices can sometimes take a moment to get started; for instance, - # garage doors will beep for a bit (to warn anyone nearby) before thea - # actual closing begins. Once we request a state change, wait for a bit - # before re-querying the status: - await asyncio.sleep(4) - - await self.update() - async def close(self) -> None: """Close the device.""" return await self._set_state(0) diff --git a/setup.py b/setup.py index f5fe346..bf71a95 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ # Load the package's __version__.py module as a dictionary. ABOUT = {} # type: ignore if not VERSION: - with open(os.path.join(HERE, '__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 From 2dcc8e7828553fdad57b05d2dd838b16cb2e7d78 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 Oct 2018 19:29:27 -0600 Subject: [PATCH 3/6] Updated README.md --- README.md | 92 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 49c1999..3bb1ea9 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,86 @@ # Introduction -This is a python module aiming to interact with the Chamberlain MyQ API. +This is a Python 3.5+ module aiming to interact with the Chamberlain MyQ API. Code is licensed under the MIT license. -Getting Started -=============== +# Getting Started -# Usage +## Installation ```python -from pymyq import MyQAPI as pymyq +pip install pymyq +``` + +## Usage + +`pymyq` starts within an [aiohttp](https://aiohttp.readthedocs.io/en/stable/) +`ClientSession`: + +```python +import asyncio + +from aiohttp import ClientSession + + +async def main() -> None: + """Create the aiohttp session and run.""" + async with ClientSession() as websession: + # YOUR CODE HERE + -myq = pymyq(username, password, brand) +asyncio.get_event_loop().run_until_complete(main()) ``` -# Methods +To get all MyQ devices associated with an account: -def is_supported_brand(self): -"""Return true/false based on supported brands list and input.""" +```python +import asyncio + +from aiohttp import ClientSession + +import pymyq + + +async def main() -> None: + """Create the aiohttp session and run.""" + async with ClientSession() as websession: + # Valid Brands: 'chamberlain', 'craftsman', 'liftmaster', 'merlin' + myq = await pymyq.login('', '', '', websession) -def is_login_valid(self): -"""Return true/false based on successful authentication.""" + # Return only cover devices: + devices = await myq.get_devices() + + # Return *all* devices: + devices = await myq.get_devices(covers_only=False) + + +asyncio.get_event_loop().run_until_complete(main()) +``` -def get_devices(self): -"""Return devices from API""" +## Device Properties -def get_garage_doors(self): -"""Parse devices data and extract garage doors. Return garage doors.""" - -def get_status(self, device_id): -"""Return current door status(open/closed)""" +* `brand`: the brand of the device +* `device_id`: the device's MyQ ID +* `parent_id`: the device's parent device's MyQ ID +* `name`: the name of the device +* `serial`: the serial number of the device +* `state`: the device's current state +* `type`: the type of MyQ device -def close_device(self, device_id): -"""Send request to close the door.""" +## Methods -def open_device(self, device_id): -"""Send request to open the door.""" +All of the routines on the `MyQDevice` class are coroutines and need to be +`await`ed. -def set_state(self, device_id, state): -"""Send request for request door state change.""" +* `close`: close the device +* `open`: open the device +* `update`: get the latest device state (which can then be accessed via the +`state` property) -### Disclaimer +# Disclaimer -The code here is based off of an unsupported API from [Chamberlain](http://www.chamberlain.com/) and is subject to change without notice. The authors claim no responsibility for damages to your garage door or property by use of the code within. +The code here is based off of an unsupported API from +[Chamberlain](http://www.chamberlain.com/) and is subject to change without +notice. The authors claim no responsibility for damages to your garage door or +property by use of the code within. From 881b71954bbf1cfb3d8573911349b25399e6a480 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 Oct 2018 19:36:16 -0600 Subject: [PATCH 4/6] Remove unnecessary file --- .coveragerc | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 6ea7ce7..0000000 --- a/.coveragerc +++ /dev/null @@ -1,6 +0,0 @@ -[run] -source = pymyq - -omit = - pymyq/__version__.py - From 04e60a20c34687667b8fea0ab3e696d39f6ec1cd Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 Oct 2018 19:50:02 -0600 Subject: [PATCH 5/6] Updated example --- example.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example.py b/example.py index 37e9885..f70488d 100644 --- a/example.py +++ b/example.py @@ -12,8 +12,7 @@ async def main() -> None: async with ClientSession() as websession: try: myq = await pymyq.login( - 'bachya1208@gmail.com', 'b6Q29b62iAFoBRC8EPYw', 'chamberlain', - websession) + '', '', '', websession) devices = await myq.get_devices() for idx, device in enumerate(devices): From 58ed597a1f1b158d3f9870d242f5c2243405b518 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 23 Oct 2018 18:20:38 -0600 Subject: [PATCH 6/6] Added retries for open & close Added retries for open and close as I've seen this fail as well. Changed so that update, open, and close return True if successful or False if unsuccessful. --- pymyq/device.py | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/pymyq/device.py b/pymyq/device.py index 1e1fed6..8b7f81f 100644 --- a/pymyq/device.py +++ b/pymyq/device.py @@ -99,22 +99,36 @@ def _coerce_state_from_string(value: Union[int, str]) -> str: _LOGGER.error('Unknown state: %s', value) return STATE_UNKNOWN - async def _set_state(self, state: int) -> None: + async def _set_state(self, state: int) -> bool: """Set the state of the device.""" - set_state_resp = await self._request( - 'put', - DEVICE_SET_ENDPOINT, - json={ - 'attributeName': 'desireddoorstate', - 'myQDeviceId': self.device_id, - 'AttributeValue': state, - }) + for attempt in range(0, DEFAULT_UPDATE_RETRIES - 1): + try: + set_state_resp = await self._request( + 'put', + DEVICE_SET_ENDPOINT, + json={ + 'attributeName': 'desireddoorstate', + 'myQDeviceId': self.device_id, + 'AttributeValue': state, + }) + + break + except RequestError as err: + if attempt == DEFAULT_UPDATE_RETRIES - 1: + _LOGGER.error('Setting state failed (and halting): %s', + err) + return False + + _LOGGER.error('Setting state failed; retrying') + await asyncio.sleep(4) if int(set_state_resp['ReturnCode']) != 0: _LOGGER.error( 'There was an error while setting the device state: %s', set_state_resp['ErrorMessage']) - return + return False + + return True async def close(self) -> None: """Close the device.""" @@ -124,7 +138,7 @@ async def open(self) -> None: """Open the device.""" return await self._set_state(1) - async def update(self) -> None: + async def update(self) -> bool: """Update the device info from the MyQ cloud.""" for attempt in range(0, DEFAULT_UPDATE_RETRIES - 1): try: @@ -140,7 +154,7 @@ async def update(self) -> None: except RequestError as err: if attempt == DEFAULT_UPDATE_RETRIES - 1: _LOGGER.error('Update failed (and halting): %s', err) - return + return False _LOGGER.error('Update failed; retrying') await asyncio.sleep(4) @@ -149,7 +163,9 @@ async def update(self) -> None: _LOGGER.error( 'There was an error while updating: %s', update_resp['ErrorMessage']) - return + return False self._state = self._coerce_state_from_string( update_resp['AttributeValue']) + + return True