diff --git a/.gitignore b/.gitignore index 1197d68..4c919de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.cfg *.pyc *.egg-info +.eggs MANIFEST dist/ build/ diff --git a/README.rst b/README.rst index b87c4b3..7a73fb5 100644 --- a/README.rst +++ b/README.rst @@ -147,9 +147,9 @@ 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. -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. +pytest is used for unit tests. +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. +Some tests are marked as "destructive" and will cause changes such as a light being turned on or tokens getting invalidated on purpose. During development you can run the test suite right from the source directory: @@ -158,14 +158,14 @@ During development you can run the test suite right from the source directory: pytest -v cozify/ # or include the live tests as well: pytest -v cozify/ --live + # or for the brave, also run destructive tests (also implies --live): + pytest -v cozify/ --destructive 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 Roadmap, aka. Current Limitations diff --git a/cozify/__init__.py b/cozify/__init__.py index 11ef092..f3291e9 100644 --- a/cozify/__init__.py +++ b/cozify/__init__.py @@ -1 +1 @@ -__version__ = "0.2.13" +__version__ = "0.2.14" diff --git a/cozify/cloud.py b/cozify/cloud.py index 2eab10c..fcdf072 100644 --- a/cozify/cloud.py +++ b/cozify/cloud.py @@ -1,7 +1,8 @@ """Module for handling Cozify Cloud highlevel operations. """ -import logging, datetime +from absl import logging +import datetime from . import config from . import hub_api @@ -50,7 +51,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): otp = _getotp() if not otp: message = "OTP unavailable, authentication cannot succeed. This may happen if running non-interactively (closed stdin)." - logging.critical(message) + logging.fatal(message) raise AuthenticationError(message) try: @@ -72,23 +73,26 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): # 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 = 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.') + logging.fatal('You have not registered any hubs to the Cozify Cloud, hence a hub cannot be used yet.') # evaluate all returned Hubs and store them - logging.debug('Listing all hubs returned by cloud hubkeys query:') for hub_id, hub_token in hubkeys.items(): logging.debug('hub: {0} token: {1}'.format(hub_id, hub_token)) hub_info = None hub_ip = None + if not hub.exists(hub_id): + autoremote = True + else: + autoremote = hub.autoremote(hub_id=hub_id) # 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.') + logging.info('No local Hubs detected, changing to remote mode.') 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(hub_id) and not hub.remote(hub_id): + # if the hub wants autoremote we flip the state. If this is the first time the hub is seen, act as if autoremote=True, remote=False + if not hub.exists(hub_id) or (hub.autoremote(hub_id) and not hub.remote(hub_id)): logging.info('[autoremote] Flipping hub remote status from local to remote.') - hub.remote(hub_id, True) + remote = 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,10 +100,10 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): logging.debug('data structure: {0}'.format(localHubs)) 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(hub_id) and hub.remote(hub_id): + # if the hub wants autoremote we flip the state. If this is the first time the hub is seen, act as if autoremote=True, remote=False + if not hub.exists(hub_id) or (hub.autoremote(hub_id) and hub.remote(hub_id)): logging.info('[autoremote] Flipping hub remote status from remote to local.') - hub.remote(hub_id, False) + remote = False hub_name = hub_info['name'] if hub_id in hubkeys: @@ -121,6 +125,7 @@ def authenticate(trustCloud=True, trustHub=True, remote=False, autoremote=True): hub._setAttr(hub_id, 'host', hub_ip, commit=False) hub._setAttr(hub_id, 'hubName', hub_name, commit=False) hub.token(hub_id, hub_token) + hub.remote(hub_id, remote) return True def resetState(): @@ -258,7 +263,7 @@ def _need_hub_token(trust=True): logging.debug("We don't have a valid hubtoken or it's not trusted.") return True else: # if we have a token, we need to test if the API is callable - ping = hub.ping() + ping = hub.ping(autorefresh=False) # avoid compliating things by disabling autorefresh on failure. logging.debug("Testing hub.ping() for hub_token validity: {0}".format(ping)) return not ping diff --git a/cozify/conftest.py b/cozify/conftest.py index a791577..96b5936 100644 --- a/cozify/conftest.py +++ b/cozify/conftest.py @@ -4,11 +4,21 @@ def pytest_addoption(parser): parser.addoption("--live", action="store_true", default=False, help="run tests requiring a functional auth and a real hub.") + parser.addoption("--destructive", action="store_true", + default=False, help="run tests that require and modify the state of a real hub.") def pytest_collection_modifyitems(config, items): + live = False + destructive = False if config.getoption("--live"): + live = True + if config.getoption("--destructive"): return skip_live = pytest.mark.skip(reason="need --live option to run") + skip_destructive = pytest.mark.skip(reason="need --destructive option to run") + for item in items: - if "live" in item.keywords: + if "live" in item.keywords and not live: item.add_marker(skip_live) + if "destructive" in item.keywords and not destructive: + item.add_marker(skip_destructive) diff --git a/cozify/hub.py b/cozify/hub.py index 738fcea..5ef0db0 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -5,43 +5,17 @@ """ -import logging +from absl import logging import math from . import config from . import hub_api from enum import Enum - from .Error import APIError 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 LUX MOISTURE MOTION 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. - - 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 - - """ - 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) - hub_token = token(hub_id) - cloud_token = cloud.token() - hostname = host(hub_id) - - if 'remote' not in kwargs: - kwargs['remote'] = remote - - return devices(**kwargs) +### Device data ### 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. @@ -72,6 +46,57 @@ def devices(*, capabilities=None, and_filter=False, **kwargs): else: # no filtering return devs +def device_reachable(device_id, **kwargs): + _fill_kwargs(kwargs) + state = {} + if device_exists(device_id, state=state, **kwargs): + return state['reachable'] + else: + raise ValueError('Device not found: {}'.format(device_id)) + +def device_exists(device_id, devs=None, state=None, **kwargs): + """Check if device exists. + + Args: + device_id(str): ID of the device to check. + devs(dict): Optional devices dictionary to use. If not defined, will be retrieved live. + state(dict): Optional state dictionary, will be populated with state of checked device if device is eligible. + Returns: + bool: True if filter matches. + """ + if devs is None: # only retrieve if we didn't get them + devs = devices(**kwargs) + if device_id in devs: + if state is not None: + state.update(devs[device_id]['state']) + logging.debug('Implicitly returning state: {0}'.format(state)) + return True + else: + return False + +def device_eligible(device_id, capability_filter, devs=None, state=None, **kwargs): + """Check if device matches a AND devices filter. + + Args: + device_id(str): ID of the device to check. + capability_filter(hub.capability): Single hub.capability or a list of them to match against. + devs(dict): Optional devices dictionary to use. If not defined, will be retrieved live. + state(dict): Optional state dictionary, will be populated with state of checked device if device is eligible. + Returns: + bool: True if filter matches. + """ + if devs is None: # only retrieve if we didn't get them + devs = devices(capabilities=capability_filter, **kwargs) + if device_id in devs: + if state is not None: + state.update(devs[device_id]['state']) + logging.debug('Implicitly returning state: {0}'.format(state)) + return True + else: + return False + +### Device control ### + def device_toggle(device_id, **kwargs): """Toggle power state of any device capable of it such as lamps. Eligibility is determined by the capability ON_OFF. @@ -98,10 +123,10 @@ def device_on(device_id, **kwargs): device_id(str): ID of the device to operate on. """ _fill_kwargs(kwargs) - if _is_eligible(device_id, capability.ON_OFF, **kwargs): + if device_eligible(device_id, capability.ON_OFF, **kwargs): hub_api.devices_command_on(device_id, **kwargs) else: - raise AttributeError('Device not found or not eligible for action.') + raise ValueError('Device not found or not eligible for action.') def device_off(device_id, **kwargs): """Turn off a device that is capable of turning off. Eligibility is determined by the capability ON_OFF. @@ -110,10 +135,10 @@ def device_off(device_id, **kwargs): device_id(str): ID of the device to operate on. """ _fill_kwargs(kwargs) - if _is_eligible(device_id, capability.ON_OFF, **kwargs): + if device_eligible(device_id, capability.ON_OFF, **kwargs): hub_api.devices_command_off(device_id, **kwargs) else: - raise AttributeError('Device not found or not eligible for action.') + raise ValueError('Device not found or not eligible for action.') def light_temperature(device_id, temperature=2700, transition=0, **kwargs): """Set temperature of a light. @@ -124,8 +149,8 @@ def light_temperature(device_id, temperature=2700, transition=0, **kwargs): transition(int): Transition length in milliseconds. Defaults to instant. """ _fill_kwargs(kwargs) - state = {} # will be populated by _is_eligible - if _is_eligible(device_id, capability.COLOR_TEMP, state=state, **kwargs): + state = {} # will be populated by device_eligible + if device_eligible(device_id, capability.COLOR_TEMP, state=state, **kwargs): # Make sure temperature is within bounds [state.minTemperature, state.maxTemperature] minimum = state['minTemperature'] maximum = state['maxTemperature'] @@ -142,25 +167,25 @@ def light_temperature(device_id, temperature=2700, transition=0, **kwargs): state['transitionMsec'] = transition hub_api.devices_command_state(device_id=device_id, state=state, **kwargs) else: - raise AttributeError('Device not found or not eligible for action.') + raise ValueError('Device not found or not eligible for action.') def light_color(device_id, hue, saturation=1.0, transition=0, **kwargs): """Set color (hue & saturation) of a light. Args: device_id(str): ID of the device to operate on. - hue(float): Hue in the range of [0, Pi*2]. If outside the range an AttributeError is raised. - saturation(float): Saturation in the range of [0, 1]. If outside the range an AttributeError is raised. Defaults to 1.0 (full saturation.) + hue(float): Hue in the range of [0, Pi*2]. If outside the range a ValueError is raised. + saturation(float): Saturation in the range of [0, 1]. If outside the range a ValueError is raised. Defaults to 1.0 (full saturation.) transition(int): Transition length in milliseconds. Defaults to instant. """ _fill_kwargs(kwargs) - state = {} # will be populated by _is_eligible - if _is_eligible(device_id, capability.COLOR_HS, state=state, **kwargs): + state = {} # will be populated by device_eligible + if device_eligible(device_id, capability.COLOR_HS, state=state, **kwargs): # Make sure hue & saturation are within bounds if hue < 0 or hue > math.pi * 2: - raise AttributeError('Hue out of bounds [0, pi*2]: {0}'.format(hue)) + raise ValueError('Hue out of bounds [0, pi*2]: {0}'.format(hue)) elif saturation < 0 or saturation > 1.0: - raise AttributeError('Saturation out of bounds [0, 1.0]: {0}'.format(saturation)) + raise ValueError('Saturation out of bounds [0, 1.0]: {0}'.format(saturation)) state = _clean_state(state) state['colorMode'] = 'hs' @@ -168,139 +193,139 @@ def light_color(device_id, hue, saturation=1.0, transition=0, **kwargs): state['saturation'] = saturation hub_api.devices_command_state(device_id=device_id, state=state, **kwargs) else: - raise AttributeError('Device not found or not eligible for action.') + raise ValueError('Device not found or not eligible for action.') def light_brightness(device_id, brightness, transition=0, **kwargs): """Set brightness of a light. Args: device_id(str): ID of the device to operate on. - brightness(float): Brightness in the range of [0, 1]. If outside the range an AttributeError is raised. + brightness(float): Brightness in the range of [0, 1]. If outside the range a ValueError is raised. transition(int): Transition length in milliseconds. Defaults to instant. """ _fill_kwargs(kwargs) - state = {} # will be populated by _is_eligible - if _is_eligible(device_id, capability.BRIGHTNESS, state=state, **kwargs): + state = {} # will be populated by device_eligible + if device_eligible(device_id, capability.BRIGHTNESS, state=state, **kwargs): # Make sure hue & saturation are within bounds if brightness < 0 or brightness > 1.0: - raise AttributeError('Brightness out of bounds [0, 1.0]: {0}'.format(brightness)) + raise ValueError('Brightness out of bounds [0, 1.0]: {0}'.format(brightness)) state = _clean_state(state) state['brightness'] = brightness hub_api.devices_command_state(device_id=device_id, state=state, **kwargs) else: - raise AttributeError('Device not found or not eligible for action.') + raise ValueError('Device not found or not eligible for action.') -def _is_eligible(device_id, capability_filter, devs=None, state=None, **kwargs): - """Check if device matches a AND devices filter. +### Hub modifiers ### + +def remote(hub_id, new_state=None): + """Get remote status of matching hub_id or set a new value for it. Args: - device_id(str): ID of the device to check. - filter(hub.capability): Single hub.capability or a list of them to match against. - devs(dict): Optional devices dictionary to use. If not defined, will be retrieved live. - state(dict): Optional state dictionary, will be populated with state of checked device if device is eligible. + 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 if filter matches. + bool: True for a hub considered remote. """ - if devs is None: # only retrieve if we didn't get them - devs = devices(capabilities=capability_filter, **kwargs) - if device_id in devs: - state.update(devs[device_id]['state']) - logging.debug('Implicitly returning state: {0}'.format(state)) - return True - else: - return False - + if new_state: + _setAttr(hub_id, 'remote', new_state) + return _getAttr(hub_id, 'remote', default=False, boolean=True) -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. +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): 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 + 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 '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 hub_id(kwargs['hub_name']) - return getHubId(kwargs['hubName']) - return default() + if new_state: + _setAttr(hub_id, 'autoremote', new_state) + return _getAttr(hub_id, 'autoremote', default=True, boolean=True) -def _fill_kwargs(kwargs): - """Check that common items are present in kwargs and fill them if not. +### Hub info ### +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: - kwargs(dict): kwargs dictionary to fill. Operated on directly. + **hub_id(str): Hub to query, by default the default hub is used. + Returns: + str: Timezone of the hub, for example: 'Europe/Helsinki' """ - 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']) + _fill_kwargs(kwargs) -def _clean_state(state): - """Return purged state of values so only wanted values can be modified. + return hub_api.tz(**kwargs) + +def ping(autorefresh=True, **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: - state(dict): device state dictionary. Original won't be modified. + autorefresh(bool): Wether to perform a autorefresh after an initially failed ping. If successful, will still return True. Defaults to True. + **hub_id(str): Hub to ping or default if neither id or name set. + **hub_name(str): Hub to ping by name. + + Returns: + bool: True for a valid and working hub authentication state. """ - 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 + try: + _fill_kwargs(kwargs) # this can raise an APIError if hub_token has expired + 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(**kwargs) + logging.debug('Ping performed with tz call, response: {0}'.format(timezone)) + except APIError as e: + if e.status_code == 401 or e.status_code == 403: + if autorefresh: + from cozify import cloud + logging.warn('Hub token has expired, hub.ping() attempting to renew it.') + logging.debug('Original APIError was: {0}'.format(e)) + if cloud.authenticate(trustHub=False): # if this fails we let it fail. + return True + logging.warn(e) + return False + else: + raise + else: + return True +def name(hub_id): + """Get hub name by it's id. -def getDefaultHub(): - """Deprecated, use default(). Return id of default Hub. + Args: + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. + + Returns: + str: Hub name or None if the hub wasn't found. """ - logging.warn('hub.getDefaultHub is deprecated and will be removed soon. Use hub.default()') - return default() + return _getAttr(hub_id, 'hubname') -def default(): - """Return id of default Hub. +def host(hub_id): + """Get hostname of matching hub_id - If default hub isn't known an AttributeError will be raised. - """ + Args: + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. - if 'default' not in config.state['Hubs']: - logging.critical('Default hub not known, you should run cozify.authenticate()') - raise AttributeError - else: - return config.state['Hubs']['default'] + Returns: + str: ip address of matching hub. Be aware that this may be empty if the hub is only known remotely and will still give you an ip address even if the hub is currently remote and an ip address was previously locally known. + """ + return _getAttr(hub_id, 'host') -def getHubId(hub_name): - """Deprecated, use hub_id(). Return id of hub by it's name. +def token(hub_id, new_token=None): + """Get hub_token of matching hub_id or set a new value for it. Args: - hub_name(str): Name of hub to query. The name is given when registering a hub to an account. - str: hub_id on success, raises an attributeerror on failure. + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. Returns: - str: Hub id or raises + str: Hub authentication token. """ - logging.warn('hub.getHubId is deprecated and will be removed soon. Use hub.hub_id()') - return hub_id(hub_name) + if new_token: + _setAttr(hub_id, 'hubtoken', new_token) + return _getAttr(hub_id, 'hubtoken') def hub_id(hub_name): """Get hub id by it's name. @@ -319,6 +344,31 @@ def hub_id(hub_name): return section[5:] # cut out "Hubs." raise AttributeError('Hub not found: {0}'.format(hub_name)) +def exists(hub_id): + """Check for existance of hub in local state. + + Args: + hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. + """ + if 'Hubs.{0}'.format(hub_id) in config.state: + return True + else: + return False + +def default(): + """Return id of default Hub. + + If default hub isn't known an AttributeError will be raised. + """ + + if 'default' not in config.state['Hubs']: + logging.fatal('Default hub not known, you should run cozify.authenticate()') + raise AttributeError + else: + return config.state['Hubs']['default'] + +### Internals ### + 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. @@ -367,105 +417,108 @@ def _setAttr(hub_id, attr, value, commit=True): logging.warning('Section {0} not found in state.'.format(section)) raise AttributeError - -def name(hub_id): - """Get hub name by it's id. +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): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. - - Returns: - str: Hub name or None if the hub wasn't found. + **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 """ - return _getAttr(hub_id, 'hubname') + 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 hub_id(kwargs['hub_name']) + return getHubId(kwargs['hubName']) + return default() -def host(hub_id): - """Get hostname of matching hub_id +def _fill_kwargs(kwargs): + """Check that common items are present in kwargs and fill them if not. Args: - hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. + kwargs(dict): kwargs dictionary to fill. Operated on directly. - Returns: - str: ip address of matching hub. Be aware that this may be empty if the hub is only known remotely and will still give you an ip address even if the hub is currently remote and an ip address was previously locally known. """ - return _getAttr(hub_id, 'host') + 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 token(hub_id, new_token=None): - """Get hub_token of matching hub_id or set a new value for it. +def _clean_state(state): + """Return purged state of values so only wanted values can be modified. Args: - hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. - - Returns: - str: Hub authentication token. + state(dict): device state dictionary. Original won't be modified. """ - if new_token: - _setAttr(hub_id, 'hubtoken', new_token) - return _getAttr(hub_id, 'hubtoken') - -def remote(hub_id, new_state=None): - """Get remote status of matching hub_id or set a new value for it. + 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 - Args: - hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. +### Deprecated functions, will be removed in v0.3. Until then they'll merely cause a logging WARN to be emitted. - Returns: - 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. +def getDevices(**kwargs): + """Deprecated, will be removed in v0.3. Get up to date full devices data set as a dict. Args: - hub_id(str): Id of hub to query. The id is a string of hexadecimal sections used internally to represent a hub. + **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: - bool: True for a hub with autoremote enabled. + dict: full live device state as returned by the API + """ - if new_state: - _setAttr(hub_id, 'autoremote', new_state) - return _getAttr(hub_id, 'autoremote', default=True, boolean=True) + 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 -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() + hub_id = _get_id(**kwargs) + hub_token = token(hub_id) + cloud_token = cloud.token() + hostname = host(hub_id) - Args: - **hub_id(str): Hub to ping or default if neither id or name set. - **hub_name(str): Hub to ping by name. + if 'remote' not in kwargs: + kwargs['remote'] = remote - Returns: - bool: True for a valid and working hub authentication state. - """ - _fill_kwargs(kwargs) - try: - 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(**kwargs) - logging.debug('Ping performed with tz call, response: {0}'.format(timezone)) - except APIError as e: - if e.status_code == 401: - logging.warn(e) - return False - else: - raise - else: - return True + return devices(**kwargs) +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 tz(**kwargs): - """Get timezone of given hub or default hub if no id is specified. For more optional kwargs see cozify.hub_api.get() +def getHubId(hub_name): + """Deprecated, use hub_id(). Return id of hub by it's name. Args: - **hub_id(str): Hub to query, by default the default hub is used. + hub_name(str): Name of hub to query. The name is given when registering a hub to an account. + str: hub_id on success, raises an attributeerror on failure. Returns: - str: Timezone of the hub, for example: 'Europe/Helsinki' + str: Hub id or raises """ - _fill_kwargs(kwargs) - - return hub_api.tz(**kwargs) + logging.warn('hub.getHubId is deprecated and will be removed soon. Use hub.hub_id()') + return hub_id(hub_name) diff --git a/cozify/test/debug.py b/cozify/test/debug.py index c67067b..eb9da06 100755 --- a/cozify/test/debug.py +++ b/cozify/test/debug.py @@ -2,6 +2,6 @@ """Set high log level """ -import logging +from absl import logging -logging.basicConfig(level=logging.DEBUG) +logging.set_verbosity(logging.DEBUG) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 0b7265c..ef37bb7 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -31,6 +31,7 @@ def live_cloud(): from cozify import cloud yield cloud config.setStatePath() + os.remove(configpath) @pytest.fixture def id(): diff --git a/cozify/test/fixtures_devices.py b/cozify/test/fixtures_devices.py index 3c3a297..f34e399 100644 --- a/cozify/test/fixtures_devices.py +++ b/cozify/test/fixtures_devices.py @@ -165,5 +165,7 @@ 'lamp_osram': lamp_osram['id'], 'strip_osram': strip_osram['id'], 'plafond_osram': plafond_osram['id'], - 'twilight_nexa': twilight_nexa['id'] + 'twilight_nexa': twilight_nexa['id'], + 'reachable': plafond_osram['id'], + 'not-reachable': lamp_osram['id'] } diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 66d9a68..16b5160 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -53,3 +53,25 @@ def test_hub_devices_filter_and(tmp_hub): 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 + +@pytest.mark.destructive +def test_hub_ping_autorefresh(live_hub): + hub_id = live_hub.default() + live_hub.token(hub_id=hub_id, new_token='destroyed-on-purpose-by-destructive-test') + assert not live_hub.ping(autorefresh=False) + assert live_hub.ping(autorefresh=True) + +def test_hub_device_eligible(tmp_hub): + ids, devs = tmp_hub.devices() + assert hub.device_eligible(ids['lamp_osram'], hub.capability.COLOR_TEMP, mock_devices=devs) + assert not hub.device_eligible(ids['twilight_nexa'], hub.capability.COLOR_TEMP, mock_devices=devs) + +def test_hub_device_reachable(tmp_hub): + ids, devs = tmp_hub.devices() + assert hub.device_reachable(ids['reachable'], mock_devices=devs) + assert not hub.device_reachable(ids['not-reachable'], mock_devices=devs) + +def test_hub_device_exists(tmp_hub): + ids, devs = tmp_hub.devices() + assert hub.device_exists(ids['reachable'], mock_devices=devs) + assert not hub.device_exists('dead-beef', mock_devices=devs) diff --git a/setup.py b/setup.py index c00c308..8bdb2eb 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def run_tests(self): license = 'MIT', packages = ['cozify'], tests_require=['pytest'], - install_requires=['requests'], + install_requires=['requests', 'absl-py'], cmdclass={'test': PyTest}, classifiers = [ "Development Status :: 3 - Alpha", diff --git a/util/cleanSlate.py b/util/cleanSlate.py index b33cb6a..a78d464 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.default())) + print(hub.tz()) os.remove(tmp) if __name__ == "__main__": diff --git a/util/device-toggle.py b/util/device-toggle.py index ab85af0..2915cb9 100755 --- a/util/device-toggle.py +++ b/util/device-toggle.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 from cozify import hub import pprint, sys +from absl import flags, app from cozify.test import debug -def main(device): - hub.device_toggle(device) +FLAGS = flags.FLAGS + +flags.DEFINE_string('device', None, 'Device to operate on.') + +def main(argv): + del argv + hub.device_toggle(FLAGS.device) if __name__ == "__main__": - if len(sys.argv) > 1: - main(sys.argv[1]) - else: - sys.exit(1) + flags.mark_flag_as_required('device') + app.run(main)