From 4314fc8cc9e3b0b94dfd286c90062acc7abc5d27 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Mon, 1 Apr 2019 19:39:38 +1100 Subject: [PATCH 1/5] - Getting test coverage working correctly, ready for making some major changes. - Some other clean-up tasks --- .gitignore | 6 ++++++ aquaipy/test/test_integration.py | 4 +--- docs/conf.py | 2 +- requirements.txt | 1 + setup.py | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 894a44c..e1d7c23 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,9 @@ venv.bak/ # mypy .mypy_cache/ + +# vim cache files +*.swp + +# pytest settings file, for integration tests. +pytest.ini diff --git a/aquaipy/test/test_integration.py b/aquaipy/test/test_integration.py index 65e5b03..991a4ba 100644 --- a/aquaipy/test/test_integration.py +++ b/aquaipy/test/test_integration.py @@ -25,9 +25,7 @@ def ai_instance(): #Restore light state ai_instance.set_schedule_state(sched_state) - - if not sched_state: - ai_instance.set_colors_brightness(color_state) + ai_instance.set_colors_brightness(color_state) @endpoint_defined diff --git a/docs/conf.py b/docs/conf.py index 226f8e1..4ddad94 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ try: release = pkg_resources.get_distribution(project).version except pkg_resources.DistributionNotFound: - print 'To build the documentation, The distribution information of sandman' + print 'To build the documentation, The distribution information of aquaipy' print 'Has to be available. Either install the package into your' print 'development environment or run "setup.py develop" to setup the' print 'metadata. A virtualenv is recommended!' diff --git a/requirements.txt b/requirements.txt index 30a34a2..1cac163 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pytest==3.6.1 requests==2.20.0 +pytest-cov==2.5.1 diff --git a/setup.py b/setup.py index 0022772..bf06884 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def read(*filenames, **kwargs): class PyTest(TestCommand): def finalize_options(self): TestCommand.finalize_options(self) - self.test_args = ["-rxXs"] + self.test_args = ["-rs"] self.test_suite = True def run_tests(self): From f3d54a86cff2b82da75ecec510bcc51023ccfecd Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Sat, 8 Jun 2019 01:17:02 +1000 Subject: [PATCH 2/5] - Added coveragerc, to standardise coverage - Updated tests, coverage now at 96% --- .coveragerc | 9 + aquaipy/aquaipy.py | 300 +++++++++--- aquaipy/test/test_AquaIPy.py | 643 ------------------------- aquaipy/test/test_async_AquaIPy.py | 731 +++++++++++++++++++++++++++++ aquaipy/test/test_integration.py | 2 + aquaipy/test/test_sync_AquaIPy.py | 105 +++++ pytest.ini.sample | 2 + requirements.txt | 7 +- setup.py | 2 +- tox.ini | 2 +- 10 files changed, 1090 insertions(+), 713 deletions(-) create mode 100644 .coveragerc delete mode 100644 aquaipy/test/test_AquaIPy.py create mode 100644 aquaipy/test/test_async_AquaIPy.py create mode 100644 aquaipy/test/test_sync_AquaIPy.py create mode 100644 pytest.ini.sample diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4cea11b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +branch=True +concurrency=multiprocessing +source=aquaipy +omit=*test* + +[report] +fail_under=100 +show_missing=True diff --git a/aquaipy/aquaipy.py b/aquaipy/aquaipy.py index 636be92..d44514c 100644 --- a/aquaipy/aquaipy.py +++ b/aquaipy/aquaipy.py @@ -15,12 +15,13 @@ """Module for working with the AquaIllumination range of lights.""" -import json -from enum import Enum +import asyncio # pylint: disable=no-name-in-module,import-error from distutils.version import StrictVersion +from enum import Enum +import json -import requests +import aiohttp from aquaipy.error import ConnError, FirmwareError, MustBeParentError @@ -205,8 +206,9 @@ class AquaIPy: # pylint: disable=too-many-instance-attributes # All attributes are required, in this case. + # pylint: disable=too-many-public-methods - def __init__(self, name=None): + def __init__(self, name=None, session=None, loop=None): """Initialise class, with an optional instance name. :param name: Instance name, not currently used for anything. @@ -221,7 +223,49 @@ def __init__(self, name=None): self._primary_device = None self._other_devices = [] + self._loop = loop + + try: + self._loop = asyncio.get_event_loop() + self._loop_is_local = False + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop_is_local = True + + if session is None: + self._session = aiohttp.ClientSession() + self._session_is_local = True + else: + self._session = session + self._session_is_local = False + def connect(self, host, check_firmware_support=True): + """Connect **AquaIPy** instance to a specifed AI light, synchronously. + + :param host: Hostname/IP of AI light, for paired lights this should be + the parent light. + :param check_firmware_support: Set to False to skip the firmware check + :type check_firmware_support: bool + + .. note:: It is **NOT** recommended to set + *check_firmware_support=False*. Do so at your own risk! + + :raises FirmwareError: If the firmware version is unsupported. + :raises ConnError: If unable to connect to specified AI light. + :raises MustBeParentError: the specified host must be the parent + light, if there are multiple lights linked. + + :Example: + >>> from aquaipy import AquaIPy + >>> ai = AquaIPy() + >>> ai.connect("192.168.1.1") + + """ + return self._loop.run_until_complete( + self.async_connect(host, check_firmware_support)) + + async def async_connect(self, host, check_firmware_support=True): """Connect **AquaIPy** instance to a specified AI light. Also verifies connectivity and firmware version support. @@ -242,13 +286,33 @@ def connect(self, host, check_firmware_support=True): :Example: >>> from aquaipy import AquaIPy >>> ai = AquaIPy() - >>> ai.connect("192.168.1.1") + >>> await ai.async_connect("192.168.1.1") """ self._host = host self._base_path = 'http://' + host + '/api' - self._setup_device_details(check_firmware_support) + await self._async_setup_device_details(check_firmware_support) + + def close(self): + """Clean-up and close the underlying async dependancies.. + + .. note:: There is no async method, as it is assumed if you are using + async functions, you will use your own event loop and + aiohttp.ClientSession and pass them in. This will close the client + session and event loop, if they were created by this object, when + it was initialised. + """ + self._base_path = None + + if self._session_is_local: + self._loop.run_until_complete(self._session.close()) + + if self._loop_is_local: + self._loop.stop() + pending_tasks = asyncio.Task.all_tasks() + self._loop.run_until_complete(asyncio.gather(*pending_tasks)) + self._loop.close() @property def mac_addr(self): @@ -321,17 +385,20 @@ def _validate_connection(self): if self._base_path is None: raise ConnError("Error connecting to host", self._host) - def _setup_device_details(self, check_firmware_support): + async def _async_setup_device_details(self, check_firmware_support): """Verify connection to the device and populate device attributes.""" r_data = None try: - resp = requests.get(self._base_path + "/identity") + path = "{0}/{1}".format(self._base_path, "identity") + async with self._session.get(path) as resp: - r_data = None - r_data = resp.json() + r_data = await resp.json() except Exception: self._base_path = None + + import traceback + traceback.print_exc() raise ConnError("Unable to connect to host", self._host) if r_data['response_code'] != 0: @@ -354,62 +421,76 @@ def _setup_device_details(self, check_firmware_support): raise MustBeParentError( "Connected to non-parent device", r_data['parent']) - self._get_devices() + await self._async_get_devices() - def _get_devices(self): + async def _async_get_devices(self): """Populate the device attributes of the current class instance.""" - resp = requests.get(self._base_path + "/power") - r_data = resp.json() + path = "{0}/{1}".format(self._base_path, "power") + async with self._session.get(path) as resp: - if r_data['response_code'] != 0: - self._base_path = None - raise ConnError("Unable to retrieve device details", self._host) - - self._primary_device = None - self._other_devices = [] + r_data = await resp.json() + if r_data['response_code'] != 0: + self._base_path = None + raise ConnError( + "Unable to retrieve device details", self._host) - for device in r_data['devices']: + self._primary_device = None + self._other_devices = [] - temp = HDDevice(device, self.mac_addr) + for device in r_data['devices']: + temp = HDDevice(device, self.mac_addr) - if temp.is_primary: - self._primary_device = temp - else: - self._other_devices.append(temp) + if temp.is_primary: + self._primary_device = temp + else: + self._other_devices.append(temp) - def _get_brightness(self): + async def _async_get_brightness(self): """Get raw intensity values back from API.""" self._validate_connection() - resp = requests.get(self._base_path + "/colors") - r_data = None - r_data = resp.json() + path = "{0}/{1}".format(self._base_path, "colors") + async with self._session.get(path) as resp: - if r_data["response_code"] != 0: - return Response.Error, None + r_data = await resp.json() - del r_data["response_code"] + if r_data["response_code"] != 0: + return Response.Error, None - return Response.Success, r_data + del r_data["response_code"] - def _set_brightness(self, body): + return Response.Success, r_data + + async def _async_set_brightness(self, body): """Set raw intensity values, via AI API.""" self._validate_connection() - resp = requests.post(self._base_path + "/colors", json=body) - r_data = None - r_data = resp.json() + path = "{0}/{1}".format(self._base_path, "colors") + async with self._session.post(path, json=body) as resp: - if r_data["response_code"] != 0: - return Response.Error + r_data = await resp.json() + + if r_data["response_code"] != 0: + return Response.Error - return Response.Success + return Response.Success ####################################################### # Get/Set Manual Control (ie. Not using light schedule) ####################################################### - def get_schedule_state(self): + """Check if light schedule is enabled/disabled, synchronously. + + :returns: Schedule Enabled (*True*) / Schedule Disabled (*False*) or + *None* if there's an error + :rtype: bool + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed + """ + return self._loop.run_until_complete(self.async_get_schedule_state()) + + async def async_get_schedule_state(self): """Check if light schedule is enabled/disabled. :returns: Schedule Enabled (*True*) / Schedule Disabled (*False*) or @@ -420,17 +501,32 @@ def get_schedule_state(self): usually because a previous call to ``connect()`` has failed """ self._validate_connection() - resp = requests.get(self._base_path + '/schedule/enable') - r_data = None + path = "{0}/{1}".format(self._base_path, "schedule/enable") + async with self._session.get(path) as resp: - r_data = resp.json() + r_data = await resp.json() - if r_data is None or r_data["response_code"] != 0: - return None + if r_data is None or r_data["response_code"] != 0: + return None - return r_data["enable"] + return r_data["enable"] def set_schedule_state(self, enable): + """Enable/Disable the light schedule, synchronously. + + :param enable: Schedule Enable (*True*) / Schedule Disable (*False*) + :type enable: bool + :returns: Response.Success if it works, or a value indicating the + error, if there is an issue. + :rtype: Response + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed + """ + return self._loop.run_until_complete( + self.async_set_schedule_state(enable)) + + async def async_set_schedule_state(self, enable): """Enable/disable the light schedule. :param enable: Schedule Enable (*True*) / Schedule Disable (*False*) @@ -444,27 +540,26 @@ def set_schedule_state(self, enable): """ self._validate_connection() data = {"enable": enable} - resp = requests.put( - self._base_path + "/schedule/enable", data=json.dumps(data)) - r_data = None + path = "{0}/{1}".format(self._base_path, "schedule/enable") + async with self._session.put(path, data=json.dumps(data)) as resp: - r_data = resp.json() + r_data = await resp.json() - if r_data is None: - return Response.Error + if r_data is None: + return Response.Error - if r_data['response_code'] != 0: - return Response.Error + if r_data['response_code'] != 0: + return Response.Error - return Response.Success + return Response.Success ########################### # Color Control / Intensity ########################### def get_colors(self): - """Get the list of valid colors to pass to other colors methods. + """Get the list of valid colors for other methods, synchronously. :returns: list of valid colors or *None* if there's an error :rtype: list( color_1..color_n ) or None @@ -472,9 +567,20 @@ def get_colors(self): :raises ConnError: if there is no valid connection to a device, usually because a previous call to ``connect()`` has failed """ + return self._loop.run_until_complete(self.async_get_colors()) + + async def async_get_colors(self): + """Get the list of valid colors to pass to other colors methods. + + :returns: list of valid colors or *None* if there's an error + :rtype: list( color_1..color_n ) or None + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``async_connect()`` has failed + """ colors = [] - resp, data = self._get_brightness() + resp, data = await self._async_get_brightness() if resp != Response.Success: return None @@ -485,6 +591,19 @@ def get_colors(self): return colors def get_colors_brightness(self): + """Get the current brightness of all color channels, synchronously. + + :returns: dictionary of color and brightness percentages, or *None* if + there's an error + :rtype: dict( color_1=percentage_1..color_n=percentage_n ) or None + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed + """ + return self._loop.run_until_complete( + self.async_get_colors_brightness()) + + async def async_get_colors_brightness(self): """Get the current brightness of all color channels. :returns: dictionary of color and brightness percentages, or *None* if @@ -497,7 +616,7 @@ def get_colors_brightness(self): colors = {} # Get current brightness, for each colour channel - resp_b, brightness = self._get_brightness() + resp_b, brightness = await self._async_get_brightness() if resp_b != Response.Success: return None @@ -509,6 +628,21 @@ def get_colors_brightness(self): return colors def set_colors_brightness(self, colors): + """Set all colors to the specified color percentage, synchronously. + + :param colors: dictionary of colors and percentage values + :type colors: dict( color_1=percentage_1..color_n=percentage_n ) + :returns: Response.Success if it works, or a value indicating the + error, if there is an issue. + :rtype: Response + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed + """ + return self._loop.run_until_complete( + self.async_set_colors_brightness(colors)) + + async def async_set_colors_brightness(self, colors): """Set all colors to the specified color percentage. .. note:: All colors returned by *get_colors()* must be specified. @@ -523,7 +657,7 @@ def set_colors_brightness(self, colors): usually because a previous call to ``connect()`` has failed """ # Need to add better validation here - if len(colors) < len(self.get_colors()): + if len(colors) < len(await self.async_get_colors()): return Response.AllColorsMustBeSpecified intensities = {} @@ -556,10 +690,25 @@ def set_colors_brightness(self, colors): str(mw_value))) return Response.PowerLimitExceeded - return self._set_brightness(intensities) + return await self._async_set_brightness(intensities) def patch_colors_brightness(self, colors): - """Set specified colors, to the specified percentage brightness. + """Set specified colors to the given percentage values, sychronously. + + :param colors: Specify just the colors that should be updated + :type colors: dict( color_1=percentage_1..color_n=percentage_n ) + :returns: Response.Success if it works, or a value indicating the + error, if there is an issue. + :rtype: Response + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed + """ + return self._loop.run_until_complete( + self.async_patch_colors_brightness(colors)) + + async def async_patch_colors_brightness(self, colors): + """Set specified colors to the given percentage brightness. :param colors: Specify just the colors that should be updated :type colors: dict( color_1=percentage_1..color_n=percentage_n ) @@ -573,7 +722,7 @@ def patch_colors_brightness(self, colors): if len(colors) < 1: return Response.InvalidData - brightness = self.get_colors_brightness() + brightness = await self.async_get_colors_brightness() if brightness is None: return Response.Error @@ -581,9 +730,26 @@ def patch_colors_brightness(self, colors): for color, value in colors.items(): brightness[color] = value - return self.set_colors_brightness(brightness) + return await self.async_set_colors_brightness(brightness) def update_color_brightness(self, color, value): + """Update a given color by the specified brightness, synchronously. + + :param color: color to change + :param value: value to change percentage by + :type color: str + :type value: float + :returns: Response.Success if it works, or a value indicating the + error, if there is an issue. + :rtype: Response + + :raises ConnError: if there is no valid connection to a device, + usually because a previous call to ``connect()`` has failed. + """ + return self._loop.run_until_complete( + self.async_update_color_brightness(color, value)) + + async def async_update_color_brightness(self, color, value): """Update a given color by the specified brightness percentage. :param color: color to change @@ -604,11 +770,11 @@ def update_color_brightness(self, color, value): if value == 0: return Response.Success - brightness = self.get_colors_brightness() + brightness = await self.async_get_colors_brightness() if brightness is None: return Response.Error brightness[color] += value - return self.set_colors_brightness(brightness) + return await self.async_set_colors_brightness(brightness) diff --git a/aquaipy/test/test_AquaIPy.py b/aquaipy/test/test_AquaIPy.py deleted file mode 100644 index 145adae..0000000 --- a/aquaipy/test/test_AquaIPy.py +++ /dev/null @@ -1,643 +0,0 @@ -import pytest -from unittest.mock import Mock, patch - -import requests -from aquaipy.aquaipy import HDDevice, AquaIPy, Response -from aquaipy.error import ConnError, FirmwareError, MustBeParentError -from aquaipy.test.TestData import TestData - - -def test_AquaIPy_init_raises_InvalidURL(): - - with pytest.raises(ConnError): - api = AquaIPy() - api.connect("") - - -def test_AquaIPy_init_raises_requests_ConnectionError_bad_hostname(): - - with pytest.raises(ConnError): - api = AquaIPy() - api.connect("invalid-host") - - -@patch("aquaipy.aquaipy.requests.get") -def test_AquaIPy_init_raises_requests_MustBeParentError(mock_get): - - mock_get.return_value.json.return_value = TestData.identity_not_parent() - - with pytest.raises(MustBeParentError): - api = AquaIPy() - api.connect("valid-host") - - -@patch("aquaipy.aquaipy.requests.get") -def test_AquaIPy_init_raises_ConnectionError_server_error(mock_get): - - mock_get.return_value.json.return_value = TestData.server_error() - - with pytest.raises(ConnError): - api = AquaIPy() - api.connect("valid-host") - -def test_AquaIPy_init_with_name(): - - api = AquaIPy("Test Name") - - assert api.name == "Test Name" - -def test_AquaIPy_init_success(): - - api = TestHelper.get_connected_instance() - - assert api.mac_addr == "D8976003AAAA" - assert api.supported_firmware - assert api.product_type == "Hydra TwentySix" - assert api.name == None - assert api.firmware_version == "2.2.0" - assert api.base_path == 'http://' + TestHelper.mock_hostname + '/api' - -def test_AquaIPy_validate_connection_fail(): - - api = AquaIPy() - - with pytest.raises(ConnError): - api.connect("valid-host") - - with pytest.raises(ConnError): - api._validate_connection() - - -@pytest.mark.parametrize("identity_response, power_response, other_count", [ - (TestData.identity_hydra52hd(), TestData.power_hydra52hd(), 0), - (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), 0), - (TestData.identity_primehd(), TestData.power_primehd(), 0), - (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), 1), - (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), 1) - ]) -def test_AquaIPy_get_devices_success(identity_response, power_response, other_count): - - api = TestHelper.get_connected_instance(identity_response, power_response) - - assert api._primary_device != None - assert len(api._other_devices) == other_count - - - -@patch('aquaipy.aquaipy.requests.get') -def test_AquaIPy_get_devices_fail(mock_get): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.server_error() - - with pytest.raises(ConnError): - api._get_devices() - - -@patch('aquaipy.aquaipy.requests.get') -def test_AquaIPy_firmware_error(mock_get): - - mock_get.return_value.json.return_value = TestData.identity_hydra26hd_unsupported_firmware() - - api = AquaIPy() - with pytest.raises(FirmwareError): - api.connect(TestHelper.mock_hostname) - - -def test_AquaIPy_get_schedule_state_enabled(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.schedule_enabled() - - assert api.get_schedule_state() - - -def test_AquaIPy_get_schedule_state_disabled(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.schedule_disabled() - - assert api.get_schedule_state() == False - -def test_AquaIPy_get_schedule_state_error(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.server_error() - - assert api.get_schedule_state() == None - -def test_AquaIPy_get_schedule_state_error_unexpected_response(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = None - - assert api.get_schedule_state() == None - - -def test_AquaIPy_get_raw_brightness_all_0(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.colors_1() - - response = api._get_brightness() - - assert response[0] == Response.Success - - for color, value in response[1].items(): - assert value == 0 - - -def test_AquaIPy_get_raw_brightness_all_1000(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.colors_2() - - response = api._get_brightness() - - assert response[0] == Response.Success - - for color, value in response[1].items(): - assert value == 1000 - - -def test_AquaIPy_get_raw_brightness_error(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_get.return_value.json.return_value = TestData.server_error() - - response = api._get_brightness() - - assert response == (Response.Error, None) - - -def test_AquaIPy_get_colors(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - - mock_get.return_value = Response.Success, data - - colors = api.get_colors() - assert len(colors) == 7 - -def test_AquaIPy_get_colors_error(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_get: - - mock_get.return_value = (Response.Error, None) - response = api.get_colors() - - assert response == None - - -def test_AquaIPy_get_color_brightness_error(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_getb: - - data = TestData.colors_1() - del data['response_code'] - mock_getb.return_value = Response.Error, None - - - colors = api.get_colors_brightness() - assert colors == None - - -def test_AquaIPy_get_color_brightness_all_0(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_getb: - - data = TestData.colors_1() - del data['response_code'] - mock_getb.return_value = Response.Success, data - - - colors = api.get_colors_brightness() - mock_getb.assert_called_once_with() - assert len(colors) == 7 - - for color, value in colors.items(): - assert value == 0 - -def test_AquaIPy_get_color_brightness_all_100(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_getb: - data = TestData.colors_2() - del data['response_code'] - mock_getb.return_value = Response.Success, data - - colors = api.get_colors_brightness() - mock_getb.assert_called_once_with() - assert len(colors) == 7 - - for color, value in colors.items(): - assert value == 100 - -def test_AquaIPy_get_color_brightness_hd_values(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, '_get_brightness') as mock_getb: - - data = TestData.colors_3() - del data['response_code'] - mock_getb.return_value = Response.Success, data - - - colors = api.get_colors_brightness() - mock_getb.assert_called_once_with() - assert len(colors) == 7 - - for color, value in colors.items(): - - if color == 'uv': - assert value == 42.4 - elif color == 'violet': - assert value == 104.78739920732541 - elif color == 'royal': - assert value == 117.23028298727394 - else: - assert value == 0 - - -def test_AquaIPy_set_brightness(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.post') as mock_post: - - mock_post.return_value.json.return_value = TestData.server_success() - - data = TestData.colors_1() - del data['response_code'] - - response = api._set_brightness(data) - - assert response == Response.Success - mock_post.assert_called_once_with(api.base_path + '/colors', json = data) - - -def test_AquaIPy_set_brightness_error(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.post') as mock_post: - - mock_post.return_value.json.return_value = TestData.server_error() - - data = TestData.colors_1() - del data['response_code'] - - response = api._set_brightness(data) - - assert response == Response.Error - - -def test_AquaIPy_set_schedule_enabled(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.put') as mock_put: - - mock_put.return_value.json.return_value = TestData.server_success() - - response = api.set_schedule_state(True) - - assert response == Response.Success - mock_put.assert_called_once_with(api.base_path + '/schedule/enable', data='{"enable": true}') - - -def test_AquaIPy_set_schedule_disabled(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.put') as mock_put: - - mock_put.return_value.json.return_value = TestData.server_success() - - response = api.set_schedule_state(False) - - assert response == Response.Success - mock_put.assert_called_once_with(api.base_path + '/schedule/enable', data='{"enable": false}') - - -def test_AquaIPy_set_schedule_error(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.put') as mock_put: - - mock_put.return_value.json.return_value = TestData.server_error() - - response = api.set_schedule_state(False) - - assert response == Response.Error - - -def test_AquaIPy_set_schedule_error_unexpected_response(): - - api = TestHelper.get_connected_instance() - - with patch('aquaipy.aquaipy.requests.put') as mock_put: - - mock_put.return_value.json.return_value = None - - response = api.set_schedule_state(False) - - assert response == Response.Error - - -def test_AquaIPy_patch_color_brightness_all_0(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.patch_colors_brightness(TestData.set_colors_1()) - - assert response == Response.Success - mock_set.assert_called_once_with(data) - -def test_AquaIPy_patch_color_brightness_all_100(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.patch_colors_brightness(TestData.set_colors_2()) - - result = TestData.set_colors_2() - - assert response == Response.Success - mock_set.assert_called_once_with(result) - - -def test_AquaIPy_patch_color_brightness_hd_values(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.patch_colors_brightness(TestData.set_colors_3()) - - result = TestData.set_colors_3() - - assert response == Response.Success - mock_set.assert_called_once_with(result) - -def test_AquaIPy_patch_color_brightness_invalid_data(): - - api = TestHelper.get_connected_instance() - - data = TestData.colors_1() - del data['response_code'] - - response = api.patch_colors_brightness({}) - assert response == Response.InvalidData - - -def test_AquaIPy_patch_color_error_response(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'get_colors_brightness') as mock_get: - - mock_get.return_value = None - - result = api.patch_colors_brightness(TestData.set_colors_3()) - - assert result == Response.Error - -def test_AquaIPy_update_color_brightness(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.update_color_brightness('blue', 20) - - result = TestData.set_colors_1() - result['blue'] = 20 - - assert response == Response.Success - mock_set.assert_called_once_with(result) - - -def test_AquaIPy_update_color_brightness_too_high(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.update_color_brightness('blue', 110) - - result = TestData.set_colors_1() - result['blue'] = 110 - - assert response == Response.Success - mock_set.assert_called_once_with(result) - - -def test_AquaIPy_update_color_brightness_too_low(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'set_colors_brightness') as mock_set: - with patch.object(api, 'get_colors_brightness') as mock_get: - - data = TestData.colors_1() - del data['response_code'] - mock_get.return_value = data - mock_set.return_value = Response.Success - - response = api.update_color_brightness('blue', -10) - - result = TestData.set_colors_1() - result['blue'] = -10 - - assert response == Response.Success - mock_set.assert_called_once_with(result) - - -def test_AquaIPy_update_color_brightness_invalid_data(): - - api = TestHelper.get_connected_instance() - - response = api.update_color_brightness("", 0.0) - assert response == Response.InvalidData - - -def test_AquaIPy_update_color_error_response(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'get_colors_brightness') as mock_get: - - mock_get.return_value = None - - result = api.update_color_brightness("deep_red", 10) - - assert result == Response.Error - - -def test_AquaIPy_update_color_brightness_no_action_required(): - - api = TestHelper.get_connected_instance() - - response = api.update_color_brightness("deep_red", 0.0) - assert response == Response.Success - - -def test_AquaIPy_set_color_brightness_error(): - - api = TestHelper.get_connected_instance() - - with patch.object(api, 'get_colors') as mock_get_colors: - with patch.object(api, '_set_brightness') as mock_set: - - mock_get_colors.return_value = TestData.get_colors() - mock_set.return_value = Response.Success - - response = api.set_colors_brightness({}) - assert response == Response.AllColorsMustBeSpecified - - -@pytest.mark.parametrize("identity_response, power_response, result", [ - (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), TestData.set_result_colors_3_hydra26hd()), - (TestData.identity_primehd(), TestData.power_primehd(), TestData.set_result_colors_3_primehd()), - (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), TestData.set_result_colors_3_hydra26hd()), - (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), TestData.set_result_colors_3_primehd()) - ]) -def test_AquaIPy_set_color_brightness_hd(identity_response, power_response, result): - - api = TestHelper.get_connected_instance(identity_response, power_response) - - with patch.object(api, 'get_colors') as mock_get_colors: - with patch.object(api, '_set_brightness') as mock_set: - - mock_get_colors.return_value = TestData.get_colors() - mock_set.return_value = Response.Success - - api.set_colors_brightness(TestData.set_colors_3()) - - mock_set.assert_called_once_with(result) - - -@pytest.mark.parametrize("set_colors_max_hd, result_colors_max_hd, identity_response, power_response", [ - (TestData.set_colors_max_hd_hydra52hd(), TestData.set_result_colors_max_hd_hydra52hd(), TestData.identity_hydra52hd(), TestData.power_hydra52hd()), - (TestData.set_colors_max_hd_hydra26hd(), TestData.set_result_colors_max_hd_hydra26hd(), TestData.identity_hydra26hd(), TestData.power_hydra26hd()), - (TestData.set_colors_max_hd_primehd(), TestData.set_result_colors_max_hd_primehd(), TestData.identity_primehd(), TestData.power_primehd()) - ]) -def test_AquaIPy_set_color_brightness_max_hd(set_colors_max_hd, result_colors_max_hd, identity_response, power_response): - - api = TestHelper.get_connected_instance(identity_response, power_response) - - with patch.object(api, 'get_colors') as mock_get_colors: - with patch.object(api, '_set_brightness') as mock_set: - - mock_get_colors.return_value = TestData.get_colors() - mock_set.return_value = Response.Success - - response = api.set_colors_brightness(set_colors_max_hd) - - mock_set.assert_called_once_with(result_colors_max_hd) - assert response == Response.Success - -@pytest.mark.parametrize("identity_response, power_response, set_colors", [ - (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), TestData.set_colors_hd_exceeded_hydra26hd()), - (TestData.identity_primehd(), TestData.power_primehd(), TestData.set_colors_hd_exceeded_primehd()), - (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), TestData.set_colors_max_hd_primehd()), - (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), TestData.set_colors_hd_exceeded_mixed()) - ]) -def test_AquaIPy_set_color_brightness_hd_exceeded(identity_response, power_response, set_colors): - - api = TestHelper.get_connected_instance(identity_response, power_response) - - with patch.object(api, 'get_colors') as mock_get_colors: - with patch.object(api, '_set_brightness') as mock_set: - - mock_get_colors.return_value = TestData.get_colors() - - result = api.set_colors_brightness(set_colors) - - mock_set.assert_not_called() - assert result == Response.PowerLimitExceeded - - - -class TestHelper: - - mock_hostname = 'valid-hostname' - - @staticmethod - def get_connected_instance(identity = TestData.identity_hydra26hd(), power = TestData.power_hydra26hd()): - - api = AquaIPy() - - with patch('aquaipy.aquaipy.requests.get') as mock_get: - mock_responses = [Mock(), Mock()] - mock_responses[0].json.return_value = identity - mock_responses[1].json.return_value = power - mock_get.side_effect = mock_responses - - api.connect(TestHelper.mock_hostname) - - return api diff --git a/aquaipy/test/test_async_AquaIPy.py b/aquaipy/test/test_async_AquaIPy.py new file mode 100644 index 0000000..8ce0585 --- /dev/null +++ b/aquaipy/test/test_async_AquaIPy.py @@ -0,0 +1,731 @@ +import pytest +from unittest.mock import Mock, patch +import asyncio +import aiohttp +import asynctest +from async_generator import yield_, async_generator + +from aquaipy.aquaipy import HDDevice, AquaIPy, Response +from aquaipy.error import ConnError, FirmwareError, MustBeParentError +from aquaipy.test.TestData import TestData + + +class TestHelper: + + @staticmethod + def get_hostname(mock_device=None): + mock_hostname = "localhost" + + if mock_device is None: + return mock_hostname + + return "{0}:{1}".format(mock_hostname, mock_device.port) + + @staticmethod + async def async_process_request(mock_device, call_method, expected_request, response_data, request_data = None, expected_method = "GET"): + task = asyncio.ensure_future(call_method) + request = await mock_device.receive_request() + + # validate request + assert request.path_qs == expected_request + assert request.method == expected_method + if request_data: + assert (await request.json()) == request_data + + mock_device.send_response(request, data=response_data) + + return await task + + @staticmethod + async def async_get_connected_instance(mock_device, identity = TestData.identity_hydra26hd(), power = TestData.power_hydra26hd()): + api = AquaIPy() + task = asyncio.ensure_future(api.async_connect(TestHelper.get_hostname(mock_device))) + + request = await mock_device.receive_request() + assert request.path_qs == '/api/identity' + mock_device.send_response(request, data=identity) + + request2 = await mock_device.receive_request() + assert request2.path_qs == '/api/power' + mock_device.send_response(request2, data=power) + + await task + assert api._base_path is not None + + return api + + +#https://aiohttp.readthedocs.io/en/stable/testing.html#pytest-example +class MockAIDevice(aiohttp.test_utils.RawTestServer): + def __init__(self, **kwargs): + super().__init__(self._handle_request, **kwargs) + self._requests = asyncio.Queue() + self._responses = {} #{id(request): Future} + + async def close(self): + '''Cancel all remaining tasks before closing.''' + for future in self._responses.values(): + future.cancel() + + await super().close() + + async def _handle_request(self, request): + '''Push requests to test case and wait until it provides a response''' + self._responses[id(request)] = response = asyncio.Future() + self._requests.put_nowait(request) + + try: + return await response + finally: + del self._responses[id(request)] + + async def receive_request(self): + '''Wait until test server receives a request''' + return await self._requests.get() + + def send_response(self, request, *args, **kwargs): + '''Send response from test case to client code''' + response = aiohttp.web.json_response(*args, **kwargs) + self._responses[id(request)].set_result(response) + + +@pytest.fixture +@async_generator +async def device(): + async with MockAIDevice() as device: + await yield_(device) + + +@pytest.fixture +@async_generator +async def api(device): + api = await TestHelper.async_get_connected_instance(device) + await yield_(api) + await api._session.close() + + +@pytest.mark.asyncio +async def test_AquaIPy_init_raises_InvalidURL(): + + api = AquaIPy() + with pytest.raises(ConnError): + await api.async_connect("") + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_init_raises_requests_ConnectionError_bad_hostname(): + + api = AquaIPy() + with pytest.raises(ConnError): + await api.async_connect("invalid-host") + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_init_raises_requests_MustBeParentError(device): + + api = AquaIPy() + task = TestHelper.async_process_request( + device, + api.async_connect(TestHelper.get_hostname(device)), + '/api/identity', + TestData.identity_not_parent()) + + with pytest.raises(MustBeParentError): + await task + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_init_raises_ConnectionError_server_error(device): + + api = AquaIPy() + task = TestHelper.async_process_request( + device, + api.async_connect(TestHelper.get_hostname(device)), + '/api/identity', + TestData.server_error()) + + with pytest.raises(ConnError): + await task + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_init_with_name(): + + api = AquaIPy("Test Name") + assert api.name == "Test Name" + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_init_success(device, api): + + assert api.mac_addr == "D8976003AAAA" + assert api.supported_firmware + assert api.product_type == "Hydra TwentySix" + assert api.name == None + assert api.firmware_version == "2.2.0" + assert api.base_path == "http://{0}/api".format(TestHelper.get_hostname(device)) + +@pytest.mark.asyncio +async def test_AquaIPy_validate_connection_fail(): + + api = AquaIPy() + + with pytest.raises(ConnError): + await api.async_connect("invalid-host") + + with pytest.raises(ConnError): + api._validate_connection() + + await api._session.close() + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity_response, power_response, other_count", [ + (TestData.identity_hydra52hd(), TestData.power_hydra52hd(), 0), + (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), 0), + (TestData.identity_primehd(), TestData.power_primehd(), 0), + (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), 1), + (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), 1) + ]) +async def test_AquaIPy_get_devices_success(device, identity_response, power_response, other_count): + + api = await TestHelper.async_get_connected_instance(device, identity_response, power_response) + + assert api._primary_device != None + assert len(api._other_devices) == other_count + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_get_devices_fail(device, api): + + task = TestHelper.async_process_request( + device, + api._async_get_devices(), + '/api/power', + TestData.server_error()) + + with pytest.raises(ConnError): + await task + +@pytest.mark.asyncio +async def test_AquaIPy_firmware_error(device): + + api = AquaIPy() + task = TestHelper.async_process_request( + device, + api.async_connect(TestHelper.get_hostname(device)), + '/api/identity', + TestData.identity_hydra26hd_unsupported_firmware()) + + with pytest.raises(FirmwareError): + await task + + await api._session.close() + +@pytest.mark.asyncio +async def test_AquaIPy_get_schedule_state_enabled(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_get_schedule_state(), + '/api/schedule/enable', + TestData.schedule_enabled()) + + assert response == True + +@pytest.mark.asyncio +async def test_AquaIPy_get_schedule_state_disabled(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_get_schedule_state(), + '/api/schedule/enable', + TestData.schedule_disabled()) + + assert response == False + +@pytest.mark.asyncio +async def test_AquaIPy_get_schedule_state_error(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_get_schedule_state(), + '/api/schedule/enable', + TestData.server_error()) + + assert response == None + +@pytest.mark.asyncio +async def test_AquaIPy_get_schedule_state_error_unexpected_response(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_get_schedule_state(), + '/api/schedule/enable', + None) + + assert response == None + +@pytest.mark.asyncio +async def test_AquaIPy_get_raw_brightness_all_0(device, api): + + response = await TestHelper.async_process_request( + device, + api._async_get_brightness(), + '/api/colors', + TestData.colors_1()) + + assert response[0] == Response.Success + + for color, value in response[1].items(): + assert value == 0 + +@pytest.mark.asyncio +async def test_AquaIPy_get_raw_brightness_all_1000(device, api): + + response = await TestHelper.async_process_request( + device, + api._async_get_brightness(), + '/api/colors', + TestData.colors_2()) + + assert response[0] == Response.Success + + for color, value in response[1].items(): + assert value == 1000 + +@pytest.mark.asyncio +async def test_AquaIPy_get_raw_brightness_error(device, api): + + response = await TestHelper.async_process_request( + device, + api._async_get_brightness(), + '/api/colors', + TestData.server_error()) + + assert response == (Response.Error, None) + +@pytest.mark.asyncio +async def test_AquaIPy_get_colors(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + + mock_get.return_value = Response.Success, data + + colors = await api.async_get_colors() + assert len(colors) == 7 + +@pytest.mark.asyncio +async def test_AquaIPy_get_colors_error(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_get: + + mock_get.return_value = (Response.Error, None) + response = await api.async_get_colors() + + assert response == None + +@pytest.mark.asyncio +async def test_AquaIPy_get_color_brightness_error(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_getb: + + data = TestData.colors_1() + del data['response_code'] + mock_getb.return_value = Response.Error, None + + + colors = await api.async_get_colors_brightness() + assert colors == None + +@pytest.mark.asyncio +async def test_AquaIPy_get_color_brightness_all_0(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_getb: + + data = TestData.colors_1() + del data['response_code'] + mock_getb.return_value = Response.Success, data + + + colors = await api.async_get_colors_brightness() + mock_getb.assert_called_once_with() + assert len(colors) == 7 + + for color, value in colors.items(): + assert value == 0 + +@pytest.mark.asyncio +async def test_AquaIPy_get_color_brightness_all_100(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_getb: + data = TestData.colors_2() + del data['response_code'] + mock_getb.return_value = Response.Success, data + + colors = await api.async_get_colors_brightness() + mock_getb.assert_called_once_with() + assert len(colors) == 7 + + for color, value in colors.items(): + assert value == 100 + +@pytest.mark.asyncio +async def test_AquaIPy_get_color_brightness_hd_values(api): + + with asynctest.patch.object(api, '_async_get_brightness') as mock_getb: + + data = TestData.colors_3() + del data['response_code'] + mock_getb.return_value = Response.Success, data + + + colors = await api.async_get_colors_brightness() + mock_getb.assert_called_once_with() + assert len(colors) == 7 + + for color, value in colors.items(): + + if color == 'uv': + assert value == 42.4 + elif color == 'violet': + assert value == 104.78739920732541 + elif color == 'royal': + assert value == 117.23028298727394 + else: + assert value == 0 + +@pytest.mark.asyncio +async def test_AquaIPy_set_brightnessde(device, api): + + data = TestData.colors_1() + del data['response_code'] + + response = await TestHelper.async_process_request( + device, + api._async_set_brightness(data), + '/api/colors', + TestData.server_success(), + data, + "POST") + + assert response == Response.Success + +@pytest.mark.asyncio +async def test_AquaIPy_set_brightness_error(device, api): + + data = TestData.colors_1() + del data['response_code'] + + response = await TestHelper.async_process_request( + device, + api._async_set_brightness(data), + '/api/colors', + TestData.server_error(), + data, + "POST") + + assert response == Response.Error + +@pytest.mark.asyncio +async def test_AquaIPy_set_schedule_enabled(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_set_schedule_state(True), + '/api/schedule/enable', + TestData.server_success(), + {"enable": True}, + "PUT") + + assert response == Response.Success + +@pytest.mark.asyncio +async def test_AquaIPy_set_schedule_disabled(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_set_schedule_state(False), + '/api/schedule/enable', + TestData.server_success(), + {"enable": False}, + "PUT") + + assert response == Response.Success + +@pytest.mark.asyncio +async def test_AquaIPy_set_schedule_error(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_set_schedule_state(False), + '/api/schedule/enable', + TestData.server_error(), + {"enable": False}, + "PUT") + + assert response == Response.Error + +@pytest.mark.asyncio +async def test_AquaIPy_set_schedule_error_unexpected_response(device, api): + + response = await TestHelper.async_process_request( + device, + api.async_set_schedule_state(False), + '/api/schedule/enable', + None, + {"enable": False}, + "PUT") + + assert response == Response.Error + +@pytest.mark.asyncio +async def test_AquaIPy_patch_color_brightness_all_0(api): + + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_patch_colors_brightness(TestData.set_colors_1()) + + assert response == Response.Success + mock_set.assert_called_once_with(data) + +@pytest.mark.asyncio +async def test_AquaIPy_patch_color_brightness_all_100(api): + + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_patch_colors_brightness(TestData.set_colors_2()) + + result = TestData.set_colors_2() + + assert response == Response.Success + mock_set.assert_called_once_with(result) + +@pytest.mark.asyncio +async def test_AquaIPy_patch_color_brightness_hd_values(api): + + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_patch_colors_brightness(TestData.set_colors_3()) + + result = TestData.set_colors_3() + + assert response == Response.Success + mock_set.assert_called_once_with(result) + +@pytest.mark.asyncio +async def test_AquaIPy_patch_color_brightness_invalid_data(api): + + data = TestData.colors_1() + del data['response_code'] + + response = await api.async_patch_colors_brightness({}) + assert response == Response.InvalidData + +@pytest.mark.asyncio +async def test_AquaIPy_patch_color_error_response(api): + + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + mock_get.return_value = None + + result = await api.async_patch_colors_brightness(TestData.set_colors_3()) + + assert result == Response.Error + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_brightness(api): + + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_update_color_brightness('blue', 20) + + result = TestData.set_colors_1() + result['blue'] = 20 + + assert response == Response.Success + mock_set.assert_called_once_with(result) + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_brightness_too_high(api): + + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_update_color_brightness('blue', 110) + + result = TestData.set_colors_1() + result['blue'] = 110 + + assert response == Response.Success + mock_set.assert_called_once_with(result) + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_brightness_too_low(api): + + with asynctest.patch.object(api, 'async_set_colors_brightness') as mock_set: + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + data = TestData.colors_1() + del data['response_code'] + mock_get.return_value = data + mock_set.return_value = Response.Success + + response = await api.async_update_color_brightness('blue', -10) + + result = TestData.set_colors_1() + result['blue'] = -10 + + assert response == Response.Success + mock_set.assert_called_once_with(result) + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_brightness_invalid_data(api): + + response = await api.async_update_color_brightness("", 0.0) + assert response == Response.InvalidData + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_error_response(api): + + with asynctest.patch.object(api, 'async_get_colors_brightness') as mock_get: + + mock_get.return_value = None + + result = await api.async_update_color_brightness("deep_red", 10) + + assert result == Response.Error + +@pytest.mark.asyncio +async def test_AquaIPy_update_color_brightness_no_action_required(api): + + response = await api.async_update_color_brightness("deep_red", 0.0) + assert response == Response.Success + +@pytest.mark.asyncio +async def test_AquaIPy_set_color_brightness_error(api): + + with asynctest.patch.object(api, 'async_get_colors') as mock_get_colors: + with asynctest.patch.object(api, '_async_set_brightness') as mock_set: + + mock_get_colors.return_value = TestData.get_colors() + mock_set.return_value = Response.Success + + response = await api.async_set_colors_brightness({}) + assert response == Response.AllColorsMustBeSpecified + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity_response, power_response, result", [ + (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), TestData.set_result_colors_3_hydra26hd()), + (TestData.identity_primehd(), TestData.power_primehd(), TestData.set_result_colors_3_primehd()), + (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), TestData.set_result_colors_3_hydra26hd()), + (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), TestData.set_result_colors_3_primehd()) + ]) +async def test_AquaIPy_set_color_brightness_hd(device, identity_response, power_response, result): + + api = await TestHelper.async_get_connected_instance(device, identity_response, power_response) + + with asynctest.patch.object(api, 'async_get_colors') as mock_get_colors: + with asynctest.patch.object(api, '_async_set_brightness') as mock_set: + + mock_get_colors.return_value = TestData.get_colors() + mock_set.return_value = Response.Success + + await api.async_set_colors_brightness(TestData.set_colors_3()) + + mock_set.assert_called_once_with(result) + + await api._session.close() + +@pytest.mark.asyncio +@pytest.mark.parametrize("set_colors_max_hd, result_colors_max_hd, identity_response, power_response", [ + (TestData.set_colors_max_hd_hydra52hd(), TestData.set_result_colors_max_hd_hydra52hd(), TestData.identity_hydra52hd(), TestData.power_hydra52hd()), + (TestData.set_colors_max_hd_hydra26hd(), TestData.set_result_colors_max_hd_hydra26hd(), TestData.identity_hydra26hd(), TestData.power_hydra26hd()), + (TestData.set_colors_max_hd_primehd(), TestData.set_result_colors_max_hd_primehd(), TestData.identity_primehd(), TestData.power_primehd()) + ]) +async def test_AquaIPy_set_color_brightness_max_hd(device, set_colors_max_hd, result_colors_max_hd, identity_response, power_response): + + api = await TestHelper.async_get_connected_instance(device, identity_response, power_response) + + with asynctest.patch.object(api, 'async_get_colors') as mock_get_colors: + with asynctest.patch.object(api, '_async_set_brightness') as mock_set: + + mock_get_colors.return_value = TestData.get_colors() + mock_set.return_value = Response.Success + + response = await api.async_set_colors_brightness(set_colors_max_hd) + + mock_set.assert_called_once_with(result_colors_max_hd) + assert response == Response.Success + + await api._session.close() + +@pytest.mark.asyncio +@pytest.mark.parametrize("identity_response, power_response, set_colors", [ + (TestData.identity_hydra26hd(), TestData.power_hydra26hd(), TestData.set_colors_hd_exceeded_hydra26hd()), + (TestData.identity_primehd(), TestData.power_primehd(), TestData.set_colors_hd_exceeded_primehd()), + (TestData.identity_hydra26hd(), TestData.power_two_hd_devices(), TestData.set_colors_max_hd_primehd()), + (TestData.identity_primehd(), TestData.power_mixed_hd_devices(), TestData.set_colors_hd_exceeded_mixed()) + ]) +async def test_AquaIPy_set_color_brightness_hd_exceeded(device, identity_response, power_response, set_colors): + + api = await TestHelper.async_get_connected_instance(device, identity_response, power_response) + + with asynctest.patch.object(api, 'async_get_colors') as mock_get_colors: + with asynctest.patch.object(api, '_async_set_brightness') as mock_set: + + mock_get_colors.return_value = TestData.get_colors() + + result = await api.async_set_colors_brightness(set_colors) + + mock_set.assert_not_called() + assert result == Response.PowerLimitExceeded + + await api._session.close() + +@pytest.mark.asyncio +async def test_init_with_session(): + + session = aiohttp.ClientSession() + + api = AquaIPy(session=session) + + api.close() + diff --git a/aquaipy/test/test_integration.py b/aquaipy/test/test_integration.py index 991a4ba..50ab5d3 100644 --- a/aquaipy/test/test_integration.py +++ b/aquaipy/test/test_integration.py @@ -27,6 +27,8 @@ def ai_instance(): ai_instance.set_schedule_state(sched_state) ai_instance.set_colors_brightness(color_state) + ai_instance.close() + @endpoint_defined @pytest.mark.parametrize("percent", [0, 50, 100]) diff --git a/aquaipy/test/test_sync_AquaIPy.py b/aquaipy/test/test_sync_AquaIPy.py new file mode 100644 index 0000000..a5cbcba --- /dev/null +++ b/aquaipy/test/test_sync_AquaIPy.py @@ -0,0 +1,105 @@ +from multiprocessing import Process +import socket +import pytest +from unittest.mock import Mock, patch +import asyncio +import aiohttp +from aiohttp import web + +from aquaipy.aquaipy import HDDevice, AquaIPy, Response +from aquaipy.error import ConnError, FirmwareError, MustBeParentError +from aquaipy.test.TestData import TestData + + +def get_hostname(): + return "localhost" + + +@pytest.fixture(scope='module') +def bound_socket(): + # Bind to random port, then start server on that port + sock = socket.socket() + sock.bind((get_hostname(), 0)) + sock.listen(128) # Magic number is default used by aiohttp. + return sock + + +@pytest.fixture(scope='module') +def server_process(bound_socket): + + app = aiohttp.web.Application() + + def identity_handler(request): + return web.json_response(data=TestData.identity_hydra26hd()) + + def power_handler(request): + return web.json_response(data=TestData.power_hydra26hd()) + + def schedule_state_handler(request): + return web.json_response(data=TestData.schedule_enabled()) + + def colors_handler(request): + return web.json_response(data=TestData.colors_1()) + + app.router.add_route('get', '/api/identity', identity_handler) + app.router.add_route('get', '/api/power', power_handler) + app.router.add_route('get', '/api/schedule/enable', schedule_state_handler) + app.router.add_route('get', '/api/colors', colors_handler) + + def run_server(): + aiohttp.web.run_app(app, handle_signals=True, sock=bound_socket) + + p = Process(target=run_server) + p.start() + + yield p + + p.terminate() + p.join() + + +@pytest.fixture +def ai_instance(bound_socket, server_process): + + host = get_hostname() + _, port = bound_socket.getsockname() + + api = AquaIPy() + + # The app runner/process takes a while to startup, so wait for it. + for i in range(0, 10): + + try: + api.connect("{0}:{1}".format(host, port)) + except Exception as e: + assertIsInstance(e, ConnError) + + api._validate_connection() + + return api + + +def test_sync_connect_and_close(ai_instance): + + ai_instance._validate_connection() + ai_instance.close() + + +def test_sync_get_schedule_state(ai_instance): + + assert ai_instance.get_schedule_state() + ai_instance.close() + + +def test_sync_get_colors(ai_instance): + + expected_colors = TestData.get_colors() + returned_colors = ai_instance.get_colors() + + for color in expected_colors: + assert returned_colors.index(color) >= 0 + + ai_instance.close() + + + diff --git a/pytest.ini.sample b/pytest.ini.sample new file mode 100644 index 0000000..6033774 --- /dev/null +++ b/pytest.ini.sample @@ -0,0 +1,2 @@ +[pytest] +aquaillumination_endpoint = 192.168.1.2 diff --git a/requirements.txt b/requirements.txt index 1cac163..21cd6b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,8 @@ -pytest==3.6.1 +pytest==4.4.0 requests==2.20.0 pytest-cov==2.5.1 +asyncio=3.4.3 +aiohttp=3.5.4 +pytest-asyncio==0.10.0 +pytest-aiohttp==0.3.0 +asynctest==0.12.3 diff --git a/setup.py b/setup.py index bf06884..33a88dc 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run(self): license='Apache Software License', author='Stephen Mc Gowan', tests_require=['pytest'], - install_requires=['requests>=2.18.4'], + install_requires=['asyncio>=3.4.3', 'aiohttp>=3.5.4'], cmdclass={'test': PyTest, 'clean': CleanCommand}, author_email='mcclown@gmail.com', description='Python library for controlling the AquaIllumination range of aquarium lights', diff --git a/tox.ini b/tox.ini index 8ab1d76..52bb302 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py34, py35 +envlist = py35 [testenv] commands = python setup.py test From 11f61ad4b4edf23493d0ae699033f214ed87c762 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Mon, 10 Jun 2019 22:55:44 +1000 Subject: [PATCH 3/5] 100% test coverage --- aquaipy/aquaipy.py | 24 ++++++++---- aquaipy/test/TestData.py | 12 ++++++ aquaipy/test/test_async_AquaIPy.py | 42 +++++++++++++++++++- aquaipy/test/test_sync_AquaIPy.py | 63 ++++++++++++++++++++++++------ 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/aquaipy/aquaipy.py b/aquaipy/aquaipy.py index d44514c..c1cf741 100644 --- a/aquaipy/aquaipy.py +++ b/aquaipy/aquaipy.py @@ -224,14 +224,18 @@ def __init__(self, name=None, session=None, loop=None): self._other_devices = [] self._loop = loop + self._loop_is_local = True - try: - self._loop = asyncio.get_event_loop() - self._loop_is_local = False - except RuntimeError: - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - self._loop_is_local = True + if self._loop is None: + + try: + self._loop = asyncio.get_event_loop() + self._loop_is_local = False + except RuntimeError: + self._create_new_event_loop() + + if self._loop.is_closed(): + self._create_new_event_loop() if session is None: self._session = aiohttp.ClientSession() @@ -385,6 +389,12 @@ def _validate_connection(self): if self._base_path is None: raise ConnError("Error connecting to host", self._host) + def _create_new_event_loop(self): + """Create a new asyncio event loop.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop_is_local = True + async def _async_setup_device_details(self, check_firmware_support): """Verify connection to the device and populate device attributes.""" r_data = None diff --git a/aquaipy/test/TestData.py b/aquaipy/test/TestData.py index 96357d7..beff0c4 100644 --- a/aquaipy/test/TestData.py +++ b/aquaipy/test/TestData.py @@ -207,6 +207,18 @@ def set_colors_3(): "blue": 0, "royal": 117, } + + @staticmethod + def get_colors_3(): + return { + "deep_red": 0, + "uv": 42.4, + "violet": 104.78739920732541, + "cool_white": 0, + "green": 0, + "blue": 0, + "royal": 117.23028298727394, + } @staticmethod def set_colors_max_hd_primehd(): diff --git a/aquaipy/test/test_async_AquaIPy.py b/aquaipy/test/test_async_AquaIPy.py index 8ce0585..db27bf5 100644 --- a/aquaipy/test/test_async_AquaIPy.py +++ b/aquaipy/test/test_async_AquaIPy.py @@ -720,8 +720,13 @@ async def test_AquaIPy_set_color_brightness_hd_exceeded(device, identity_respons await api._session.close() -@pytest.mark.asyncio -async def test_init_with_session(): + +""" These aren't async tests but they conflict with the fixtures used for the + synchronous tests, so I'm adding them here instead. +""" + + +def test_init_with_session(): session = aiohttp.ClientSession() @@ -729,3 +734,36 @@ async def test_init_with_session(): api.close() + +def test_init_with_no_asyncio_loop_running(): + + #Close current loop + loop = asyncio.get_event_loop() + loop.stop() + pending_tasks = asyncio.Task.all_tasks() + loop.run_until_complete(asyncio.gather(*pending_tasks)) + loop.close() + + api = AquaIPy() + + api.close() + + #Add new loop again + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + + +def test_init_with_specified_loop(): + + loop = asyncio.get_event_loop() + + api = AquaIPy(loop=loop) + + +@patch("aquaipy.aquaipy.asyncio.get_event_loop", side_effect=RuntimeError("Testing this exception handling.")) +def test_init_get_event_loop_error(mock_get): + + with pytest.raises(RuntimeError): + api = AquaIPy() + + diff --git a/aquaipy/test/test_sync_AquaIPy.py b/aquaipy/test/test_sync_AquaIPy.py index a5cbcba..0db9606 100644 --- a/aquaipy/test/test_sync_AquaIPy.py +++ b/aquaipy/test/test_sync_AquaIPy.py @@ -28,6 +28,7 @@ def bound_socket(): def server_process(bound_socket): app = aiohttp.web.Application() + set_schedule_request = None def identity_handler(request): return web.json_response(data=TestData.identity_hydra26hd()) @@ -35,16 +36,25 @@ def identity_handler(request): def power_handler(request): return web.json_response(data=TestData.power_hydra26hd()) - def schedule_state_handler(request): + def get_schedule_state_handler(request): return web.json_response(data=TestData.schedule_enabled()) - def colors_handler(request): - return web.json_response(data=TestData.colors_1()) + def get_colors_handler(request): + return web.json_response(data=TestData.colors_3()) - app.router.add_route('get', '/api/identity', identity_handler) - app.router.add_route('get', '/api/power', power_handler) - app.router.add_route('get', '/api/schedule/enable', schedule_state_handler) - app.router.add_route('get', '/api/colors', colors_handler) + def set_schedule_state_handler(request): + return web.json_response(data=TestData.server_success()) + + def set_colors_brightness_handler(request): + return web.json_response(data=TestData.server_success()) + + app.router.add_route('GET', '/api/identity', identity_handler) + app.router.add_route('GET', '/api/power', power_handler) + app.router.add_route('GET', '/api/schedule/enable', get_schedule_state_handler) + app.router.add_route('GET', '/api/colors', get_colors_handler) + + app.router.add_route('PUT', '/api/schedule/enable', set_schedule_state_handler) + app.router.add_route('POST', '/api/colors', set_colors_brightness_handler) def run_server(): aiohttp.web.run_app(app, handle_signals=True, sock=bound_socket) @@ -76,19 +86,21 @@ def ai_instance(bound_socket, server_process): api._validate_connection() - return api - + yield api + + api.close() + def test_sync_connect_and_close(ai_instance): ai_instance._validate_connection() - ai_instance.close() + + #close automatically happens in the ai_instance fixture def test_sync_get_schedule_state(ai_instance): assert ai_instance.get_schedule_state() - ai_instance.close() def test_sync_get_colors(ai_instance): @@ -99,7 +111,34 @@ def test_sync_get_colors(ai_instance): for color in expected_colors: assert returned_colors.index(color) >= 0 - ai_instance.close() + +def test_sync_get_colors_brightness(ai_instance): + + expected_result = TestData.get_colors_3() + returned_colors = ai_instance.get_colors_brightness() + + for color, value in returned_colors.items(): + assert expected_result[color] == value + + +def test_sync_set_schedule_state(ai_instance): + + assert ai_instance.set_schedule_state(True) == Response.Success + + +def test_sync_set_colors_brightness(ai_instance): + + assert ai_instance.set_colors_brightness(TestData.set_colors_3()) == Response.Success + + +def test_sync_patch_colors_brightness(ai_instance): + + assert ai_instance.patch_colors_brightness(TestData.set_colors_3()) == Response.Success + + +def test_sync_update_color_brightness(ai_instance): + + assert ai_instance.update_color_brightness('deep_red', 10) == Response.Success From c05b93d618aa3a16c361b90e10193af478dc2a59 Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Tue, 11 Jun 2019 00:16:49 +1000 Subject: [PATCH 4/5] Bumping version to v2.0.0 --- aquaipy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aquaipy/__init__.py b/aquaipy/__init__.py index eb8af1c..b8ba362 100644 --- a/aquaipy/__init__.py +++ b/aquaipy/__init__.py @@ -18,4 +18,4 @@ from .aquaipy import AquaIPy, Response # noqa: F401 -_VERSION_ = "1.0.2" +_VERSION_ = "2.0.0" From 85739daf995e915c717d2888496155706116ac1c Mon Sep 17 00:00:00 2001 From: Stephen Mc Gowan Date: Tue, 11 Jun 2019 00:55:16 +1000 Subject: [PATCH 5/5] Updating docs --- README.md | 2 ++ README.rst | 4 ++++ docs/basics.rst | 24 +++++++++++++----------- docs/index.rst | 3 ++- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2d7d44d..81331fd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Quickstart Install aquaipy using `pip`: `$ pip install aquaipy`. Once that is complete you can import the AquaIPy class and connect to your lights. +In this guide synchronous calls are shown but v2.0.0 of AquaIPy was a we-write, to provide async support. Synchronous functions are still supported but they are just wrappers of the async functions. + ```python >>> from aquaipy import AquaIPy >>> ai = AquaIPy() diff --git a/README.rst b/README.rst index 605654e..b8d938e 100644 --- a/README.rst +++ b/README.rst @@ -31,6 +31,10 @@ Quickstart Install aquaipy using ``pip``: ``$ pip install aquaipy``. Once that is complete you can import the AquaIPy class and connect to your lights. +In this guide synchronous calls are shown but v2.0.0 of AquaIPy was a +we-write, to provide async support. Synchronous functions are still +supported but they are just wrappers of the async functions. + .. code:: python >>> from aquaipy import AquaIPy diff --git a/docs/basics.rst b/docs/basics.rst index 9d11009..6184abd 100644 --- a/docs/basics.rst +++ b/docs/basics.rst @@ -19,9 +19,11 @@ that is required is.:: >>> from aquaipy import AquaIPy >>> ai = AquaIPy() - >>> ai.connect("192.168.1.10") + >>> await ai.async_connect("192.168.1.10") -This will verify connectivity to the light and check the firmware version is supported. +This will verify connectivity to the light and check the firmware version is supported. In the case where you have multiple +lights paired together, AquaIPy expects to be connected to the parent/primary light, of the group and will give an error if +it is connected to one of the child lights. Getting/Setting the schedule state @@ -33,12 +35,12 @@ library, the colors will only change for a second and then change back to the sc Getting the schedule state is easy.:: - >>> ai.get_schedule_state() + >>> await ai.async_get_schedule_state() True Setting it is easy as well.:: - >>> ai.set_schedule_state(False) + >>> await ai.async_set_schedule_state(False) @@ -55,13 +57,13 @@ Getting the colors The API will only accept certain colors, you can get the ``list`` of valid colors with the following call.:: - >>> ai.get_colors() + >>> await ai.async_get_colors() ['deep_red', 'royal', 'cool_white', 'violet', 'green', 'blue', 'uv'] It's also possible to retrieve the list of the colours and their current state with the following call.:: - >>> ai.get_colors_brightness() + >>> await ai.async_get_colors_brightness() {'blue': 18.7, 'cool_white': 4.4, 'deep_red': 1.0, @@ -80,19 +82,19 @@ color channel. All colors can be set in one call to the function below, by providing a ``dict`` of colors and their percentage value.:: - >>> ai.set_colors_brightness(all_colors) + >>> await ai.async_set_colors_brightness(all_colors) It also possible to modify only a subset of the colors by providing them as a ``dict``.:: - >>> ai.patch_colors_brightness(subset_colors) + >>> await ai.async_patch_colors_brightness(subset_colors) Lastly, it's possible to update a given color channel by a specified percentage.:: - >>> ai.update_color_brightness('cool_white', 33.333) + >>> await ai.async_update_color_brightness('cool_white', 33.333) - >>> ai.update_color_brightness('deep_red', -15.2) + >>> await ai.async_update_color_brightness('deep_red', -15.2) @@ -103,7 +105,7 @@ The library can return a number of response codes as ``Response`` objects. The r of are below. If any of the these error response codes are returned, then the call will have failed and no changes will have been made: -* ``Response.AllColorsMustBeSpecified`` - returned when a call to ``set_colors_brightness()`` doesn't include all colors. +* ``Response.AllColorsMustBeSpecified`` - returned when a call to ``async_set_colors_brightness()`` doesn't include all colors. * ``Response.PowerLimitExceeded`` - returned when a call to one of the methods that updates the colors, would have exceeded the max wattage allowed for the targeted light. * ``Response.InvalidData`` - returned when invalid data is supplied to one of the methods that updates the colors. diff --git a/docs/index.rst b/docs/index.rst index 99b1c6e..6fbd50a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,8 @@ Introduction ------------ AquaIPy exposes the functionality that is usually available, via the AquaIllumination mobile phone app or web app, -for the Prime HD and Hydra HD ranges of aquarium lights. +for the Prime HD and Hydra HD ranges of aquarium lights. AquaIPy is written with full async support, using asyncio +but also provides synchronous wrappers for all functions. This has been tested and validated with a Prime HD and a Hydra 26HD. It should work with a Hydra 52HD but I don't own one to test against, contact me if you have one and you are willing to help me validate the library. In theory