diff --git a/.gitignore b/.gitignore index 5ae0836..1197d68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ MANIFEST dist/ build/ .cache/ +.pytest_cache/ diff --git a/README.rst b/README.rst index 623d456..b87c4b3 100644 --- a/README.rst +++ b/README.rst @@ -27,14 +27,15 @@ Basic usage ----------- These are merely some simple examples, for the full documentation see: `http://python-cozify.readthedocs.io/en/latest/` -read devices, extract multisensor data -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +read devices by capability, print temperature data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code:: python - from cozify import hub, multisensor - devices = hub.getDevices() - print(multisensor.getMultisensorData(devices)) + from cozify import hub + devices = hub.devices(capabilities=hub.capability.TEMPERATURE) + for id, dev in devices.items(): + print('{0}: {1}C'.format(dev['name'], dev['state']['temperature'])) only authenticate ~~~~~~~~~~~~~~~~~ @@ -57,6 +58,24 @@ authenticate with a non-default state storage # authentication and other useful data is now stored in the defined location instead of ~/.config/python-cozify/python-cozify.cfg # you could also use the environment variable XDG_CONFIG_HOME to override where config files are stored +On Capabilities +--------------- +The most practical way to "find" devices for operating on is currently to filter the devices list by their capabilties. The +most up to date list of recognized capabilities can be seen at `cozify/hub.py `_ + +If the capability you need is not yet supported, open a bug to get it added. One way to compare your live hub device's capabilities +to those implemented is running the util/capabilities_list.py tool. It will list implemented and gathered capabilities from your live environment. +To get all of your previously unknown capabilities implemented, just copy-paste the full output of the utility into a new bug. + +In short capabilities are tags assigned to devices by Cozify that mostly guarantee the data related to that capability will be in the same format and structure. +For example the capabilities based example code in this document filters all the devices that claim to support temperature and reads their name and temperature state. +Multiple capabilities can be given in a filter by providing a list of capabilities. By default any capability in the list can match (OR filter) but it can be flipped to AND mode +where every capability must be present on a device for it to qualify. For example, if you only want multi-sensors that support both temperature and humidity monitoring you could define a filter as: + +.. code:: python + + devices = hub.devices(capabilities=[ hub.capability.TEMPERATURE, hub.capability.HUMIDITY ], and_filter=True) + Keeping authentication valid ---------------------------- If the cloud token expires, the only option to get a new one is an interactive prompt for an OTP. @@ -86,6 +105,33 @@ And the expiry duration can be altered (also when calling cloud.ping()): # or cloud.ping(autorefresh=True, expiry=datetime.timedelta(days=20)) +Working Remotely +---------------- +By default queries to the hub are attempted via local LAN. Also by default "remoteness" autodetection is on and thus +if it is determined during cloud.authentication() or a hub.ping() call that you seem to not be in the same network, the state is flipped. +Both the remote state and autodetection can be overriden in most if not all funcions by the boolean keyword arguments 'remote' and 'autoremote'. They can also be queried or permanently changed by the hub.remote() and hub.autoremote() functions. + +Using Multiple Hubs +------------------- +Everything has been designed to support multiple hubs registered to the same Cozify Cloud account. All hub operations can be targeted by setting the keyword argument 'hub_id' or 'hub_name'. The developers do not as of yet have access to multiple hubs so proper testing of multi functionality has not been performed. If you run into trouble, please open bugs so things can be improved. + +The remote state of hubs is kept separately so there should be no issues calling your home hub locally but operating on a summer cottage hub remotely at the same time. + +Enconding Pitfalls +------------------ +The hub provides data encoded as a utf-8 json string. Python-cozify transforms this into a Python dictionary +where string values are kept as unicode strings. Normally this isn't an issue, as long as your system supports utf-8. +If not, you will run into trouble printing for example device names with non-ascii characters: + + UnicodeEncodeError: 'ascii' codec can't encode character '\xe4' in position 34: ordinal not in range(128) + +The solution is to change your system locale to support utf-8. How this is done is however system dependant. +As a first test try temporarily overriding your locale: + +.. code:: bash + + LC_ALL='en_US.utf8' python3 program.py + Sample projects --------------- @@ -98,7 +144,7 @@ Development ----------- To develop python-cozify clone the devel branch and submit pull requests against the devel branch. New releases are cut from the devel branch as needed. - + Tests ~~~~~ pytest is used for unit tests. Test coverage is still quite spotty and under active development. diff --git a/cozify/__init__.py b/cozify/__init__.py index 5635676..b5c9b6c 100644 --- a/cozify/__init__.py +++ b/cozify/__init__.py @@ -1 +1 @@ -__version__ = "0.2.11" +__version__ = "0.2.12" diff --git a/cozify/cloud.py b/cozify/cloud.py index 6f91505..2eab10c 100644 --- a/cozify/cloud.py +++ b/cozify/cloud.py @@ -4,7 +4,6 @@ import logging, datetime from . import config -from . import hub from . import hub_api from . import cloud_api @@ -34,6 +33,8 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): bool: True on authentication success. Failure will result in an exception. """ + from . import hub + if not _isAttr('email'): _setAttr('email', _getEmail()) email = _getAttr('email') @@ -85,9 +86,9 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): logging.info('No local Hubs detected, attempting authentication via Cozify Cloud.') 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: + if hub.autoremote(hub_id) and not hub.remote(hub_id): logging.info('[autoremote] Flipping hub remote status from local to remote.') - hub.remote = True + hub.remote(hub_id, True) else: # localHubs is valid so a hub is in the lan. A mixed environment cannot yet be detected. # cloud_api.lan_ip cannot provide a map as to which ip is which hub. Thus we actually need to determine the right one. @@ -96,9 +97,9 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): hub_ip = localHubs[0] 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: + if hub.autoremote(hub_id) and hub.remote(hub_id): logging.info('[autoremote] Flipping hub remote status from remote to local.') - hub.remote = False + hub.remote(hub_id, False) hub_name = hub_info['name'] if hub_id in hubkeys: @@ -246,6 +247,8 @@ def _need_hub_token(trust=True): Returns: bool: True to indicate a need to request token. """ + from . import hub + if not trust: logging.debug("hub_token not trusted so we'll say it needs to be renewed.") return True diff --git a/cozify/cloud_api.py b/cozify/cloud_api.py index 62e5cee..80fdb04 100644 --- a/cozify/cloud_api.py +++ b/cozify/cloud_api.py @@ -96,15 +96,14 @@ def refreshsession(cloud_token): else: raise APIError(response.status_code, response.text) -def remote(cloud_token, hub_token, apicall, put=False, payload=None, **kwargs): +def remote(cloud_token, hub_token, apicall, 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. + payload(str): json string to use as payload, changes method to PUT. Returns: requests.response: Requests response object. @@ -114,7 +113,7 @@ def remote(cloud_token, hub_token, apicall, put=False, payload=None, **kwargs): 'Authorization': cloud_token, 'X-Hub-Key': hub_token } - if put: + if payload: response = requests.put(cloudBase + 'hub/remote' + apicall, headers=headers, data=payload) else: response = requests.get(cloudBase + 'hub/remote' + apicall, headers=headers) diff --git a/cozify/config.py b/cozify/config.py index ea76ece..556e793 100644 --- a/cozify/config.py +++ b/cozify/config.py @@ -48,24 +48,28 @@ def stateWrite(tmpstate=None): with open(state_file, 'w') as cf: tmpstate.write(cf) -def setStatePath(filepath=_initXDG()): +def setStatePath(filepath=_initXDG(), copy_current=False): """Set state storage path. Useful for example for testing without affecting your normal state. Call with no arguments to reset back to autoconfigured location. Args: filepath(str): file path to use as new storage location. Defaults to XDG defined path. + copy_current(bool): Instead of initializing target file, dump previous state into it. """ global state_file global state state_file = filepath - state = _initState(state_file) + if copy_current: + stateWrite() + else: + state = _initState(state_file) def dump_state(): """Print out current state file to stdout. Long values are truncated since this is only for visualization. """ for section in state.sections(): - print('[{0:.10}]'.format(section)) + print('[{!s:.10}]'.format(section)) for option in state.options(section): - print(' {0:<13.13} = {1:>10.100}'.format(option, state[section][option])) + print(' {!s:<13.13} = {!s:>10.100}'.format(option, state[section][option])) def _iso_now(): """Helper to return isoformat datetime stamp that's more compatible than the default. diff --git a/cozify/hub.py b/cozify/hub.py index 1ca20b7..a9bbf33 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -1,25 +1,19 @@ """Module for handling highlevel Cozify Hub operations. Attributes: - 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, logging +import logging from . import config -from . import cloud from . import hub_api from enum import Enum from .Error import APIError -remote = False -autoremote = True - -capability = Enum('capability', 'ALERT BASS BRIGHTNESS COLOR_HS COLOR_LOOP COLOR_TEMP CONTACT DEVICE HUMIDITY LOUDNESS MUTE NEXT ON_OFF PAUSE PLAY PREVIOUS SEEK STOP TEMPERATURE TRANSITION TREBLE TWILIGHT USER_PRESENCE VOLUME') +capability = Enum('capability', 'ALERT BASS BATTERY_U BRIGHTNESS COLOR_HS COLOR_LOOP COLOR_TEMP CONTACT CONTROL_LIGHT CONTROL_POWER DEVICE DIMMER_CONTROL GENERATE_ALERT HUMIDITY IDENTIFY LOUDNESS MOISTURE MUTE NEXT ON_OFF PAUSE PLAY PREVIOUS PUSH_NOTIFICATION REMOTE_CONTROL SEEK SMOKE STOP TEMPERATURE TRANSITION TREBLE TWILIGHT USER_PRESENCE VOLUME') def getDevices(**kwargs): """Deprecated, will be removed in v0.3. Get up to date full devices data set as a dict. @@ -35,6 +29,7 @@ def getDevices(**kwargs): dict: full live device state as returned by the API """ + from . import cloud cloud.authenticate() # the old version of getDevices did more than it was supposed to, including making sure there was a valid connection hub_id = _get_id(**kwargs) @@ -45,7 +40,7 @@ def getDevices(**kwargs): if 'remote' not in kwargs: kwargs['remote'] = remote - return devices(capability=None, **kwargs) + return devices(**kwargs) def devices(*, capabilities=None, and_filter=False, **kwargs): """Get up to date full devices data set as a dict. Optionally can be filtered to only include certain devices. @@ -63,14 +58,8 @@ def devices(*, capabilities=None, and_filter=False, **kwargs): 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 - - devs = hub_api.devices(host=hostname, hub_token=hub_token, cloud_token=cloud_token, **kwargs) + _fill_kwargs(kwargs) + devs = hub_api.devices(**kwargs) if capabilities: if isinstance(capabilities, capability): # single capability given logging.debug("single capability {0}".format(capabilities.name)) @@ -83,6 +72,31 @@ def devices(*, capabilities=None, and_filter=False, **kwargs): else: # no filtering return devs +def toggle(device_id, **kwargs): + """Toggle power state of any device capable of it such as lamps. Eligibility is determined by the capability ON_OFF. + + Args: + device_id: ID of the device to toggle. + **hub_id(str): optional id of hub to operate on. A specified hub_id takes presedence over a hub_name or default Hub. + **hub_name(str): optional name of hub to operate on. + **remote(bool): Remote or local query. + """ + _fill_kwargs(kwargs) + + # Get list of devices known to support toggle and find the device and it's state. + devs = devices(capabilities=capability.ON_OFF, **kwargs) + dev_state = devs[device_id]['state'] + current_state = dev_state['isOn'] + new_state = _clean_state(dev_state) + new_state['isOn'] = not current_state # reverse state + + command = { + "type": "CMD_DEVICE", + "id": device_id, + "state": new_state + } + hub_api.devices_command(command, **kwargs) + 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. @@ -102,16 +116,60 @@ def _get_id(**kwargs): if 'hub_name' in kwargs: return getHubId(kwargs['hub_name']) return getHubId(kwargs['hubName']) - return getDefaultHub() + return default() + +def _fill_kwargs(kwargs): + """Check that common items are present in kwargs and fill them if not. + + Args: + kwargs(dict): kwargs dictionary to fill. Operated on directly. + + """ + if 'hub_id' not in kwargs: + kwargs['hub_id'] = _get_id(**kwargs) + if 'remote' not in kwargs: + kwargs['remote'] = remote(kwargs['hub_id']) + if 'autoremote' not in kwargs: + kwargs['autoremote'] = True + if 'hub_token' not in kwargs: + kwargs['hub_token'] = token(kwargs['hub_id']) + if 'cloud_token' not in kwargs: + from . import cloud + kwargs['cloud_token'] = cloud.token() + if 'host' not in kwargs: + kwargs['host'] = host(kwargs['hub_id']) + +def _clean_state(state): + """Return purged state of values so only wanted values can be modified. + + Args: + state(dict): device state dictionary. Original won't be modified. + """ + out = {} + for k, v in state.items(): + if isinstance(v, dict): # recurse nested dicts + out[k] = _clean_state(v) + elif k == "type": # type values are kept + out[k] = v + else: # null out the rest + out[k] = None + return out + def getDefaultHub(): + """Deprecated, use default(). Return id of default Hub. + """ + logging.warn('hub.getDefaultHub is deprecated and will be removed soon. Use hub.default()') + return default() + +def default(): """Return id of default Hub. - If default hub isn't known, run authentication to make it known. + If default hub isn't known an AttributeError will be raised. """ if 'default' not in config.state['Hubs']: - logging.critical('no hub name given and no default known, you should run cozify.authenticate()') + logging.critical('Default hub not known, you should run cozify.authenticate()') raise AttributeError else: return config.state['Hubs']['default'] @@ -133,21 +191,30 @@ def getHubId(hub_name): return section[5:] # cut out "Hubs." return None -def _getAttr(hub_id, attr): - """Get hub state attributes by attr name. +def _getAttr(hub_id, attr, default=None, boolean=False): + """Get hub state attributes by attr name. Optionally set a default value if attribute not found. Args: hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. attr(str): Name of hub attribute to retrieve + default: Optional default value to set for unset attributes. If no default is provided these raise an AttributeError. + boolean: Retrieve and return value as a boolean instead of string. Defaults to False. Returns: str: Value of attribute or exception on failure. """ section = 'Hubs.' + hub_id - if section in config.state and attr in config.state[section]: - return config.state[section][attr] + if section in config.state: + if attr not in config.state[section]: + if default is not None: + _setAttr(hub_id, attr, default) + else: + raise AttributeError('Attribute {0} not set for hub {1}'.format(attr, hub_id)) + if boolean: + return config.state.getboolean(section, attr) + else: + return config.state[section][attr] else: - logging.warning('Hub id "{0}" not found in state or attribute {1} not set for hub.'.format(hub_id, attr)) - raise AttributeError + raise AttributeError("Hub id '{0}' not found in state.".format(hub_id)) def _setAttr(hub_id, attr, value, commit=True): """Set hub state attributes by hub_id and attr name @@ -158,6 +225,9 @@ def _setAttr(hub_id, attr, value, commit=True): value(str): Value to store commit(bool): True to commit state after set. Defaults to True. """ + if isinstance(value, bool): + value = str(value) + section = 'Hubs.' + hub_id if section in config.state: if attr not in config.state[section]: @@ -205,39 +275,53 @@ def token(hub_id, new_token=None): _setAttr(hub_id, 'hubtoken', new_token) return _getAttr(hub_id, 'hubtoken') -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() +def remote(hub_id, new_state=None): + """Get remote status of matching hub_id or set a new value for it. Args: - hub_id(str): Hub to ping or default if None. Defaults to None. - hub_name(str): Hub to ping or default if None. Defaults to None. + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. Returns: - bool: True for a valid and working hub authentication state. + bool: True for a hub considered remote. + """ + if new_state: + _setAttr(hub_id, 'remote', new_state) + return _getAttr(hub_id, 'remote', default=False, boolean=True) + +def autoremote(hub_id, new_state=None): + """Get autoremote status of matching hub_id or set a new value for it. + + Args: + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. + + Returns: + bool: True for a hub with autoremote enabled. """ + if new_state: + _setAttr(hub_id, 'autoremote', new_state) + return _getAttr(hub_id, 'autoremote', default=True, boolean=True) - if hub_name and not hub_id: - hub_id = getHubId(hub_name) +def ping(**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 neither id or name set. + **hub_name(str): Hub to ping by name. - if not hub_id and not hub_name: - hub_id = getDefaultHub() + Returns: + bool: True for a valid and working hub authentication state. + """ + _fill_kwargs(kwargs) try: - config_name = 'Hubs.' + hub_id - hub_token = _getAttr(hub_id, 'hubtoken') - hub_host = _getAttr(hub_id, 'host') - cloud_token = config.state['Cloud']['remotetoken'] - - # if we don't have a stored host then we assume the hub is remote - global remote - if not remote and autoremote and not hub_host: - remote = True + if not kwargs['remote'] and kwargs['autoremote'] and not kwargs['host']: # flip state if no host known + remote(kwargs['hub_id'], True) + kwargs['remote'] = True logging.debug('Ping determined hub is remote and flipped state to remote.') - - timezone = tz(hub_id) + timezone = tz(**kwargs) logging.debug('Ping performed with tz call, response: {0}'.format(timezone)) except APIError as e: if e.status_code == 401: - logging.debug(e) + logging.warn(e) return False else: raise @@ -245,25 +329,15 @@ def ping(hub_id=None, hub_name=None, **kwargs): return True -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() +def tz(**kwargs): + """Get timezone of given hub or default hub if no id is specified. For more optional kwargs see cozify.hub_api.get() Args: - hub_id(str): Hub to query, by default the default hub is used. + **hub_id(str): Hub to query, by default the default hub is used. Returns: str: Timezone of the hub, for example: 'Europe/Helsinki' """ + _fill_kwargs(kwargs) - if not hub_id: - hub_id = getDefaultHub() - - ip = host(hub_id) - hub_token = token(hub_id) - cloud_token = cloud.token() - - # if remote state not already set in the parameters, include it - if remote not in kwargs: - kwargs['remote'] = remote - - return hub_api.tz(host=ip, hub_token=hub_token, cloud_token=cloud_token, **kwargs) + return hub_api.tz(**kwargs) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index 83d1d0c..0a677f0 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -4,11 +4,12 @@ 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 +import requests, json, logging from cozify import cloud_api from .Error import APIError +from requests.exceptions import RequestException apiPath = '/cc/1.8' @@ -30,44 +31,54 @@ def get(call, hub_token_header=True, base=apiPath, **kwargs): **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)) + return _call(method=requests.get, + call=call, + hub_token_header=hub_token_header, + base=base, + **kwargs + ) def put(call, payload, hub_token_header=True, base=apiPath, **kwargs): - """PUT method for calling hub API. + """PUT method for calling hub API. For rest of kwargs parameters see get() 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. + """ + return _call(method=requests.put, + call=call, + hub_token_header=hub_token_header, + base=base, + payload=payload, + **kwargs + ) + +def _call(*, call, method, base, hub_token_header, payload=None, **kwargs): + """Backend for get & put """ 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']) + + if kwargs['remote']: # remote call + if 'cloud_token' not in kwargs: + raise AttributeError('Asked to do remote call but no cloud_token provided.') + logging.debug('_call routing to cloud.remote()') + response = cloud_api.remote(apicall=base + call, payload=payload, **kwargs) + else: # local call + if not kwargs['host']: + raise AttributeError('Local call but no hostname was provided. Either set keyword remote or host.') if hub_token_header: headers = _headers(kwargs['hub_token']) - response = requests.put(_getBase(host=kwargs['host'], api=base) + call, headers=headers, data=payload) + try: + response = method(_getBase(host=kwargs['host'], api=base) + call, headers=headers, data=payload) + except RequestException as e: + raise APIError('connection failure', 'issues connection to \'{0}\': {1}'.format(kwargs['host'], e)) + # evaluate response, wether it was remote or local if response.status_code == 200: return response.json() elif response.status_code == 410: @@ -109,9 +120,11 @@ 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 + command(dict): dictionary of type DeviceData containing the changes wanted. Will be converted to json. Returns: str: What ever the API replied or an APIException on failure. """ + command = json.dumps(command) + logging.debug('command json to send: {0}'.format(command)) return put('/devices/command', command, **kwargs) diff --git a/cozify/multisensor.py b/cozify/multisensor.py index d6c88f7..156ca6b 100644 --- a/cozify/multisensor.py +++ b/cozify/multisensor.py @@ -4,6 +4,8 @@ # expects Cozify devices type json data def getMultisensorData(data): + """Deprecated, will be removed in v0.3 + """ out = [] for device in data: state=data[device]['state'] diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 6cc0dbd..0b7265c 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -1,52 +1,59 @@ #!/usr/bin/env python3 -import os, pytest, tempfile, datetime +import os, pytest, tempfile, datetime, logging -from cozify import conftest, config, hub, cloud +from cozify import conftest, config from . import fixtures_devices as dev @pytest.fixture -def default_hub(scope='module'): +def default_hub(): barehub = lambda:0 config.setStatePath() # reset to default config config.dump_state() - barehub.hub_id = hub.getDefaultHub() + from cozify import hub + barehub.hub_id = hub.default() barehub.name = hub.name(barehub.hub_id) barehub.host = hub.host(barehub.hub_id) barehub.token = hub.token(barehub.hub_id) - barehub.remote = hub.remote + barehub.remote = hub.remote(barehub_hub_id) return barehub @pytest.fixture -def tmp_cloud(scope='module'): +def tmp_cloud(): with Tmp_cloud() as cloud: yield cloud @pytest.fixture -def live_cloud(scope='module'): - config.setStatePath() # reset to default - return cloud +def live_cloud(): + configfile, configpath = tempfile.mkstemp() + config.setStatePath(configpath, copy_current=True) + from cozify import cloud + yield cloud + config.setStatePath() @pytest.fixture -def id(scope='module'): - return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' +def id(): + return 'deadbeef-aaaa-bbbb-cccc-fixtureddddd' -@pytest.fixture -def tmphub(scope='module'): - with tmp_hub() as hub: - yield hub +@pytest.fixture() +def tmp_hub(): + with Tmp_hub() as hub_obj: + print('Tmp hub state for testing:') + config.dump_state() + yield hub_obj @pytest.fixture -def id(scope='module'): - return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' +def devices(): + return dev -@pytest.fixture -def livehub(scope='module'): +@pytest.fixture() +def live_hub(): config.setStatePath() # default config assumed to be live + print('Live hub state for testing:') config.dump_state() # dump state so it's visible in failed test output - assert hub.ping() - return hub + from cozify import hub + yield hub class Tmp_cloud(): """Creates a temporary cloud state with test data. @@ -61,41 +68,39 @@ def __init__(self): self.iso_now = self.now.isoformat().split(".")[0] self.yesterday = self.now - datetime.timedelta(days=1) self.iso_yesterday = self.yesterday.isoformat().split(".")[0] - def __enter__(self): config.setStatePath(self.configpath) + from cozify import cloud cloud._setAttr('email', self.email) cloud._setAttr('remotetoken', self.token) cloud._setAttr('last_refresh', self.iso_yesterday) + def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): os.remove(self.configpath) if exc_type is not None: - debug.logger.error("%s, %s, %s" % (exc_type, exc_value, traceback)) + logging.error("%s, %s, %s" % (exc_type, exc_value, traceback)) return False -class tmp_hub(): - """Creates a temporary hub section (with test data) in the current live state. +class Tmp_hub(): + """Creates a temporary hub section (with test data) in a tmp_cloud """ def __init__(self): - self.id = 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' + self.id = 'deadbeef-aaaa-bbbb-cccc-tmphubdddddd' self.name = 'HubbyMcHubFace' self.host = '127.0.0.1' self.section = 'Hubs.{0}'.format(self.id) self.token = 'eyJkb20iOiJ1ayIsImFsZyI6IkhTNTEyIiwidHlwIjoiSldUIn0.eyJyb2xlIjo4LCJpYXQiOjE1MTI5ODg5NjksImV4cCI6MTUxNTQwODc2OSwidXNlcl9pZCI6ImRlYWRiZWVmLWFhYWEtYmJiYi1jY2NjLWRkZGRkZGRkZGRkZCIsImtpZCI6ImRlYWRiZWVmLWRkZGQtY2NjYy1iYmJiLWFhYWFhYWFhYWFhYSIsImlzcyI6IkNsb3VkIn0.QVKKYyfTJPks_BXeKs23uvslkcGGQnBTKodA-UGjgHg' # valid but useless jwt token. - def __enter__(self): - config.setStatePath() # reset to default + self.cloud = Tmp_cloud() # this also initializes temporary state config.state.add_section(self.section) config.state[self.section]['hubname'] = self.name config.state[self.section]['host'] = self.host config.state[self.section]['hubtoken'] = self.token config.state['Hubs']['default'] = self.id + def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - if exc_type is not None: - debug.logger.error("%s, %s, %s" % (exc_type, exc_value, traceback)) - return False - config.state.remove_section(self.section) + config.setStatePath() def devices(self): return dev.device_ids, dev.devices diff --git a/cozify/test/fixtures_devices.py b/cozify/test/fixtures_devices.py index ba49394..3c3a297 100644 --- a/cozify/test/fixtures_devices.py +++ b/cozify/test/fixtures_devices.py @@ -46,7 +46,7 @@ 'id': 'a371469c-ae3e-11e5-ab7a-68c90bba878f', 'manufacturer': 'OSRAM', 'model': 'Classic A60 RGBW', - 'name': 'Dining Table', + 'name': 'Dining Täble', 'room': ['87658ab7-bc4f-4d03-85a2-eb32ee1d4539'], 'rwx': 509, 'state': {'brightness': 0.4667, diff --git a/cozify/test/test_cloud.py b/cozify/test/test_cloud.py index da0375e..9a1e906 100755 --- a/cozify/test/test_cloud.py +++ b/cozify/test/test_cloud.py @@ -39,9 +39,9 @@ def test_cloud_refresh_expiry_not_over(tmp_cloud): ## integration tests for remote @pytest.mark.live -def test_cloud_remote_match(live_cloud): +def test_cloud_remote_match(live_cloud, live_hub): config.dump_state() - local_tz = hub.tz() - remote_tz = hub.tz(remote=True) + local_tz = live_hub.tz() + remote_tz = live_hub.tz(remote=True) assert local_tz == remote_tz diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 2b9a879..87dfda0 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -7,52 +7,49 @@ from cozify.test import debug @pytest.mark.live -def test_tz(livehub): +def test_tz(live_hub): + assert hub.ping() assert hub.tz() - # hand craft data needed for low-level api call hub_api.tz - hubSection = 'Hubs.' + config.state['Hubs']['default'] - print(hub_api.tz( - host=config.state[hubSection]['host'], - hub_token=config.state[hubSection]['hubtoken'], - remote=hub.remote, - cloud_token=config.state['Cloud']['remotetoken'] - )) +@pytest.mark.live +def test_remote_naive(live_hub): + assert hub.tz() -def test_hub_id_to_name(tmphub): - assert hub.name(tmphub.id) == tmphub.name +def test_hub_id_to_name(tmp_hub): + assert hub.name(tmp_hub.id) == tmp_hub.name -def test_hub_name_to_id(tmphub): - assert hub.getHubId(tmphub.name) == tmphub.id +def test_hub_name_to_id(tmp_hub): + assert hub.getHubId(tmp_hub.name) == tmp_hub.id @pytest.mark.live -def test_multisensor(livehub): - data = hub.getDevices() +def test_multisensor(live_hub): + assert hub.ping() + data = hub.devices() 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 - -def test_hub_devices_filter_single(tmphub): - ids, devs = tmphub.devices() - out = hub.devices(hub_id=tmphub.id, capabilities=hub.capability.COLOR_LOOP, mock_devices=devs) +def test_hub_get_id(tmp_hub): + assert hub._get_id(hub_id=tmp_hub.id) == tmp_hub.id + assert hub._get_id(hub_name=tmp_hub.name) == tmp_hub.id + assert hub._get_id(hub_name=tmp_hub.name, hub_id=tmp_hub.id) == tmp_hub.id + assert hub._get_id(hubName=tmp_hub.name) == tmp_hub.id + assert hub._get_id(hubId=tmp_hub.id) == tmp_hub.id + assert hub._get_id() == tmp_hub.id + assert not hub._get_id(hub_id='foo') == tmp_hub.id + +def test_hub_devices_filter_single(tmp_hub): + ids, devs = tmp_hub.devices() + out = hub.devices(hub_id=tmp_hub.id, capabilities=hub.capability.COLOR_LOOP, mock_devices=devs) assert all(i in out for i in [ ids['lamp_osram'], ids['strip_osram'] ]) assert len(out) == 2 -def test_hub_devices_filter_or(tmphub): - ids, devs = tmphub.devices() - out = hub.devices(hub_id=tmphub.id, and_filter=False, capabilities=[hub.capability.TWILIGHT, hub.capability.COLOR_HS], mock_devices=devs) +def test_hub_devices_filter_or(tmp_hub): + ids, devs = tmp_hub.devices() + out = hub.devices(hub_id=tmp_hub.id, and_filter=False, capabilities=[hub.capability.TWILIGHT, hub.capability.COLOR_HS], mock_devices=devs) assert all(i in out for i in [ ids['lamp_osram'], ids['strip_osram'], ids['twilight_nexa'] ]) assert len(out) == 3 -def test_hub_devices_filter_and(tmphub): - ids, devs = tmphub.devices() - out = hub.devices(hub_id=tmphub.id, and_filter=True, capabilities=[hub.capability.COLOR_HS, hub.capability.COLOR_TEMP], mock_devices=devs) +def test_hub_devices_filter_and(tmp_hub): + ids, devs = tmp_hub.devices() + out = hub.devices(hub_id=tmp_hub.id, and_filter=True, capabilities=[hub.capability.COLOR_HS, hub.capability.COLOR_TEMP], mock_devices=devs) assert all(i in out for i in [ ids['lamp_osram'], ids['strip_osram'] ]) assert len(out) == 2 diff --git a/cozify/test/test_hub_api.py b/cozify/test/test_hub_api.py index 9c88fb0..2d5b85a 100755 --- a/cozify/test/test_hub_api.py +++ b/cozify/test/test_hub_api.py @@ -5,10 +5,13 @@ from cozify import cloud, hub, hub_api, config @pytest.mark.live -def test_hub(live_cloud, default_hub): +def test_hub(live_cloud, live_hub): + assert live_hub.ping() + hub_id = live_hub.default() assert hub_api.hub( - host = default_hub.host, - remote = default_hub.remote, - remote_token = live_cloud.token(), - hub_token = default_hub.token + hub_id = hub_id, + host = live_hub.host(hub_id), + remote = live_hub.remote(hub_id), + cloud_token = live_cloud.token(), + hub_token = live_hub.token(hub_id) ) diff --git a/util/auth.py b/util/auth.py new file mode 100755 index 0000000..f2d73c8 --- /dev/null +++ b/util/auth.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +from cozify import cloud + +def main(): + assert cloud.authenticate() + +if __name__ == "__main__": + main() diff --git a/util/capabilities_list.py b/util/capabilities_list.py new file mode 100755 index 0000000..f3a7205 --- /dev/null +++ b/util/capabilities_list.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from cozify import hub +import cozify + +def dedup(seq): + seen = set() + seen_add = seen.add + return [x for x in seq if not (x in seen or seen_add(x))] + +def main(): + capabilities = [] + devs = hub.devices() + for id, dev in devs.items(): + capabilities = capabilities + dev['capabilities']['values'] + + gathered = sorted(dedup(capabilities)) + implemented = [ e.name for e in hub.capability ] + not_implemented = [ item for item in gathered if item not in implemented ] + composite = sorted(implemented + not_implemented) + + print('Capabilities in python-cozify version {0}'.format(cozify.__version__)) + print('implemented ({0}): {1}'.format(len(implemented), implemented)) + print('gathered ({0}): {1}'.format(len(gathered), gathered)) + print('Not currently implemented ({0}): {1}'.format(len(not_implemented), not_implemented)) + print('Fully updated capabilities string({0}): {1}'.format(len(composite), ' '.join(composite))) + +if __name__ == "__main__": + main() diff --git a/util/cleanSlate.py b/util/cleanSlate.py index e622727..b33cb6a 100755 --- a/util/cleanSlate.py +++ b/util/cleanSlate.py @@ -8,7 +8,7 @@ def main(): assert cloud.authenticate() config.dump_state() - print(hub.tz(hub.getDefaultHub())) + print(hub.tz(hub.default())) os.remove(tmp) if __name__ == "__main__": diff --git a/util/devicedata.py b/util/devicedata.py index 327675c..3d9b095 100755 --- a/util/devicedata.py +++ b/util/devicedata.py @@ -3,7 +3,7 @@ import pprint, sys def main(device): - devs = hub.getDevices() + devs = hub.devices() pprint.pprint(devs[device]) if __name__ == "__main__": diff --git a/util/devicelist.py b/util/devicelist.py index e03d4fc..9c82796 100755 --- a/util/devicelist.py +++ b/util/devicelist.py @@ -1,11 +1,19 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 from cozify import hub +import sys -def main(): - devs = hub.getDevices() +def main(capability=None): + devs = None + if capability: + devs = hub.devices(capabilities=hub.capability[capability]) + else: + devs = hub.devices() for key, dev in devs.items(): print('{0}: {1}'.format(key, dev['name'])) if __name__ == "__main__": + if len(sys.argv) > 1: + main(sys.argv[1]) + else: main() diff --git a/util/remoter.py b/util/remoter.py index 4e9224b..fcb8d45 100755 --- a/util/remoter.py +++ b/util/remoter.py @@ -4,14 +4,14 @@ from cozify.test import debug def main(): - hub_id = hub.getDefaultHub() + hub_id = hub.default() - hub_api.tz( + print(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/toggle.py similarity index 55% rename from util/temperature_sensors.py rename to util/toggle.py index cf2fffc..7c30ba4 100755 --- a/util/temperature_sensors.py +++ b/util/toggle.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 from cozify import hub -import pprint +import pprint, sys -def main(): - sensors = hub.devices(capability=hub.capability.TEMPERATURE) - pprint.pprint(sensors) +from cozify.test import debug + +def main(device): + hub.toggle(device) if __name__ == "__main__": - main() + if len(sys.argv) > 1: + main(sys.argv[1]) + else: + sys.exit(1) diff --git a/util/tokenExplorer.py b/util/tokenExplorer.py index 911fe9d..49f54ea 100755 --- a/util/tokenExplorer.py +++ b/util/tokenExplorer.py @@ -8,7 +8,7 @@ def main(statepath): config.setStatePath(statepath) cloud_token = cloud.token() - hub_id = hub.getDefaultHub() + hub_id = hub.default() hub_token = hub.token(hub_id) pp = pprint.PrettyPrinter(indent=2) diff --git a/util/versionExplorer.py b/util/versionExplorer.py index 230aca9..786520d 100755 --- a/util/versionExplorer.py +++ b/util/versionExplorer.py @@ -5,7 +5,7 @@ from cozify.Error import APIError def main(start=hub_api.apiPath): - id = hub.getDefaultHub() + id = hub.default() host = hub.host(id) token = hub.token(id) api = start