diff --git a/README.rst b/README.rst index 7233058..9b274ec 100644 --- a/README.rst +++ b/README.rst @@ -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 ------------ @@ -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 ------------------- diff --git a/cozify/__init__.py b/cozify/__init__.py index 8a5ae74..6232f7a 100644 --- a/cozify/__init__.py +++ b/cozify/__init__.py @@ -1 +1 @@ -__version__ = "0.2.9.1" +__version__ = "0.2.10" diff --git a/cozify/cloud.py b/cozify/cloud.py index 77c70e6..5d4eb6b 100644 --- a/cozify/cloud.py +++ b/cozify/cloud.py @@ -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. @@ -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 @@ -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() @@ -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.') @@ -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.') @@ -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 @@ -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 @@ -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 diff --git a/cozify/cloud_api.py b/cozify/cloud_api.py new file mode 100644 index 0000000..62e5cee --- /dev/null +++ b/cozify/cloud_api.py @@ -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 diff --git a/cozify/conftest.py b/cozify/conftest.py new file mode 100644 index 0000000..18b1191 --- /dev/null +++ b/cozify/conftest.py @@ -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) diff --git a/cozify/hub.py b/cozify/hub.py index c348918..f82f19f 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -1,48 +1,96 @@ -"""Module for handling Cozify Hub API operations +"""Module for handling highlevel Cozify Hub operations. Attributes: - apiPath(str): Hub API endpoint path including version. Things may suddenly stop working if a software update increases the API version on the Hub. Incrementing this value until things work will get you by until a new version is published. remote(bool): Selector to treat a hub as being outside the LAN, i.e. calls will be routed via the Cozify Cloud remote call system. Defaults to False. autoremote(bool): Selector to autodetect hub LAN presence and flip to remote mode if needed. Defaults to True. + capability(capability): Enum of known device capabilities. Alphabetically sorted, numeric value not guaranteed to stay constant between versions if new capabilities are added. """ -import requests, json, logging +import requests, logging from . import config as c from . import cloud +from . import hub_api +from enum import Enum + from .Error import APIError -apiPath = '/cc/1.7' remote = False autoremote = True -def getDevices(hubName=None, hubId=None): - """Get up to date full devices data set as a dict +capability = Enum('capability', 'BASS BRIGHTNESS COLOR_HS COLOR_LOOP COLOR_TEMP CONTACT DEVICE HUMIDITY LOUDNESS MUTE NEXT ON_OFF PAUSE PLAY PREVIOUS SEEK STOP TEMPERATURE TRANSITION TREBLE USER_PRESENCE VOLUME') + +def getDevices(**kwargs): + """Deprecated, will be removed in v0.3. Get up to date full devices data set as a dict. + + Args: + **hub_name(str): optional name of hub to query. Will get converted to hubId for use. + **hub_id(str): optional id of hub to query. A specified hub_id takes presedence over a hub_name or default Hub. Providing incorrect hub_id's will create cruft in your state but it won't hurt anything beyond failing the current operation. + **remote(bool): Remote or local query. + **hubId(str): Deprecated. Compatibility keyword for hub_id, to be removed in v0.3 + **hubName(str): Deprecated. Compatibility keyword for hub_name, to be removed in v0.3 + + Returns: + dict: full live device state as returned by the API + + """ + hub_id = _get_id(**kwargs) + hub_token = token(hub_id) + cloud_token = cloud.token() + hostname = host(hub_id) + + if 'remote' not in kwargs: + kwargs['remote'] = remote + + return devices(capability=None, **kwargs) + +def devices(*, capability=None, **kwargs): + """Get up to date full devices data set as a dict. Optionally can be filtered to only include certain devices. Args: - hubName(str): optional name of hub to query. Will get converted to hubId for use. - hubId(str): optional id of hub to query. A specified hubId takes presedence over a hubName or default Hub. Providing incorrect hubId's will create cruft in your state but it won't hurt anything beyond failing the current operation. + capability(cozify.hub.capability): Capability to filter by, for example: cozify.hub.capability.TEMPERATURE. Defaults to no filtering. + **hub_name(str): optional name of hub to query. Will get converted to hubId for use. + **hub_id(str): optional id of hub to query. A specified hub_id takes presedence over a hub_name or default Hub. Providing incorrect hub_id's will create cruft in your state but it won't hurt anything beyond failing the current operation. + **remote(bool): Remote or local query. + **hubId(str): Deprecated. Compatibility keyword for hub_id, to be removed in v0.3 + **hubName(str): Deprecated. Compatibility keyword for hub_name, to be removed in v0.3 Returns: dict: full live device state as returned by the API """ - # No matter what we got we resolve it down to a hubId - if not hubId and hubName: - hubId = getHubId(hubName) - if not hubName and not hubId: - hubId = getDefaultHub() - - configName = 'Hubs.' + hubId - if cloud._need_hub_token(): - logging.warning('No valid authentication token, requesting authentication') - cloud.authenticate() - hub_token = c.state[configName]['hubtoken'] - cloud_token = c.state['Cloud']['remotetoken'] - host = c.state[configName]['host'] - - return _devices(host=host, hub_token=hub_token, cloud_token=cloud_token) + hub_id = _get_id(**kwargs) + hub_token = token(hub_id) + cloud_token = cloud.token() + hostname = host(hub_id) + + devs = hub_api.devices(host=hostname, hub_token=hub_token, remote=remote, cloud_token=cloud_token) + if capability: + return { key : value for key, value in devs.items() if capability.name in value['capabilities']['values'] } + else: + return devs + +def _get_id(**kwargs): + """Get a hub_id from various sources, meant so that you can just throw kwargs at it and get a valid id. + If no data is available to determine which hub was meant, will default to the default hub. If even that fails, will raise an AttributeError. + + Args: + **hub_id(str): Will be returned as-is if defined. + **hub_name(str): Name of hub. + hubName(str): Deprecated. Compatibility keyword for hub_name, to be removed in v0.3 + hubId(str): Deprecated. Compatibility keyword for hub_id, to be removed in v0.3 + """ + if 'hub_id' in kwargs or 'hubId' in kwargs: + logging.debug("Redundant hub._get_id call, resolving hub_id to itself.") + if 'hub_id' in kwargs: + return kwargs['hub_id'] + return kwargs['hubId'] + if 'hub_name' in kwargs or 'hubName' in kwargs: + if 'hub_name' in kwargs: + return getHubId(kwargs['hub_name']) + return getHubId(kwargs['hubName']) + return getDefaultHub() def getDefaultHub(): """Return id of default Hub. @@ -51,9 +99,10 @@ def getDefaultHub(): """ if 'default' not in c.state['Hubs']: - logging.warning('no hub name given and no default known, running authentication.') - cloud.authenticate(remote=remote, autoremote=autoremote) - return c.state['Hubs']['default'] + logging.critical('no hub name given and no default known, you should run cozify.authenticate()') + raise AttributeError + else: + return c.state['Hubs']['default'] def getHubId(hub_name): """Get hub id by it's name. @@ -144,11 +193,8 @@ def token(hub_id, new_token=None): _setAttr(hub_id, 'hubtoken', new_token) return _getAttr(hub_id, 'hubtoken') -def _getBase(host, port=8893, api=apiPath): - return 'http://%s:%s%s' % (host, port, api) - -def ping(hub_id=None, hub_name=None): - """Perform a cheap API call to trigger any potential APIError and return boolean for success/failure +def ping(hub_id=None, hub_name=None, **kwargs): + """Perform a cheap API call to trigger any potential APIError and return boolean for success/failure. For optional kwargs see cozify.hub_api.get() Args: hub_id(str): Hub to ping or default if None. Defaults to None. @@ -187,31 +233,8 @@ def ping(hub_id=None, hub_name=None): return True -def _hub(host=None, remoteToken=None, hubToken=None): - """1:1 implementation of /hub API call - - Args: - host(str): ip address or hostname of hub - remoteToken(str): Cloud remote authentication token. Only needed if authenticating remotely, i.e. via the cloud. Defaults to None. - hubToken(str): Hub authentication token. Only needed if authenticating remotely, i.e. via the cloud. Defaults to None. - - Returns: - dict: Hub state dict converted from the raw json dictionary. - """ - - response = None - if host: - response = requests.get(_getBase(host=host, api='/') + 'hub') - elif remoteToken and hubToken: - response = cloud._remote(remoteToken, hubToken, '/hub') - - if response.status_code == 200: - return json.loads(response.text) - else: - raise APIError(response.status_code, response.text) - -def tz(hub_id=None): - """Get timezone of given hub or default hub if no id is specified. +def tz(hub_id=None, **kwargs): + """Get timezone of given hub or default hub if no id is specified. For kwargs see cozify.hub_api.get() Args: hub_id(str): Hub to query, by default the default hub is used. @@ -225,55 +248,10 @@ def tz(hub_id=None): ip = host(hub_id) hub_token = token(hub_id) - cloud_token = None - if remote: - cloud_token = cloud.token() - - return _tz(ip, hub_token, cloud_token) + cloud_token = cloud.token() -def _tz(host, hub_token, cloud_token=None): - """1:1 implementation of /hub/tz API call - - Args: - host(str): ip address or hostname of hub - hub_token(str): Hub authentication token. - cloud_token(str): Cloud authentication token. Only needed if authenticating remotely, i.e. via the cloud. Defaults to None. - - Returns: - str: Timezone of the hub, for example: 'Europe/Helsinki' - """ + # if remote state not already set in the parameters, include it + if remote not in kwargs: + kwargs['remote'] = remote - headers = { 'Authorization': hub_token } - call = '/hub/tz' - if remote: - response = cloud._remote(cloud_token=cloud_token, hub_token=hub_token, apicall=apiPath + call) - else: - response = requests.get(_getBase(host=host) + call, headers=headers) - if response.status_code == 200: - return response.json() - else: - raise APIError(response.status_code, '%s - %s - %s' % (response.reason, response.url, response.text)) - - -def _devices(host, hub_token, cloud_token=None): - """1:1 implementation of /devices - - Args: - host(str): ip address or hostname of hub. - hub_token(str): Hub authentication token. - cloud_token(str): Cloud authentication token. Only needed if authenticating remotely, i.e. via the cloud. Defaults to None. - Returns: - json: Full live device state as returned by the API - - """ - - headers = { 'Authorization': hub_token } - call = '/devices' - if remote: - response = cloud._remote(cloud_token, hub_token, apiPath + call) - else: - response = requests.get(_getBase(host=host) + call, headers=headers) - if response.status_code == 200: - return response.json() - else: - raise APIError(response.status_code, '%s - %s - %s' % (response.reason, response.url, response.text)) + return hub_api.tz(host=ip, hub_token=hub_token, cloud_token=cloud_token, **kwargs) diff --git a/cozify/hub_api.py b/cozify/hub_api.py new file mode 100644 index 0000000..01f74e7 --- /dev/null +++ b/cozify/hub_api.py @@ -0,0 +1,111 @@ +"""Module for all Cozify Hub API 1:1 calls + +Attributes: + apiPath(str): Hub API endpoint path including version. Things may suddenly stop working if a software update increases the API version on the Hub. Incrementing this value until things work will get you by until a new version is published. +""" + +import requests, json + +from cozify import cloud_api + +from .Error import APIError + +apiPath = '/cc/1.7' + +def _getBase(host, port=8893, api=apiPath): + return 'http://%s:%s%s' % (host, port, api) + +def _headers(hub_token): + return { 'Authorization': hub_token } + +def get(call, hub_token_header=True, base=apiPath, **kwargs): + """GET method for calling hub API. + + Args: + call(str): API path to call after apiPath, needs to include leading /. + hub_token_header(bool): Set to False to omit hub_token usage in call headers. + base(str): Base path to call from API instead of global apiPath. Defaults to apiPath. + **host(str): ip address or hostname of hub. + **hub_token(str): Hub authentication token. + **remote(bool): If call is to be local or remote (bounced via cloud). + **cloud_token(str): Cloud authentication token. Only needed if remote = True. + """ + response = None + headers = None + if kwargs['remote'] and kwargs['cloud_token']: + response = cloud_api.remote(apicall=base + call, **kwargs) + else: + if hub_token_header: + headers = _headers(kwargs['hub_token']) + response = requests.get(_getBase(host=kwargs['host'], api=base) + call, headers=headers) + + if response.status_code == 200: + return response.json() + elif response.status_code == 410: + raise APIError(response.status_code, 'API version outdated. Update python-cozify. %s - %s - %s' % (response.reason, response.url, response.text)) + else: + raise APIError(response.status_code, '%s - %s - %s' % (response.reason, response.url, response.text)) + +def put(call, payload, hub_token_header=True, base=apiPath, **kwargs): + """PUT method for calling hub API. + + Args: + call(str): API path to call after apiPath, needs to include leading /. + payload(str): json string to push out as the payload. + hub_token_header(bool): Set to False to omit hub_token usage in call headers. + base(str): Base path to call from API instead of global apiPath. Defaults to apiPath. + **host(str): ip address or hostname of hub. + **hub_token(str): Hub authentication token. + **remote(bool): If call is to be local or remote (bounced via cloud). + **cloud_token(str): Cloud authentication token. Only needed if remote = True. + """ + response = None + headers = None + if kwargs['remote'] and kwargs['cloud_token']: + response = cloud_api.remote(apicall=base + call, put=True, payload=payload, **kwargs) + else: + if hub_token_header: + headers = _headers(kwargs['hub_token']) + response = requests.put(_getBase(host=kwargs['host'], api=base) + call, headers=headers, data=payload) + + if response.status_code == 200: + return response.json() + elif response.status_code == 410: + raise APIError(response.status_code, 'API version outdated. Update python-cozify. %s - %s - %s' % (response.reason, response.url, response.text)) + else: + raise APIError(response.status_code, '%s - %s - %s' % (response.reason, response.url, response.text)) + +def hub(**kwargs): + """1:1 implementation of /hub API call. For kwargs see cozify.hub_api.get() + + Returns: + dict: Hub state dict. + """ + return get('hub', base='/', hub_token_header=False, **kwargs) + +def tz(**kwargs): + """1:1 implementation of /hub/tz API call. For kwargs see cozify.hub_api.get() + + Returns: + str: Timezone of the hub, for example: 'Europe/Helsinki' + """ + return get('/hub/tz', **kwargs) + +def devices(**kwargs): + """1:1 implementation of /devices API call. For kwargs see cozify.hub_api.get() + + Returns: + json: Full live device state as returned by the API + """ + return get('/devices', **kwargs) + +def devices_command(command, **kwargs): + """1:1 implementation of /devices/command. For kwargs see cozify.hub_api.put() + + Args: + command(str): json string of type DeviceData containing the changes wanted + + Returns: + str: What ever the API replied or an APIException on failure. + """ + return put('/devices/command', command, **kwargs) diff --git a/cozify/test/debug.py b/cozify/test/debug.py index c47b3cd..c67067b 100755 --- a/cozify/test/debug.py +++ b/cozify/test/debug.py @@ -1,12 +1,7 @@ #!/usr/bin/env python3 -"""Set high log level and consistent temporary state storage in /tmp/python-cozify-testing.cfg +"""Set high log level """ import logging -from cozify import config logging.basicConfig(level=logging.DEBUG) - -# Disabled due to not wanting to mock the entire auth, so instead we use whatever is the live state. -#conf_file='/tmp/python-cozify-testing.cfg' -#config.setStatePath(conf_file) diff --git a/cozify/test/test_cloud.py b/cozify/test/test_cloud.py index 0f2464e..5f5fd9e 100755 --- a/cozify/test/test_cloud.py +++ b/cozify/test/test_cloud.py @@ -2,23 +2,20 @@ import os, pytest, tempfile, datetime -from cozify import cloud, config +from cozify import conftest + +from cozify import cloud, config, hub from cozify.test import debug +## basic cloud.authenticate() tests + +@pytest.mark.live def test_auth_cloud(): - print('Baseline;') - print('needRemote: {0}'.format(cloud._need_cloud_token(True))) - print('needHub: {0}'.format(cloud._need_hub_token(True))) - print('Authentication with default trust;') - print(cloud.authenticate()) + assert cloud.authenticate() +@pytest.mark.live def test_auth_hub(): - print('Baseline;') - print('needRemote: {0}'.format(cloud._need_cloud_token(True))) - print('needHub: {0}'.format(cloud._need_hub_token(True))) - - print('Authentication with no hub trust;') - print(cloud.authenticate(trustHub=False)) + assert cloud.authenticate(trustHub=False) class tmp_cloud(): """Creates a temporary cloud state with test data. @@ -51,11 +48,18 @@ def tmpcloud(scope='module'): with tmp_cloud() as cloud: yield cloud +@pytest.fixture +def livecloud(scope='module'): + config.setStatePath() # reset to default + return cloud + @pytest.fixture def id(scope='module'): return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' +## cloud.refresh() logic tests + def test_cloud_refresh_cold(tmpcloud): config.state.remove_option('Cloud', 'last_refresh') config.dump_state() @@ -72,3 +76,13 @@ def test_cloud_refresh_expiry_over(tmpcloud): def test_cloud_refresh_expiry_not_over(tmpcloud): config.dump_state() assert not cloud._need_refresh(force=False, expiry=datetime.timedelta(days=2)) + +## integration tests for remote + +@pytest.mark.live +def test_cloud_remote_match(livecloud): + config.dump_state() + local_tz = hub.tz() + remote_tz = hub.tz(remote=True) + + assert local_tz == remote_tz diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 35261b2..2506032 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import pytest -import os, sys -from cozify import hub, config, multisensor + +from cozify import conftest + +from cozify import hub, hub_api, config, multisensor from cozify.test import debug class tmp_hub(): @@ -10,11 +12,14 @@ class tmp_hub(): def __init__(self): self.id = 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' self.name = 'HubbyMcHubFace' - self.section = 'Hubs.%s' % self.id + self.host = '127.0.0.1' + self.section = 'Hubs.{0}'.format(self.id) def __enter__(self): config.setStatePath() # reset to default config.state.add_section(self.section) config.state[self.section]['hubname'] = self.name + config.state[self.section]['host'] = self.host + config.state['Hubs']['default'] = self.id return self def __exit__(self, exc_type, exc_value, traceback): if exc_type is not None: @@ -31,16 +36,23 @@ def tmphub(scope='module'): def id(scope='module'): return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' -def test_tz(tmphub): - # this actually runs against a real hub so dump state to have any chance of debugging - config.dump_state() - assert hub.ping() # make sure we have valid auth +@pytest.fixture +def livehub(scope='module'): + config.setStatePath() # default config assumed to be live + config.dump_state() # dump state so it's visible in failed test output + assert hub.ping() + return hub + +@pytest.mark.live +def test_tz(livehub): assert hub.tz() - # hand craft data needed for low-level api call _tz + + # hand craft data needed for low-level api call hub_api.tz hubSection = 'Hubs.' + config.state['Hubs']['default'] - print(hub._tz( + print(hub_api.tz( host=config.state[hubSection]['host'], hub_token=config.state[hubSection]['hubtoken'], + remote=hub.remote, cloud_token=config.state['Cloud']['remotetoken'] )) @@ -50,6 +62,17 @@ def test_hub_id_to_name(tmphub): def test_hub_name_to_id(tmphub): assert hub.getHubId(tmphub.name) == tmphub.id -def test_multisensor(): +@pytest.mark.live +def test_multisensor(livehub): data = hub.getDevices() print(multisensor.getMultisensorData(data)) + +def test_hub_get_id(tmphub): + assert hub._get_id(hub_id=tmphub.id) == tmphub.id + assert hub._get_id(hub_name=tmphub.name) == tmphub.id + assert hub._get_id(hub_name=tmphub.name, hub_id=tmphub.id) == tmphub.id + assert hub._get_id(hubName=tmphub.name) == tmphub.id + assert hub._get_id(hubId=tmphub.id) == tmphub.id + assert hub._get_id() == tmphub.id + assert not hub._get_id(hub_id='foo') == tmphub.id + diff --git a/cozify/test/test_hub_api.py b/cozify/test/test_hub_api.py new file mode 100755 index 0000000..29971fc --- /dev/null +++ b/cozify/test/test_hub_api.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import pytest + +from cozify import conftest +from cozify import cloud, hub, hub_api, config + +tmphub = lambda:0 +tmpcloud = lambda:0 + +@pytest.fixture +def default_hub(scope='module'): + config.setStatePath() # reset to default config + config.dump_state() + tmphub.hub_id = hub.getDefaultHub() + tmphub.name = hub.name(tmphub.hub_id) + tmphub.host = hub.host(tmphub.hub_id) + tmphub.token = hub.token(tmphub.hub_id) + tmphub.remote = hub.remote + return tmphub + +@pytest.fixture +def live_cloud(scope='module'): + tmpcloud.token = cloud.token() + return tmpcloud + + +@pytest.mark.live +def test_hub(live_cloud, default_hub): + assert hub_api.hub( + host = default_hub.host, + remote = default_hub.remote, + remote_token = live_cloud.token, + hub_token = default_hub.token + ) diff --git a/docs/cloud.rst b/docs/cloud.rst index 7e481b9..7216049 100644 --- a/docs/cloud.rst +++ b/docs/cloud.rst @@ -1,6 +1,5 @@ -Cloud -===== +High-level Cloud functions +========================== .. automodule:: cozify.cloud :members: - :private-members: _requestlogin, _emaillogin, _lan_ip, _hubkeys, _refreshsession, _remote diff --git a/docs/cloud_api.rst b/docs/cloud_api.rst new file mode 100644 index 0000000..d6e6ebd --- /dev/null +++ b/docs/cloud_api.rst @@ -0,0 +1,5 @@ +Low-level Cloud API calls +========================= + +.. automodule:: cozify.cloud_api + :members: diff --git a/docs/conf.py b/docs/conf.py index cd80d03..e953cb0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,9 +57,9 @@ # built documents. # # The short X.Y version. -version = '0.2.9' +version = '0.2.10' # The full version, including alpha/beta/rc tags. -release = 'v0.2.9' +release = 'v0.2.10' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/hub.rst b/docs/hub.rst index 5b00b0a..4cbd97f 100644 --- a/docs/hub.rst +++ b/docs/hub.rst @@ -1,6 +1,5 @@ -Hub -=== +High-level Hub functions +======================== .. automodule:: cozify.hub :members: - :private-members: _hub, _tz, _devices diff --git a/docs/hub_api.rst b/docs/hub_api.rst new file mode 100644 index 0000000..4b543bd --- /dev/null +++ b/docs/hub_api.rst @@ -0,0 +1,5 @@ +Low-level Hub API calls +======================= + +.. automodule:: cozify.hub_api + :members: diff --git a/util/cleanSlate.py b/util/cleanSlate.py index 6fdd361..e622727 100755 --- a/util/cleanSlate.py +++ b/util/cleanSlate.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 import tempfile, os -from cozify import config, cloud +from cozify import config, cloud, hub def main(): fh, tmp = tempfile.mkstemp() config.setStatePath(tmp) - cloud.authenticate() + assert cloud.authenticate() config.dump_state() + print(hub.tz(hub.getDefaultHub())) os.remove(tmp) if __name__ == "__main__": diff --git a/util/devicedata.py b/util/devicedata.py new file mode 100755 index 0000000..327675c --- /dev/null +++ b/util/devicedata.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +from cozify import hub +import pprint, sys + +def main(device): + devs = hub.getDevices() + pprint.pprint(devs[device]) + +if __name__ == "__main__": + if len(sys.argv) > 1: + main(sys.argv[1]) + else: + sys.exit(1) diff --git a/util/devicelist.py b/util/devicelist.py new file mode 100755 index 0000000..e03d4fc --- /dev/null +++ b/util/devicelist.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from cozify import hub + +def main(): + devs = hub.getDevices() + + for key, dev in devs.items(): + print('{0}: {1}'.format(key, dev['name'])) + +if __name__ == "__main__": + main() diff --git a/util/remoter.py b/util/remoter.py new file mode 100755 index 0000000..4e9224b --- /dev/null +++ b/util/remoter.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from cozify import hub, cloud, hub_api + +from cozify.test import debug + +def main(): + hub_id = hub.getDefaultHub() + + hub_api.tz( + host = hub.host(hub_id), + cloud_token = cloud.token(), + hub_token = hub.token(hub_id), + remote = True + ) + +if __name__ == "__main__": + main() diff --git a/util/temperature_sensors.py b/util/temperature_sensors.py new file mode 100755 index 0000000..cf2fffc --- /dev/null +++ b/util/temperature_sensors.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from cozify import hub +import pprint + +def main(): + sensors = hub.devices(capability=hub.capability.TEMPERATURE) + pprint.pprint(sensors) + +if __name__ == "__main__": + main() diff --git a/util/versionExplorer.py b/util/versionExplorer.py index 6388f99..230aca9 100755 --- a/util/versionExplorer.py +++ b/util/versionExplorer.py @@ -1,19 +1,19 @@ #!/usr/bin/env python3 import sys import requests -from cozify import hub +from cozify import hub, hub_api from cozify.Error import APIError -def main(start=hub.apiPath): +def main(start=hub_api.apiPath): id = hub.getDefaultHub() host = hub.host(id) token = hub.token(id) api = start - print('Testing against {0}, starting from {1}'.format(id, hub._getBase(host, api=start))) + print('Testing against {0}, starting from {1}'.format(id, hub_api._getBase(host, api=start))) while True: - base = hub._getBase(host, api=api) + base = hub_api._getBase(host, api=api) if not ping(base, token): print('Fail: {0}'.format(api)) else: