From 969b489f6754126744492fc3238cf711dd163536 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Wed, 21 Feb 2018 18:36:20 +0200 Subject: [PATCH 01/48] Use deferred imports to prevent a circular dependency seen by some users. --- cozify/cloud.py | 5 ++++- cozify/hub.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cozify/cloud.py b/cozify/cloud.py index 6f91505..181d33a 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') @@ -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/hub.py b/cozify/hub.py index 1ca20b7..7b9f90d 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -9,7 +9,6 @@ import requests, logging from . import config -from . import cloud from . import hub_api from enum import Enum @@ -35,6 +34,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) @@ -63,6 +63,7 @@ def devices(*, capabilities=None, and_filter=False, **kwargs): dict: full live device state as returned by the API """ + from . import cloud hub_id = _get_id(**kwargs) hub_token = token(hub_id) cloud_token = cloud.token() @@ -254,6 +255,7 @@ def tz(hub_id=None, **kwargs): Returns: str: Timezone of the hub, for example: 'Europe/Helsinki' """ + from . import cloud if not hub_id: hub_id = getDefaultHub() From 733556565aa2d4645ba578434138a5a25562e560 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 16:30:05 +0200 Subject: [PATCH 02/48] Improve and modernize basic read example --- README.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 623d456..21e393c 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 ~~~~~~~~~~~~~~~~~ From 1043dabdaebb6fe41bf103a2bc1b355c4541b1bb Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 16:32:34 +0200 Subject: [PATCH 03/48] Utility to extract and display capabilities available in a live system Sort of works but the lists don't stay in the same order making it a pain to use --- util/capabilities_list.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100755 util/capabilities_list.py diff --git a/util/capabilities_list.py b/util/capabilities_list.py new file mode 100755 index 0000000..faad70e --- /dev/null +++ b/util/capabilities_list.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +from cozify import hub +from collections import OrderedDict + +def sort(tosort): + return list(OrderedDict.fromkeys(tosort)) + + +def main(): + capabilities = [] + devs = hub.getDevices() + for id, dev in devs.items(): + capabilities = capabilities + dev['capabilities']['values'] + + gathered = sort(capabilities) + implemented = sort([ e.name for e in hub.capability ]) + not_implemented = [item for item in gathered if item not in implemented] + composite = sort(implemented + not_implemented) + + 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}'.format(' '.join(composite))) + +if __name__ == "__main__": + main() From f533bca40c4e17eb5623db47b18b816a54fa83f0 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 17:13:38 +0200 Subject: [PATCH 04/48] Improved version of capabilities_list util, now things stay sorted --- util/capabilities_list.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/util/capabilities_list.py b/util/capabilities_list.py index faad70e..f3a7205 100755 --- a/util/capabilities_list.py +++ b/util/capabilities_list.py @@ -1,26 +1,28 @@ #!/usr/bin/env python3 from cozify import hub -from collections import OrderedDict - -def sort(tosort): - return list(OrderedDict.fromkeys(tosort)) +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.getDevices() + devs = hub.devices() for id, dev in devs.items(): capabilities = capabilities + dev['capabilities']['values'] - gathered = sort(capabilities) - implemented = sort([ e.name for e in hub.capability ]) - not_implemented = [item for item in gathered if item not in implemented] - composite = sort(implemented + not_implemented) + 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}'.format(' '.join(composite))) + print('Fully updated capabilities string({0}): {1}'.format(len(composite), ' '.join(composite))) if __name__ == "__main__": main() From 78d2a1834c0686a23c3430a9b4cc27d0502ce73b Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 17:15:29 +0200 Subject: [PATCH 05/48] Add all discovered capabilities from my own system as available capabilities --- cozify/hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cozify/hub.py b/cozify/hub.py index 7b9f90d..cb13c0e 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -18,7 +18,7 @@ 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. From 52d6ba298aa019bc859ba86c77925c846cc07aa2 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 17:31:39 +0200 Subject: [PATCH 06/48] More documentation on capabilities --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index 21e393c..2f4a397 100644 --- a/README.rst +++ b/README.rst @@ -58,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](cozify/hub.py#L22) + +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. From 3b79a4f1d9311477d3800e203784548458547627 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 17:35:19 +0200 Subject: [PATCH 07/48] Use rst instead of md --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2f4a397..bb8c15b 100644 --- a/README.rst +++ b/README.rst @@ -61,7 +61,7 @@ authenticate with a non-default state storage 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](cozify/hub.py#L22) +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. From 0723050a1ace46ec989218004d4bba37a4e1c16d Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 17:36:43 +0200 Subject: [PATCH 08/48] Correct line of code for capabilities --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index bb8c15b..68e7f1d 100644 --- a/README.rst +++ b/README.rst @@ -61,7 +61,7 @@ authenticate with a non-default state storage 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 `_ +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. From b5658c8cfe7eba6b554bc447eef8d5f705313b41 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 19:52:47 +0200 Subject: [PATCH 09/48] Not needed as an util anymore --- util/temperature_sensors.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 util/temperature_sensors.py diff --git a/util/temperature_sensors.py b/util/temperature_sensors.py deleted file mode 100755 index cf2fffc..0000000 --- a/util/temperature_sensors.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/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() From 0ddcb5fe3c43e6ee84ebb5f3176e2c985f1ee2c8 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 19:56:55 +0200 Subject: [PATCH 10/48] Change test device name to include unicode --- cozify/test/fixtures_devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 3aef35764d3456cebef48db0c8c8ecf6377462f5 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:14:20 +0200 Subject: [PATCH 11/48] Requests no longer used at this level --- cozify/hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cozify/hub.py b/cozify/hub.py index cb13c0e..2f63b5e 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -7,7 +7,7 @@ """ -import requests, logging +import logging from . import config from . import hub_api from enum import Enum From 9227e7f91eb0f08475baad0350af6b36f87d2821 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:15:37 +0200 Subject: [PATCH 12/48] Fixture to access individual test devices directly --- cozify/test/fixtures.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 6cc0dbd..3c294d2 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -41,6 +41,10 @@ def tmphub(scope='module'): def id(scope='module'): return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' +@pytest.fixture +def devices(scope='module'): + return dev + @pytest.fixture def livehub(scope='module'): config.setStatePath() # default config assumed to be live From 17b7bef96a8b287fcd82bf3d106f5d0b1da75fc2 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:31:48 +0200 Subject: [PATCH 13/48] Add a blurb on dealing with locales --- README.rst | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 68e7f1d..f54adf5 100644 --- a/README.rst +++ b/README.rst @@ -60,7 +60,7 @@ authenticate with a non-default state storage On Capabilities --------------- -The most practical way to "find" devices for operating on is currently to filter the devices list by their capabilties. The +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 @@ -105,6 +105,21 @@ And the expiry duration can be altered (also when calling cloud.ping()): # or cloud.ping(autorefresh=True, expiry=datetime.timedelta(days=20)) +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: + +.. code:: python + + 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:: shell + LC_ALL='en_US.utf8' python3 program.py + Sample projects --------------- @@ -117,7 +132,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. From 44a0fd344801a470e1a166f675fae0910c772401 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:33:23 +0200 Subject: [PATCH 14/48] Fix shell block --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index f54adf5..18f28e7 100644 --- a/README.rst +++ b/README.rst @@ -117,6 +117,7 @@ If not, you will run into trouble printing for example device names with non-asc 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:: shell LC_ALL='en_US.utf8' python3 program.py From 6cee3bc9b86725b0707460f69b0971495c1f157d Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:34:58 +0200 Subject: [PATCH 15/48] .. and fix indent level --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 18f28e7..113a828 100644 --- a/README.rst +++ b/README.rst @@ -118,8 +118,8 @@ If not, you will run into trouble printing for example device names with non-asc 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:: shell - LC_ALL='en_US.utf8' python3 program.py +.. code:: bash + LC_ALL='en_US.utf8' python3 program.py Sample projects --------------- From 3ee702bc8587e6164bda08307d1f6e94f17b717f Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:37:24 +0200 Subject: [PATCH 16/48] Keep fine tuning formatting --- README.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 113a828..f7c750b 100644 --- a/README.rst +++ b/README.rst @@ -111,14 +111,13 @@ The hub provides data encoded as a utf-8 json string. Python-cozify transforms t 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: -.. code:: python - 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 From 55436c066fc6b34be87dceeb172bd4cd86b79605 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:47:24 +0200 Subject: [PATCH 17/48] Marked multisensor for deprecation in v0.3 The new capabilities based device access works way better. --- cozify/multisensor.py | 2 ++ 1 file changed, 2 insertions(+) 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'] From 6daaa9b48907d39a4e67906fbb9eb0ea7450de1a Mon Sep 17 00:00:00 2001 From: Artanicus Date: Fri, 23 Feb 2018 23:48:39 +0200 Subject: [PATCH 18/48] Bump version number for current devel --- cozify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 63c6f95a98fe753835e21bd7aae9ea9ac8597b01 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 19:03:24 +0200 Subject: [PATCH 19/48] Deprecate hub.getDefaultHub(), use hub.default() instead --- cozify/hub.py | 16 +++++++++++----- cozify/test/fixtures.py | 2 +- util/cleanSlate.py | 2 +- util/remoter.py | 2 +- util/tokenExplorer.py | 2 +- util/versionExplorer.py | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cozify/hub.py b/cozify/hub.py index 2f63b5e..4066709 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -103,16 +103,22 @@ def _get_id(**kwargs): if 'hub_name' in kwargs: return getHubId(kwargs['hub_name']) return getHubId(kwargs['hubName']) - return getDefaultHub() + return default() 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'] @@ -221,7 +227,7 @@ def ping(hub_id=None, hub_name=None, **kwargs): hub_id = getHubId(hub_name) if not hub_id and not hub_name: - hub_id = getDefaultHub() + hub_id = default() try: config_name = 'Hubs.' + hub_id hub_token = _getAttr(hub_id, 'hubtoken') @@ -258,7 +264,7 @@ def tz(hub_id=None, **kwargs): from . import cloud if not hub_id: - hub_id = getDefaultHub() + hub_id = default() ip = host(hub_id) hub_token = token(hub_id) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 3c294d2..273a556 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -11,7 +11,7 @@ def default_hub(scope='module'): barehub = lambda:0 config.setStatePath() # reset to default config config.dump_state() - barehub.hub_id = hub.getDefaultHub() + 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) 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/remoter.py b/util/remoter.py index 4e9224b..442c40f 100755 --- a/util/remoter.py +++ b/util/remoter.py @@ -4,7 +4,7 @@ from cozify.test import debug def main(): - hub_id = hub.getDefaultHub() + hub_id = hub.default() hub_api.tz( host = hub.host(hub_id), 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 From 4816529f97af739d9d72d5ec80ac627fa3cee518 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 20:05:15 +0200 Subject: [PATCH 20/48] util/devicelist.py now takes an optional capability parameter --- util/devicelist.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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() From e2d876430c75ad04bd20764be36790d95ea00896 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 21:09:11 +0200 Subject: [PATCH 21/48] Use newer call --- util/devicedata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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__": From 8cb61162c91464f713dcf1024a12c91ab2791a63 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 21:09:51 +0200 Subject: [PATCH 22/48] Ingest dict, provide json to lower level calls --- cozify/hub_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index 83d1d0c..c27d6c0 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -4,7 +4,7 @@ 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 @@ -109,9 +109,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) From c28a12f21d4ff78eaa4148e3880bc32701846418 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 21:10:31 +0200 Subject: [PATCH 23/48] Test utility that will use the new toggle function once it actually works. --- util/toggle.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 util/toggle.py diff --git a/util/toggle.py b/util/toggle.py new file mode 100755 index 0000000..7c30ba4 --- /dev/null +++ b/util/toggle.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +from cozify import hub +import pprint, sys + +from cozify.test import debug + +def main(device): + hub.toggle(device) + +if __name__ == "__main__": + if len(sys.argv) > 1: + main(sys.argv[1]) + else: + sys.exit(1) From 32e22d3b89145cc2a0f1ce4fec50ba42c788eae1 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 21:11:09 +0200 Subject: [PATCH 24/48] Implement hub.toggle(). It doesn't work yet though. --- cozify/hub.py | 76 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/cozify/hub.py b/cozify/hub.py index 4066709..9ba5fb7 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -45,7 +45,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,15 +63,8 @@ def devices(*, capabilities=None, and_filter=False, **kwargs): dict: full live device state as returned by the API """ - from . import cloud - 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)) @@ -84,6 +77,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. @@ -105,6 +123,44 @@ def _get_id(**kwargs): return getHubId(kwargs['hubName']) 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. + + Returns: + dict: Replacement kwargs dictionary with basic values filled. + """ + if 'remote' not in kwargs: + kwargs['remote'] = remote + if 'hub_id' not in kwargs: + kwargs['hub_id'] = _get_id(**kwargs) + 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. """ From 30911f9380731d965484fef76a5cf171c60246e1 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 24 Feb 2018 23:08:14 +0200 Subject: [PATCH 25/48] Basic auth test util --- util/auth.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 util/auth.py 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() From a799f9215d2ac8903697076f945a40b7b2392476 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 18:02:31 +0200 Subject: [PATCH 26/48] Create tmphub fixtures in a temporary cloud instead of assuming one is available --- cozify/test/fixtures.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 273a556..3f68af5 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -79,7 +79,7 @@ def __exit__(self, exc_type, exc_value, traceback): return False class tmp_hub(): - """Creates a temporary hub section (with test data) in the current live state. + """Creates a temporary hub section (with test data) in a tmp_cloud """ def __init__(self): self.id = 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' @@ -88,12 +88,14 @@ def __init__(self): 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 + print('Temporary state:') + config.dump_state() return self def __exit__(self, exc_type, exc_value, traceback): if exc_type is not None: From 78d4f91387a2bba880fe436c04ccffea28c40a13 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 11:07:13 -0500 Subject: [PATCH 27/48] Print remoter output --- util/remoter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/remoter.py b/util/remoter.py index 442c40f..fcb8d45 100755 --- a/util/remoter.py +++ b/util/remoter.py @@ -6,12 +6,12 @@ def main(): 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() From 6e8d1456994a9009b8ed187a45142e331cf4d7c0 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 18:30:01 +0200 Subject: [PATCH 28/48] Move more hub functions to be keyword-only. --- cozify/hub.py | 51 +++++++++++++++------------------------------------ 1 file changed, 15 insertions(+), 36 deletions(-) diff --git a/cozify/hub.py b/cozify/hub.py index 9ba5fb7..5f23092 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -94,7 +94,7 @@ def toggle(device_id, **kwargs): 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, @@ -268,39 +268,29 @@ 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): +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 None. Defaults to None. - hub_name(str): Hub to ping or default if None. Defaults to None. + **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. """ - - if hub_name and not hub_id: - hub_id = getHubId(hub_name) - - if not hub_id and not hub_name: - hub_id = default() + _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: + if not kwargs['remote'] and autoremote and not kwargs['host']: # TODO(artanicus): I'm not sure if the last condition makes sense + global remote remote = 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 @@ -308,26 +298,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' """ - from . import cloud - - if not hub_id: - hub_id = default() - - 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 + _fill_kwargs(kwargs) - return hub_api.tz(host=ip, hub_token=hub_token, cloud_token=cloud_token, **kwargs) + return hub_api.tz(**kwargs) From 4c15f0b7b7c3f660ddaa51af2292af2599e33a21 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 11:53:32 -0500 Subject: [PATCH 29/48] Pass cloud_token with the right name, enables --live tests remotely --- cozify/test/test_hub_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cozify/test/test_hub_api.py b/cozify/test/test_hub_api.py index 9c88fb0..633e72d 100755 --- a/cozify/test/test_hub_api.py +++ b/cozify/test/test_hub_api.py @@ -9,6 +9,6 @@ def test_hub(live_cloud, default_hub): assert hub_api.hub( host = default_hub.host, remote = default_hub.remote, - remote_token = live_cloud.token(), + cloud_token = live_cloud.token(), hub_token = default_hub.token ) From 1ede23fea72406dcdc0e559f749f08a13b1a199f Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 11:54:11 -0500 Subject: [PATCH 30/48] Explicitly cast values to strings. Only an issue with ascii locale machines. --- cozify/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cozify/config.py b/cozify/config.py index ea76ece..2f93d29 100644 --- a/cozify/config.py +++ b/cozify/config.py @@ -63,9 +63,9 @@ 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. From dd1da9ec913fc51d9b7ef3c5cdcc9414d8795869 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 11:55:01 -0500 Subject: [PATCH 31/48] Make cloud_token failures more graceful --- cozify/hub_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index c27d6c0..0a575b3 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -32,7 +32,9 @@ def get(call, hub_token_header=True, base=apiPath, **kwargs): """ response = None headers = None - if kwargs['remote'] and kwargs['cloud_token']: + if kwargs['remote']: + if 'cloud_token' not in kwargs: + raise AttributeError('Asked to do remote call but no cloud_token provided.') response = cloud_api.remote(apicall=base + call, **kwargs) else: if hub_token_header: From 63120257740694775d85a2b10263e9e9c8e69c71 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 19:25:00 +0200 Subject: [PATCH 32/48] Also use autoremote via kwargs, allowing per invocation overriding --- cozify/hub.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cozify/hub.py b/cozify/hub.py index 5f23092..2cc6cbd 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -134,6 +134,8 @@ def _fill_kwargs(kwargs): """ if 'remote' not in kwargs: kwargs['remote'] = remote + if 'autoremote' not in kwargs: + kwargs['autoremote'] = autoremote if 'hub_id' not in kwargs: kwargs['hub_id'] = _get_id(**kwargs) if 'hub_token' not in kwargs: @@ -280,8 +282,8 @@ def ping(**kwargs): """ _fill_kwargs(kwargs) try: - # if we don't have a stored host then we assume the hub is remote - if not kwargs['remote'] and autoremote and not kwargs['host']: # TODO(artanicus): I'm not sure if the last condition makes sense + # if we don't have a stored host then we assume the hub is remote TODO(artanicus): need a second test as well so a failed call will attempt to flip + if not kwargs['remote'] and kwargs['autoremote'] and not kwargs['host']: # TODO(artanicus): I'm not sure if the last condition makes sense global remote remote = True kwargs['remote'] = True From f310c8124ae3e491b91048c49e5c3d56c4ec7f53 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 19:25:47 +0200 Subject: [PATCH 33/48] New test for naive hub calls --- cozify/test/test_hub.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 2b9a879..7b6793d 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -19,6 +19,11 @@ def test_tz(livehub): cloud_token=config.state['Cloud']['remotetoken'] )) +@pytest.mark.live +def test_tz_naive(livehub): + autoremote = False # skip hub.ping() which does remote autodetect + assert hub.tz() + def test_hub_id_to_name(tmphub): assert hub.name(tmphub.id) == tmphub.name @@ -27,7 +32,7 @@ def test_hub_name_to_id(tmphub): @pytest.mark.live def test_multisensor(livehub): - data = hub.getDevices() + data = hub.devices() print(multisensor.getMultisensorData(data)) def test_hub_get_id(tmphub): From e685b5c700b179d1241559a84f90e54f2e4c0962 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 19:26:30 +0200 Subject: [PATCH 34/48] Enable livehub to be able to skip ping Also removed fixture scopes, they were done wrong anyway --- cozify/test/fixtures.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 3f68af5..acdfa9d 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -7,7 +7,7 @@ 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() @@ -19,37 +19,39 @@ def default_hub(scope='module'): 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'): +def live_cloud(): config.setStatePath() # reset to default return cloud @pytest.fixture -def id(scope='module'): +def id(): return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' @pytest.fixture -def tmphub(scope='module'): +def tmphub(): with tmp_hub() as hub: yield hub @pytest.fixture -def id(scope='module'): +def id(): return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' @pytest.fixture -def devices(scope='module'): +def devices(): return dev @pytest.fixture -def livehub(scope='module'): +def livehub(request): config.setStatePath() # default config assumed to be live config.dump_state() # dump state so it's visible in failed test output - assert hub.ping() + autoremote = getattr(request.module, "autoremote", True) # enable skipping ping + if autoremote: + assert hub.ping() return hub class Tmp_cloud(): From cbc31c03958f2c4a2a9e99daf69185e52e648ab8 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 19:33:09 +0200 Subject: [PATCH 35/48] More debug data --- cozify/test/fixtures.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index acdfa9d..d77d6f2 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -51,7 +51,10 @@ def livehub(request): config.dump_state() # dump state so it's visible in failed test output autoremote = getattr(request.module, "autoremote", True) # enable skipping ping if autoremote: + log.debug('Livehub setup checking if connection valid.') assert hub.ping() + else: + log.debug('Livehub setup skipped ping.') return hub class Tmp_cloud(): From 94702fdf121bf82be7e9dbd11a5b271e0e9077b8 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 19:37:26 +0200 Subject: [PATCH 36/48] Fix fixtures logging. Serves me right for not testing --- cozify/test/fixtures.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index d77d6f2..fdd13e8 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import os, pytest, tempfile, datetime +import os, pytest, tempfile, datetime, logging from cozify import conftest, config, hub, cloud @@ -51,10 +51,10 @@ def livehub(request): config.dump_state() # dump state so it's visible in failed test output autoremote = getattr(request.module, "autoremote", True) # enable skipping ping if autoremote: - log.debug('Livehub setup checking if connection valid.') + logging.debug('Livehub setup checking if connection valid.') assert hub.ping() else: - log.debug('Livehub setup skipped ping.') + logging.debug('Livehub setup skipped ping.') return hub class Tmp_cloud(): @@ -80,7 +80,7 @@ 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(): @@ -104,7 +104,7 @@ 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)) + logging.error("%s, %s, %s" % (exc_type, exc_value, traceback)) return False config.state.remove_section(self.section) From 35772f0238962b7e7106d3e9fd55beb0b2008ad8 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 13:45:16 -0500 Subject: [PATCH 37/48] Handle connection issues more gracefully, catch empty hostnames --- cozify/hub_api.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index 0a575b3..90ad149 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -9,6 +9,7 @@ from cozify import cloud_api from .Error import APIError +from requests.exceptions import RequestException apiPath = '/cc/1.8' @@ -37,16 +38,22 @@ def get(call, hub_token_header=True, base=apiPath, **kwargs): raise AttributeError('Asked to do remote call but no cloud_token provided.') 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)) + if kwargs['host']: + if hub_token_header: + headers = _headers(kwargs['hub_token']) + try: + response = requests.get(_getBase(host=kwargs['host'], api=base) + call, headers=headers) + except RequestException as e: + raise APIError('connection failure', 'issues connection to \'{0}\': {1}'.format(kwargs['host'], e)) + else: + 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)) + else: + raise AttributeError('Local call but no hostname was provided. Either set keyword remote or host.') def put(call, payload, hub_token_header=True, base=apiPath, **kwargs): """PUT method for calling hub API. From 0b4f019b87298a69f94ef800efea184f36c1b492 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 13:45:49 -0500 Subject: [PATCH 38/48] Get param correctly --- cozify/test/fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index fdd13e8..546e5c6 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -49,7 +49,7 @@ def devices(): def livehub(request): config.setStatePath() # default config assumed to be live config.dump_state() # dump state so it's visible in failed test output - autoremote = getattr(request.module, "autoremote", True) # enable skipping ping + autoremote = request.param if autoremote: logging.debug('Livehub setup checking if connection valid.') assert hub.ping() From 864c27a012b14356bcae25a919057973cd8b7aa8 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 13:46:15 -0500 Subject: [PATCH 39/48] Parametrize test_tz instead of two different tests --- cozify/test/test_hub.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 7b6793d..93670f3 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -7,23 +7,10 @@ from cozify.test import debug @pytest.mark.live +@pytest.mark.parametrize('livehub', [[True, False]]) # also test skipping hub.ping() which does remote autodetect def test_tz(livehub): 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_tz_naive(livehub): - autoremote = False # skip hub.ping() which does remote autodetect - assert hub.tz() - def test_hub_id_to_name(tmphub): assert hub.name(tmphub.id) == tmphub.name From 916f6f3561896b221a816353e25eb3039b6fd9cc Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 21:06:55 +0200 Subject: [PATCH 40/48] Fix hub_api.get() remote logic, was only briefly broken --- cozify/hub_api.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index 90ad149..5869abc 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -33,27 +33,28 @@ def get(call, hub_token_header=True, base=apiPath, **kwargs): """ response = None headers = None - if kwargs['remote']: + if kwargs['remote']: # remote call if 'cloud_token' not in kwargs: raise AttributeError('Asked to do remote call but no cloud_token provided.') - response = cloud_api.remote(apicall=base + call, **kwargs) - else: - if kwargs['host']: - if hub_token_header: - headers = _headers(kwargs['hub_token']) - try: - response = requests.get(_getBase(host=kwargs['host'], api=base) + call, headers=headers) - except RequestException as e: - raise APIError('connection failure', 'issues connection to \'{0}\': {1}'.format(kwargs['host'], e)) - else: - 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)) - else: + logging.debug('GET turned remote.') + response = cloud_api.remote(apicall=base + call, **kwargs) # should the remote call be also getting the headers? + 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']) + try: + response = requests.get(_getBase(host=kwargs['host'], api=base) + call, headers=headers) + 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: + 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. From 6ae622e56802621a1c5522b5a3605c562d91180d Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 21:16:50 +0200 Subject: [PATCH 41/48] Also allow livehub without parametrization --- cozify/test/fixtures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 546e5c6..1bbe9ba 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -49,7 +49,10 @@ def devices(): def livehub(request): config.setStatePath() # default config assumed to be live config.dump_state() # dump state so it's visible in failed test output - autoremote = request.param + if hasattr(request, 'param'): # can be specified to toggle use of ping + autoremote = request.param + else: + autoremote = True if autoremote: logging.debug('Livehub setup checking if connection valid.') assert hub.ping() From df559c8056b600e65c0ef22328a76cd9f656a633 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 14:18:54 -0500 Subject: [PATCH 42/48] Ignore pytest caches --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5ae0836..1197d68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ MANIFEST dist/ build/ .cache/ +.pytest_cache/ From 598e0894c88a3edc3e96c07ce496e6ce3bc9b6b7 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 16:07:46 -0500 Subject: [PATCH 43/48] Plenty of work to make tests more self-contained Some tests were succeeding when they should have failed and vise versa. --- cozify/test/fixtures.py | 43 +++++++++++++---------------- cozify/test/test_hub.py | 55 ++++++++++++++++++++----------------- cozify/test/test_hub_api.py | 11 +++++--- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 1bbe9ba..6f30e94 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -2,7 +2,7 @@ import os, pytest, tempfile, datetime, logging -from cozify import conftest, config, hub, cloud +from cozify import conftest, config from . import fixtures_devices as dev @@ -11,6 +11,7 @@ def default_hub(): barehub = lambda:0 config.setStatePath() # reset to default config config.dump_state() + from cozify import hub barehub.hub_id = hub.default() barehub.name = hub.name(barehub.hub_id) barehub.host = hub.host(barehub.hub_id) @@ -26,39 +27,32 @@ def tmp_cloud(): @pytest.fixture def live_cloud(): config.setStatePath() # reset to default + from cozify import cloud return cloud @pytest.fixture def id(): - return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' + return 'deadbeef-aaaa-bbbb-cccc-fixtureddddd' -@pytest.fixture -def tmphub(): - with tmp_hub() as hub: - yield hub - -@pytest.fixture -def id(): - return 'deadbeef-aaaa-bbbb-cccc-dddddddddddd' +@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 devices(): return dev -@pytest.fixture -def livehub(request): +@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 - if hasattr(request, 'param'): # can be specified to toggle use of ping - autoremote = request.param - else: - autoremote = True - if autoremote: - logging.debug('Livehub setup checking if connection valid.') - assert hub.ping() - else: - logging.debug('Livehub setup skipped ping.') - return hub + from cozify import hub + yield hub + hub.remote = False # reset remote state at teardown class Tmp_cloud(): """Creates a temporary cloud state with test data. @@ -75,6 +69,7 @@ def __init__(self): 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) @@ -86,11 +81,11 @@ def __exit__(self, exc_type, exc_value, traceback): logging.error("%s, %s, %s" % (exc_type, exc_value, traceback)) return False -class tmp_hub(): +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) diff --git a/cozify/test/test_hub.py b/cozify/test/test_hub.py index 93670f3..87dfda0 100755 --- a/cozify/test/test_hub.py +++ b/cozify/test/test_hub.py @@ -7,44 +7,49 @@ from cozify.test import debug @pytest.mark.live -@pytest.mark.parametrize('livehub', [[True, False]]) # also test skipping hub.ping() which does remote autodetect -def test_tz(livehub): +def test_tz(live_hub): + assert hub.ping() assert hub.tz() -def test_hub_id_to_name(tmphub): - assert hub.name(tmphub.id) == tmphub.name +@pytest.mark.live +def test_remote_naive(live_hub): + assert hub.tz() + +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): +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 633e72d..31af3f6 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, + hub_id = hub_id, + host = live_hub.host, + remote = live_hub.remote, cloud_token = live_cloud.token(), - hub_token = default_hub.token + hub_token = live_hub.token(hub_id) ) From 5cc46c4b3b90ecabc5a02bee63615a9e954bee58 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 23:11:58 +0200 Subject: [PATCH 44/48] Pass value of host instead of the fuction itself --- cozify/test/test_hub_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cozify/test/test_hub_api.py b/cozify/test/test_hub_api.py index 31af3f6..665bbdd 100755 --- a/cozify/test/test_hub_api.py +++ b/cozify/test/test_hub_api.py @@ -10,7 +10,7 @@ def test_hub(live_cloud, live_hub): hub_id = live_hub.default() assert hub_api.hub( hub_id = hub_id, - host = live_hub.host, + host = live_hub.host(hub_id), remote = live_hub.remote, cloud_token = live_cloud.token(), hub_token = live_hub.token(hub_id) From a6b0b27b4c70516cb6eabb0f75113ce9ecf8e703 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 23:47:58 +0200 Subject: [PATCH 45/48] Stop explicitly requiring put mode, autodetect from payload --- cozify/cloud_api.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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) From 046725c05cb6a8fe0fe15c85a7cf32c5b7bd09a4 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sat, 3 Mar 2018 23:48:29 +0200 Subject: [PATCH 46/48] Refactor hub_api.get|put to use a common call interface --- cozify/hub_api.py | 65 ++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/cozify/hub_api.py b/cozify/hub_api.py index 5869abc..0a677f0 100644 --- a/cozify/hub_api.py +++ b/cozify/hub_api.py @@ -31,20 +31,50 @@ 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. """ + 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. 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. + """ + 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 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('GET turned remote.') - response = cloud_api.remote(apicall=base + call, **kwargs) # should the remote call be also getting the headers? + 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']) try: - response = requests.get(_getBase(host=kwargs['host'], api=base) + call, headers=headers) + 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)) @@ -56,35 +86,6 @@ def get(call, hub_token_header=True, base=apiPath, **kwargs): 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() From 599de4ec4a5a7ac4907cc676cf8802635e64e267 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sun, 4 Mar 2018 16:35:20 +0200 Subject: [PATCH 47/48] Move hub remoteness into the state. Enables a mix of remoteness on multiple hubs. Also further improves isolation between tests to lessen spilling over. --- cozify/cloud.py | 8 ++-- cozify/config.py | 8 +++- cozify/hub.py | 73 ++++++++++++++++++++++++++----------- cozify/test/fixtures.py | 20 ++++------ cozify/test/test_cloud.py | 6 +-- cozify/test/test_hub_api.py | 2 +- 6 files changed, 73 insertions(+), 44 deletions(-) diff --git a/cozify/cloud.py b/cozify/cloud.py index 181d33a..2eab10c 100644 --- a/cozify/cloud.py +++ b/cozify/cloud.py @@ -86,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. @@ -97,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: diff --git a/cozify/config.py b/cozify/config.py index 2f93d29..556e793 100644 --- a/cozify/config.py +++ b/cozify/config.py @@ -48,16 +48,20 @@ 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. diff --git a/cozify/hub.py b/cozify/hub.py index 2cc6cbd..a9bbf33 100644 --- a/cozify/hub.py +++ b/cozify/hub.py @@ -1,8 +1,6 @@ """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. """ @@ -15,9 +13,6 @@ from .Error import APIError -remote = False -autoremote = True - 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): @@ -127,17 +122,15 @@ def _fill_kwargs(kwargs): """Check that common items are present in kwargs and fill them if not. Args: - kwargs(dict): kwargs dictionary to fill. + kwargs(dict): kwargs dictionary to fill. Operated on directly. - Returns: - dict: Replacement kwargs dictionary with basic values filled. """ - if 'remote' not in kwargs: - kwargs['remote'] = remote - if 'autoremote' not in kwargs: - kwargs['autoremote'] = autoremote 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: @@ -198,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 @@ -223,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]: @@ -270,6 +275,32 @@ def token(hub_id, new_token=None): _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. + + 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 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) + 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() @@ -282,10 +313,8 @@ def ping(**kwargs): """ _fill_kwargs(kwargs) try: - # if we don't have a stored host then we assume the hub is remote TODO(artanicus): need a second test as well so a failed call will attempt to flip - if not kwargs['remote'] and kwargs['autoremote'] and not kwargs['host']: # TODO(artanicus): I'm not sure if the last condition makes sense - global remote - 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(**kwargs) diff --git a/cozify/test/fixtures.py b/cozify/test/fixtures.py index 6f30e94..0b7265c 100755 --- a/cozify/test/fixtures.py +++ b/cozify/test/fixtures.py @@ -16,7 +16,7 @@ def default_hub(): 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 @@ -26,9 +26,11 @@ def tmp_cloud(): @pytest.fixture def live_cloud(): - config.setStatePath() # reset to default + configfile, configpath = tempfile.mkstemp() + config.setStatePath(configpath, copy_current=True) from cozify import cloud - return cloud + yield cloud + config.setStatePath() @pytest.fixture def id(): @@ -52,7 +54,6 @@ def live_hub(): config.dump_state() # dump state so it's visible in failed test output from cozify import hub yield hub - hub.remote = False # reset remote state at teardown class Tmp_cloud(): """Creates a temporary cloud state with test data. @@ -67,12 +68,12 @@ 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) @@ -90,21 +91,16 @@ def __init__(self): 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): 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 - print('Temporary state:') - config.dump_state() + def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - if exc_type is not None: - logging.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/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_api.py b/cozify/test/test_hub_api.py index 665bbdd..2d5b85a 100755 --- a/cozify/test/test_hub_api.py +++ b/cozify/test/test_hub_api.py @@ -11,7 +11,7 @@ def test_hub(live_cloud, live_hub): assert hub_api.hub( hub_id = hub_id, host = live_hub.host(hub_id), - remote = live_hub.remote, + remote = live_hub.remote(hub_id), cloud_token = live_cloud.token(), hub_token = live_hub.token(hub_id) ) From cce24ab28b3b3fedd41d36283c691d532b8e11d7 Mon Sep 17 00:00:00 2001 From: Artanicus Date: Sun, 4 Mar 2018 16:48:44 +0200 Subject: [PATCH 48/48] Document multihub, remote and autoremote. --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index f7c750b..b87c4b3 100644 --- a/README.rst +++ b/README.rst @@ -105,6 +105,18 @@ 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