diff --git a/README.md b/README.md index ab05e26..256a9a8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Hydrawise official site: https://hydrawise.com
Source code documentation: https://hydrawiser.readthedocs.io
Python Package Index: https://pypi.org/project/Hydrawiser/ -## Usage +## Usage API v1 ```python from hydrawiser.core import Hydrawiser @@ -74,7 +74,30 @@ True hw.time_remaining(3) 247 ``` +### Limitations -## Limitations + - Only one controller is supported + - No sensor data + + +## Hydrawise GraphQL API (v2) + +```python + +from hydrawiser.core_v2 import HydrawiserV2 + +hw = HydrawiserV2('', '') + +# Information about your account +hw.customer() + +# List all controllers +hw.controllers() + +# List all controllers and their sensors +hw.sensors() + +# List all controllers and their zones +hw.zones() +``` -* Only one controller is supported diff --git a/hydrawiser/__init__.py b/hydrawiser/__init__.py index c93873e..08edf25 100644 --- a/hydrawiser/__init__.py +++ b/hydrawiser/__init__.py @@ -6,6 +6,6 @@ """ __author__ = 'David Ryan' -__version__ = '0.2' +__version__ = '0.3.0' __copyright__ = 'Copyright (c) 2020 David Ryan' __license__ = 'MIT' diff --git a/hydrawiser/core_v2.py b/hydrawiser/core_v2.py new file mode 100644 index 0000000..a38589a --- /dev/null +++ b/hydrawiser/core_v2.py @@ -0,0 +1,217 @@ +""" +A GraphQL (API v2) implementation +""" + +from gql import Client, gql +from gql.transport.requests import RequestsHTTPTransport +import requests + + +class HydrawiserV2(): + """ + :param email: E-mail used during Hydrawise account registration + :type email: string + :param password: Hydrawise account password + :type password: string + :param timeout: Requests timeout in seconds + :type timeout: int + :returns: Hydrawiser object. + :rtype: object + """ + CLIENT_ID = 'hydrawise_app' + CLIENT_SECRET = 'zn3CrjglwNV1' + TOKEN_ENDPOINT = 'https://app.hydrawise.com/api/v2/oauth/access-token' + API_ENDPOINT = 'https://app.hydrawise.com/api/v2/graph' + + def __init__(self, email, password, timeout=10): + self.timeout = timeout + self._token = self.__fetch_token(email, password) + self._client = self.__client(self._token) + + def __fetch_token(self, username, password): + """ + Retrieves OAuth2 token and refresh token + + :param username: E-mail used during Hydrawise account registration + :type username: string + :param password: Hydrawise account password + :type password: string + + :returns: JSON object containing access_token, refresh_token + :rtype: JSON + """ + + payload = { + 'grant_type': 'password', + 'client_id': self.CLIENT_ID, + 'client_secret': self.CLIENT_SECRET, + 'username': username, + 'password': password, + 'scope': 'all', + } + + resp = requests.post( + self.TOKEN_ENDPOINT, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=payload, + timeout=self.timeout + ) + + if resp.status_code != 200: + print(resp.json()) + + return resp.json() + + def __client(self, token): + """ + Create gql client for communication with remote GraphQL API. + + :returns: GraphQL Client object + :rtype: gql.Client + """ + + headers = { + 'Authorization': token['token_type'] + ' ' + token['access_token'] + } + + _transport = RequestsHTTPTransport( + url=self.API_ENDPOINT, + headers=headers, + use_json=True, + verify=True, + timeout=self.timeout, + retries=3, + ) + + return Client( + transport=_transport, + fetch_schema_from_transport=True, + ) + + def customer(self): + """ + Fetches basic information about current user including all registered + controllers. + + :returns: Information about current user + :rtype: dict + """ + + query = gql("""{ + me { + id + customerId + name + email + customerId + validated + controllers { + id + name + deviceId + } + } + } + """) + result = self._client.execute(query) + return result['me'] + + def controllers(self): + """ + List all registered controllers, their IDs, version and status. + + :returns: Controllers information + :rtype: array + """ + + query = gql("""{ + me { + controllers { + id + name + online + deviceId + wizardComplete + hardware { + serialNumber + version + status + installationDate + } + softwareVersion + boc + lastAction { + value + timestamp + } + } + } + } + """) + result = self._client.execute(query) + return result['me']['controllers'] + + def zones(self, controller_id=None): + """ + List all zones and its controllers, their id and names. + + :returns: Available zones + :rtype: array + """ + + query = gql("""{ + me { + controllers { + id + name + zones { + id + name + } + } + } + } + """) + result = self._client.execute(query) + return result['me']['controllers'] + + def sensors(self, controller_id=None): + """ + List all sensors connected to a controller, their id and status + information. + + :returns: Sensors list and state. + :rtype: array + """ + + query = gql("""{ + me { + controllers { + id + name + sensors { + id + name + model { + id + name + modeType + active + offLevel + offTimer + delay + divisor + flowRate + customerId + sensorType + category { + name + } + } + } + } + } + } + """) + result = self._client.execute(query) + return result['me']['controllers'] diff --git a/requirements.txt b/requirements.txt index fe3c0af..215b106 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests>=2.18.4 +gql[requests]>=3.0.0 diff --git a/setup.py b/setup.py index 3e043d9..ef2541d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name='Hydrawiser', packages=['hydrawiser'], - version='0.2', + version='0.3.0', description='A Python library to communicate with Hunter ' + 'Wi-Fi irrigation controllers ' + '(https://www.hunter.com) that support the ' + @@ -14,7 +14,7 @@ url='https://github.com/ptcryan/hydrawiser', license='MIT', include_package_data=True, - install_requires=['requests>=2.0'], + install_requires=['requests>=2.0', 'gql[requests]>=3.0.0', ], platforms='any', test_suite='tests', keywords=[ diff --git a/tests/fixtures/oauth2_resp.json b/tests/fixtures/oauth2_resp.json new file mode 100644 index 0000000..a86601b --- /dev/null +++ b/tests/fixtures/oauth2_resp.json @@ -0,0 +1 @@ +{"token_type": "Bearer", "expires_in": 3600, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImEyNTYyZmIxMWI5YzljYjg2NzUwNGM5NGZkMmQ3NmVkOWM0NTE0YjhhZGI3MzQxZTdlZWJiY2Y2N2RlOTYyNjAyMWJlYWQxYjliMDgxNDkyIn0.eyJhdWQiOiJoeWRyYXdpc2VfYXBwIiwianRpIjoiYTI1NjJmYjExYjljOWNiODY3NTA0Yzk0ZmQyZDc2ZWQ5YzQ1MTRiOGFkYjczNDFlN2VlYmJjZjY3ZGU5NjI2MDIxYmVhZDFiOWIwODE0OTIiLCJpYXQiOjE2NDg3NDA3MjcsIm5iZiI6MTY0ODc0MDcyNywiZXhwIjoxNjQ4NzQ0MzI3LCJzdWIiOiIxMjM0NTYiLCJzY29wZXMiOlsiYWxsIl19.A0cjscbqE_QDaFtt_k9Db0EYeKFupZokcOz44xhS7gwGbKMgevUckgHukCUJ1wQxQpXrbFA8CH9Oj4TYPASheG-PiB9CEsguiqLdY9d8hz5c3ztU6Gi9dqOzrKSfuMewcko4eF-VHmrQPD24RAFBd_pseK7TsGC66WhhfhuizSeHnhTMOzAgbzYZzWD0C3RuDvKdZmCmgj9W6y2MCHxGm8_wDwYKVN_QM1IVlDOZkwWqXHGG3N9lPSXSZIBrry6VToO31DWEBUbiJWm4GTUuLFFkWfVAL8uoZ6PEVziBkaPdltB3Yo11iXZr8uNrThFcmzjbs751fofx9DLpdv05Cw", "refresh_token": "def50200109bb8172ece79bf2664ca3738f1e9a58694a9ffcc1d25af01bffabcfcb3b5c74b0a3affd1ebd29c19a489228b40865dea880ce409947124b9c403272cebc8907a4fc92c0896d992ed0c90b6ada003f89c0113431bf14b7df723230ede5009cd4df32bb386cacc7eb357e355374e247ac13ffc08cb40e7e876c92002e6f3ee807f1156d3c5ef148e6d3c2fee2bb812a1e0921bfb1fa491ebd5bc32c2e348708eb2a856aab4a72237b224885f0c58b4b1bc171fb51d4a41a01e30c1aaf365ec72403a8169f7844e64f99a70d88584e19550aefffbbc1d888f7192756fb132395ae0afccbf14ba8f2eff81298f69bfb5d465c2c58d48bf4e228994664dc9cc8f7b5f435cccf3da5f0985afb5a5253b00373388febfab24064d7ae265fc26bc7404ab0ecf8eec1f2445d9fae7f6b9a704aaf0e760ca764da54c97d0876085a2cded26834f825e20eb9c92a184a6e9b44b631e1c9af86d0db604e4cc39508db038c4a6a14741f1f571e3221a7e0487ebdc181bf76b", "expires_at": 1648744327} diff --git a/tests/test_core_v2.py b/tests/test_core_v2.py new file mode 100644 index 0000000..b31ca49 --- /dev/null +++ b/tests/test_core_v2.py @@ -0,0 +1,39 @@ +"""Define basic data for unittests.""" +import unittest +import requests_mock + +from tests.extras import load_fixture + + +class UnitTestBase(unittest.TestCase): + """Top level test class""" + + @requests_mock.Mocker() + def setUp(self, mock): + """Initialize rdy object for unittests.""" + from hydrawiser.core_v2 import HydrawiserV2 + + mock.post( + HydrawiserV2.TOKEN_ENDPOINT, + text=load_fixture('oauth2_resp.json') + ) + + # initialize self.rdy object + self.rdy = HydrawiserV2('test@example.com', 's3cr3tP1ssw0rd') + + def cleanUp(self): + """Cleanup any data created from the tests.""" + self.rdy = None + + def tearDown(self): + """Stop everything initialized.""" + self.cleanUp() + + +class TestHydrawiserV2(UnitTestBase): + + def test_attributes(self): + """ Test if attributes exist. """ + + self.assertTrue(hasattr(self.rdy, 'customer')) + self.assertTrue(hasattr(self.rdy, 'controllers')) diff --git a/tox.ini b/tox.ini index 9857b7d..367445e 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py27, py38, lint +envlist = py38, lint skip_missing_interpreters = True [testenv] @@ -14,6 +14,7 @@ commands = py.test --basetemp={envtmpdir} --cov-report term-missing --cov --verbose deps = requests>=2.18.4 + gql[requests]>=3.0.0 requests_mock pytest pytest-cov