Skip to content

Commit

Permalink
Merge branch 'devel'
Browse files Browse the repository at this point in the history
  • Loading branch information
jinnatar committed Dec 21, 2017
2 parents 8127f7a + 59b99f4 commit 743c5d6
Show file tree
Hide file tree
Showing 22 changed files with 521 additions and 277 deletions.
13 changes: 10 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ python-cozify
=============

Unofficial Python3 API bindings for the (unpublished) Cozify API.
Includes 1:1 API calls plus helper functions to string together an
authentication flow.
Includes high-level helpers for easier use of the APIs,
for example an automatic authentication flow, and low-level 1:1 API functions.

Installation
------------
Expand Down Expand Up @@ -89,18 +89,25 @@ And the expiry duration can be altered (also when calling cloud.ping()):
Tests
-----
pytest is used for unit tests. Test coverage is still quite spotty and under active development.
Certain tests are marked as "live" tests and require an active authentication state and a real hub to query against.
Live tests are non-destructive.

During development you can run the test suite right from the source directory:

.. code:: bash
pytest -v
pytest -v cozify/
# or include the live tests as well:
pytest -v cozify/ --live
To run the test suite on an already installed python-cozify:

.. code:: bash
pytest -v --pyargs cozify
# or including live tests:
pytest -v --pyargs cozify --live
Current limitations
-------------------
Expand Down
2 changes: 1 addition & 1 deletion cozify/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.9.1"
__version__ = "0.2.10"
138 changes: 13 additions & 125 deletions cozify/cloud.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
"""Module for handling Cozify Cloud API operations
Attributes:
cloudBase(str): API endpoint including version
"""Module for handling Cozify Cloud highlevel operations.
"""

import json, requests, logging, datetime
import logging, datetime

from . import config as c
from . import hub
from . import hub_api
from . import cloud_api

from .Error import APIError, AuthenticationError

cloudBase='https://cloud2.cozify.fi/ui/0.2/'

def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
"""Authenticate with the Cozify Cloud and Hub.
Expand Down Expand Up @@ -44,7 +40,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):

if _need_cloud_token(trustCloud):
try:
_requestlogin(email)
cloud_api.requestlogin(email)
except APIError:
resetState() # a bogus email will shaft all future attempts, better to reset
raise
Expand All @@ -57,7 +53,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
raise AuthenticationError(message)

try:
cloud_token = _emaillogin(email, otp)
cloud_token = cloud_api.emaillogin(email, otp)
except APIError:
logging.error('OTP authentication has failed.')
resetState()
Expand All @@ -71,9 +67,9 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
cloud_token = _getAttr('remoteToken')

if _need_hub_token(trustHub):
localHubs = _lan_ip() # will only work if we're local to the Hub, otherwise None
localHubs = cloud_api.lan_ip() # will only work if we're local to the Hub, otherwise None
# TODO(artanicus): unknown what will happen if there is a local hub but another one remote. Needs testing by someone with multiple hubs. Issue #7
hubkeys = _hubkeys(cloud_token) # get all registered hubs and their keys from the cloud.
hubkeys = cloud_api.hubkeys(cloud_token) # get all registered hubs and their keys from the cloud.
if not hubkeys:
logging.critical('You have not registered any hubs to the Cozify Cloud, hence a hub cannot be used yet.')

Expand All @@ -87,18 +83,18 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True):
# if we're remote, we didn't get a valid ip
if not localHubs:
logging.info('No local Hubs detected, attempting authentication via Cozify Cloud.')
hub_info = hub._hub(cloud_token=cloud_token, hub_token=hub_token)
hub_info = hub_api.hub(remote=True, cloud_token=cloud_token, hub_token=hub_token)
# if the hub wants autoremote we flip the state
if hub.autoremote and not hub.remote:
logging.info('[autoremote] Flipping hub remote status from local to remote.')
hub.remote = True
else:
# localHubs is valid so a hub is in the lan. A mixed environment cannot yet be detected.
# _lan_ip cannot provide a map as to which ip is which hub. Thus we actually need to determine the right one.
# cloud_api.lan_ip cannot provide a map as to which ip is which hub. Thus we actually need to determine the right one.
# TODO(artanicus): Need to truly test how multihub works before implementing ip to hub resolution. See issue #7
logging.debug('data structure: {0}'.format(localHubs))
hub_ip = localHubs[0]
hub_info = hub._hub(host=hub_ip)
hub_info = hub_api.hub(host=hub_ip, remote=False)
# if the hub wants autoremote we flip the state
if hub.autoremote and hub.remote:
logging.info('[autoremote] Flipping hub remote status from remote to local.')
Expand Down Expand Up @@ -149,7 +145,7 @@ def ping(autorefresh=True, expiry=None):
"""

try:
_hubkeys(token()) # TODO(artanicus): see if there's a cheaper API call
cloud_api.hubkeys(token()) # TODO(artanicus): see if there's a cheaper API call
except APIError as e:
if e.status_code == 401:
return False
Expand Down Expand Up @@ -177,7 +173,7 @@ def refresh(force=False, expiry=datetime.timedelta(days=1)):
"""
if _need_refresh(force, expiry):
try:
cloud_token = _refreshsession(token())
cloud_token = cloud_api.refreshsession(token())
except APIError as e:
if e.status_code == 401:
# too late, our token is already dead
Expand Down Expand Up @@ -333,111 +329,3 @@ def email(new_email=None):
if new_email:
_setAttr('email', new_email)
return _getAttr('email')

def _requestlogin(email):
"""Raw Cloud API call, request OTP to be sent to account email address.
Args:
email(str): Email address connected to Cozify account.
"""

payload = { 'email': email }
response = requests.post(cloudBase + 'user/requestlogin', params=payload)
if response.status_code is not 200:
raise APIError(response.status_code, response.text)

def _emaillogin(email, otp):
"""Raw Cloud API call, request cloud token with email address & OTP.
Args:
email(str): Email address connected to Cozify account.
otp(int): One time passcode.
Returns:
str: cloud token
"""

payload = {
'email': email,
'password': otp
}

response = requests.post(cloudBase + 'user/emaillogin', params=payload)
if response.status_code == 200:
return response.text
else:
raise APIError(response.status_code, response.text)

def _lan_ip():
"""1:1 implementation of hub/lan_ip
This call will fail with an APIError if the requesting source address is not the same as that of the hub, i.e. if they're not in the same NAT network.
The above is based on observation and may only be partially true.
Returns:
list: List of Hub ip addresses.
"""
response = requests.get(cloudBase + 'hub/lan_ip')
if response.status_code == 200:
return json.loads(response.text)
else:
raise APIError(response.status_code, response.text)

def _hubkeys(cloud_token):
"""1:1 implementation of user/hubkeys
Args:
cloud_token(str) Cloud remote authentication token.
Returns:
dict: Map of hub_id: hub_token pairs.
"""
headers = {
'Authorization': cloud_token
}
response = requests.get(cloudBase + 'user/hubkeys', headers=headers)
if response.status_code == 200:
return json.loads(response.text)
else:
raise APIError(response.status_code, response.text)

def _refreshsession(cloud_token):
"""1:1 implementation of user/refreshsession
Args:
cloud_token(str) Cloud remote authentication token.
Returns:
str: New cloud remote authentication token. Not automatically stored into state.
"""
headers = {
'Authorization': cloud_token
}
response = requests.get(cloudBase + 'user/refreshsession', headers=headers)
if response.status_code == 200:
return response.text
else:
raise APIError(response.status_code, response.text)

def _remote(cloud_token, hub_token, apicall, put=False):
"""1:1 implementation of 'hub/remote'
Args:
cloud_token(str): Cloud remote authentication token.
hub_token(str): Hub authentication token.
apicall(str): Full API call that would normally go directly to hub, e.g. '/cc/1.6/hub/colors'
Returns:
requests.response: Requests response object.
"""

headers = {
'Authorization': cloud_token,
'X-Hub-Key': hub_token
}
if put:
response = requests.put(cloudBase + 'hub/remote' + apicall, headers=headers)
else:
response = requests.get(cloudBase + 'hub/remote' + apicall, headers=headers)

return response
122 changes: 122 additions & 0 deletions cozify/cloud_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Module for handling Cozify Cloud API 1:1 functions
Attributes:
cloudBase(str): API endpoint including version
"""

import json, requests

from .Error import APIError, AuthenticationError

cloudBase='https://cloud2.cozify.fi/ui/0.2/'

def requestlogin(email):
"""Raw Cloud API call, request OTP to be sent to account email address.
Args:
email(str): Email address connected to Cozify account.
"""

payload = { 'email': email }
response = requests.post(cloudBase + 'user/requestlogin', params=payload)
if response.status_code is not 200:
raise APIError(response.status_code, response.text)

def emaillogin(email, otp):
"""Raw Cloud API call, request cloud token with email address & OTP.
Args:
email(str): Email address connected to Cozify account.
otp(int): One time passcode.
Returns:
str: cloud token
"""

payload = {
'email': email,
'password': otp
}

response = requests.post(cloudBase + 'user/emaillogin', params=payload)
if response.status_code == 200:
return response.text
else:
raise APIError(response.status_code, response.text)

def lan_ip():
"""1:1 implementation of hub/lan_ip
This call will fail with an APIError if the requesting source address is not the same as that of the hub, i.e. if they're not in the same NAT network.
The above is based on observation and may only be partially true.
Returns:
list: List of Hub ip addresses.
"""
response = requests.get(cloudBase + 'hub/lan_ip')
if response.status_code == 200:
return json.loads(response.text)
else:
raise APIError(response.status_code, response.text)

def hubkeys(cloud_token):
"""1:1 implementation of user/hubkeys
Args:
cloud_token(str) Cloud remote authentication token.
Returns:
dict: Map of hub_id: hub_token pairs.
"""
headers = {
'Authorization': cloud_token
}
response = requests.get(cloudBase + 'user/hubkeys', headers=headers)
if response.status_code == 200:
return json.loads(response.text)
else:
raise APIError(response.status_code, response.text)

def refreshsession(cloud_token):
"""1:1 implementation of user/refreshsession
Args:
cloud_token(str) Cloud remote authentication token.
Returns:
str: New cloud remote authentication token. Not automatically stored into state.
"""
headers = {
'Authorization': cloud_token
}
response = requests.get(cloudBase + 'user/refreshsession', headers=headers)
if response.status_code == 200:
return response.text
else:
raise APIError(response.status_code, response.text)

def remote(cloud_token, hub_token, apicall, put=False, payload=None, **kwargs):
"""1:1 implementation of 'hub/remote'
Args:
cloud_token(str): Cloud remote authentication token.
hub_token(str): Hub authentication token.
apicall(str): Full API call that would normally go directly to hub, e.g. '/cc/1.6/hub/colors'
put(bool): Use PUT instead of GET.
payload(str): json string to use as payload if put = True.
Returns:
requests.response: Requests response object.
"""

headers = {
'Authorization': cloud_token,
'X-Hub-Key': hub_token
}
if put:
response = requests.put(cloudBase + 'hub/remote' + apicall, headers=headers, data=payload)
else:
response = requests.get(cloudBase + 'hub/remote' + apicall, headers=headers)

return response
12 changes: 12 additions & 0 deletions cozify/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import pytest
def pytest_addoption(parser):
parser.addoption("--live", action="store_true",
default=False, help="run tests requiring a functional auth and a real hub.")

def pytest_collection_modifyitems(config, items):
if config.getoption("--live"):
return
skip_live = pytest.mark.skip(reason="need --live option to run")
for item in items:
if "live" in item.keywords:
item.add_marker(skip_live)
Loading

0 comments on commit 743c5d6

Please sign in to comment.