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