├── aiomqtt ├── version.py ├── __init__.py └── client.py ├── requirements-test.txt ├── .gitignore ├── tox.ini ├── setup.py ├── README.md └── tests └── test_client.py /aiomqtt/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # Test suite requirements 2 | pytest>=2.6 3 | pytest-asyncio 4 | flake8 5 | mock 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *~ 4 | .* 5 | 6 | pip-selfcheck.json 7 | bin/ 8 | include/ 9 | lib/ 10 | dist/ 11 | *.egg-info/ 12 | -------------------------------------------------------------------------------- /aiomqtt/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__ 2 | 3 | # Get all the constant definitions from paho-mqtt 4 | from paho.mqtt.client import * 5 | 6 | from aiomqtt.client import Client 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, pep8 3 | 4 | [testenv] 5 | deps = 6 | -rrequirements-test.txt 7 | commands = 8 | # This checks that the system-wide setup script works correctly and 9 | # correctly installs all required dependencies. Note that this leaves all 10 | # dependencies installed in the virtualenv which saves time on subsequent 11 | # runs. When making changes to the package dependencies, users should 12 | # ensure they reset this virtualenv to ensure that no dependencies are 13 | # omitted. 14 | python setup.py install 15 | # Run the main test suite 16 | py.test tests/ {posargs} 17 | # Uninstall the package ready for the next test run 18 | pip uninstall -y aiomqtt 19 | 20 | [testenv:pep8] 21 | deps = flake8 22 | commands = flake8 aiomqtt tests 23 | 24 | [flake8] 25 | exclude = __init__.py 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("aiomqtt/version.py", "r") as f: 4 | exec(f.read()) 5 | 6 | setup( 7 | name="aiomqtt", 8 | version=__version__, 9 | packages=find_packages(), 10 | 11 | # Metadata for PyPi 12 | url="https://github.com/mossblaser/aiomqtt", 13 | author="Jonathan Heathcote", 14 | author_email="mail@jhnet.co.uk", 15 | description="An AsyncIO asynchronous wrapper around paho-mqtt.", 16 | license="GPLv2", 17 | classifiers=[ 18 | "Development Status :: 3 - Alpha", 19 | 20 | "Intended Audience :: Developers", 21 | 22 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 23 | 24 | "Operating System :: POSIX :: Linux", 25 | "Operating System :: Microsoft :: Windows", 26 | "Operating System :: MacOS", 27 | 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.6", 30 | ], 31 | keywords="mqtt async asyncio wrapper paho-mqtt", 32 | 33 | # Requirements 34 | install_requires=["paho-mqtt>=1.3.0"], 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `aiomqtt`: An asyncio Wrapper for paho-mqtt 2 | =========================================== 3 | 4 | This library implements a minimal Python 3 5 | [asyncio](https://docs.python.org/3/library/asyncio.html) wrapper around the 6 | MQTT client in [paho-mqtt](https://github.com/eclipse/paho.mqtt.python). 7 | 8 | Installation: 9 | 10 | pip install aiomqtt 11 | 12 | API 13 | --- 14 | 15 | This library is as thin as possible, exposing the exact same API as the 16 | original paho-mqtt `Client` object with blocking calls replaced with coroutines 17 | and all callbacks being scheduled into the asyncio main event loop. It does not 18 | attempt to introduce a more idiomatic asyncio API. 19 | 20 | When using aiomqtt, refer to the [paho-mqtt 21 | documentation](https://pypi.python.org/pypi/paho-mqtt/1.1) which applies 22 | verbatim with the exception of the above rules. An example use of the library 23 | is shown below: 24 | 25 | import asyncio 26 | import aiomqtt 27 | 28 | loop = asyncio.get_event_loop() 29 | 30 | async def demo(): 31 | c = aiomqtt.Client(loop) 32 | c.loop_start() # See "About that loop..." below. 33 | 34 | connected = asyncio.Event(loop=loop) 35 | def on_connect(client, userdata, flags, rc): 36 | connected.set() 37 | c.on_connect = on_connect 38 | 39 | await c.connect("localhost") 40 | await connected.wait() 41 | print("Connected!") 42 | 43 | subscribed = asyncio.Event(loop=loop) 44 | def on_subscribe(client, userdata, mid, granted_qos): 45 | subscribed.set() 46 | c.on_subscribe = on_subscribe 47 | 48 | c.subscribe("my/test/path") 49 | await subscribed.wait() 50 | print("Subscribed to my/test/path") 51 | 52 | def on_message(client, userdata, message): 53 | print("Got message:", message.topic, message.payload) 54 | c.on_message = on_message 55 | 56 | message_info = c.publish("my/test/path", "Hello, world") 57 | await message_info.wait_for_publish() 58 | print("Message published!") 59 | 60 | await asyncio.sleep(1, loop=loop) 61 | print("Disconnecting...") 62 | 63 | disconnected = asyncio.Event(loop=loop) 64 | def on_disconnect(client, userdata, rc): 65 | disconnected.set() 66 | c.on_disconnect = on_disconnect 67 | c.disconnect() 68 | await disconnected.wait() 69 | print("Disconnected") 70 | 71 | await c.loop_stop() 72 | print("MQTT loop stopped!") 73 | 74 | loop.run_until_complete(demo()) 75 | 76 | 77 | About that loop... 78 | ------------------ 79 | 80 | Unfortunately the author was unable to work out how to integrate paho-mqtt's 81 | event loop into asyncio, despite the best efforts of the paho-mqtt authors to 82 | make this possible. (Patches are welcome.) 83 | 84 | Instead, `loop_start()` and `loop_stop()` may be used as normal (and aiomqtt 85 | will ensure callbacks arrive in the correct thread) or `loop_forever()` may be 86 | used which in aiomqtt is a coroutine. 87 | -------------------------------------------------------------------------------- /aiomqtt/client.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import asyncio 3 | 4 | from paho.mqtt.client import Client as _Client 5 | 6 | 7 | class MQTTMessageInfo(object): 8 | 9 | def __init__(self, loop, mqtt_message_info): 10 | self._loop = loop 11 | self._mqtt_message_info = mqtt_message_info 12 | 13 | def __getattr__(self, name): 14 | return getattr(self._mqtt_message_info, name) 15 | 16 | async def wait_for_publish(self): 17 | return await self._loop.run_in_executor( 18 | None, self._mqtt_message_info.wait_for_publish) 19 | 20 | def __iter__(self): 21 | return iter(self._mqtt_message_info) 22 | 23 | def __getitem__(self, i): 24 | return self._mqtt_message_info[i] 25 | 26 | def __str__(self): 27 | return str(self._mqtt_message_info) 28 | 29 | 30 | class Client(object): 31 | """ 32 | An AsyncIO based wrapper around the paho-mqtt MQTT client class. 33 | 34 | Essentially, the differences between this and the paho.mqtt.client.Client 35 | are: 36 | 37 | * The constructor takes an asyncio loop to use as the first argument. 38 | * Blocking methods (connect, connect_srv, reconnect_delay_set) are now 39 | coroutines. 40 | * Callback functions are always safely inserted into the asyncio event loop 41 | rather than being run from an unspecified thread, however the loop is 42 | started. 43 | """ 44 | 45 | def __init__(self, loop=None, *args, **kwargs): 46 | self._loop = loop or asyncio.get_event_loop() 47 | self._client = _Client(*args, **kwargs) 48 | 49 | self._wrap_blocking_method("connect") 50 | self._wrap_blocking_method("connect_srv") 51 | self._wrap_blocking_method("reconnect") 52 | 53 | self._wrap_blocking_method("loop_forever") 54 | self._wrap_blocking_method("loop_stop") 55 | 56 | self._wrap_callback("on_connect") 57 | self._wrap_callback("on_disconnect") 58 | self._wrap_callback("on_message") 59 | self._wrap_callback("on_publish") 60 | self._wrap_callback("on_subscribe") 61 | self._wrap_callback("on_unsubscribe") 62 | self._wrap_callback("on_log") 63 | 64 | ########################################################################### 65 | # Utility functions for creating wrappers 66 | ########################################################################### 67 | 68 | def _wrap_callback(self, name): 69 | """Add the named callback to the MQTT client which triggers a call to 70 | the wrapper's registered callback in the event loop thread. 71 | """ 72 | setattr(self, name, None) 73 | 74 | def wrapper(_client, *args): 75 | f = getattr(self, name) 76 | if f is not None: 77 | self._loop.call_soon_threadsafe(f, self, *args) 78 | setattr(self._client, name, wrapper) 79 | 80 | def _wrap_blocking_method(self, name): 81 | """Wrap a blocking function to make it async.""" 82 | f = getattr(self._client, name) 83 | 84 | @functools.wraps(f) 85 | async def wrapper(*args, **kwargs): 86 | return await self._loop.run_in_executor( 87 | None, functools.partial(f, *args, **kwargs)) 88 | setattr(self, name, wrapper) 89 | 90 | def __getattr__(self, name): 91 | """Fall back on non-wrapped versions of most functions.""" 92 | return getattr(self._client, name) 93 | 94 | ########################################################################### 95 | # Special-case wrappers around certain methods 96 | ########################################################################### 97 | 98 | @functools.wraps(_Client.publish) 99 | def publish(self, *args, **kwargs): 100 | # Produce an alternative MQTTMessageInfo object with a coroutine 101 | # wait_for_publish. 102 | return MQTTMessageInfo( 103 | self._loop, self._client.publish(*args, **kwargs)) 104 | 105 | @functools.wraps(_Client.message_callback_add) 106 | def message_callback_add(self, sub, callback): 107 | # Ensure callbacks are called from MQTT 108 | @functools.wraps(callback) 109 | def wrapper(_client, *args, **kwargs): 110 | self._loop.call_soon_threadsafe( 111 | functools.partial(callback, self, *args, **kwargs)) 112 | self._client.message_callback_add(sub, wrapper) 113 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | This test suite functions more as a sanity check than a comprehensive test. 3 | """ 4 | 5 | import pytest 6 | from mock import Mock 7 | 8 | import asyncio 9 | 10 | import aiomqtt 11 | 12 | 13 | @pytest.fixture("module") 14 | def port(): 15 | # A port which is likely to be free for the duration of tests... 16 | return 11223 17 | 18 | 19 | @pytest.fixture("module") 20 | def hostname(): 21 | return "localhost" 22 | 23 | 24 | @pytest.fixture("module") 25 | def event_loop(): 26 | return asyncio.get_event_loop() 27 | 28 | 29 | @pytest.yield_fixture(scope="module") 30 | def server(event_loop, port): 31 | mosquitto = event_loop.run_until_complete(asyncio.create_subprocess_exec( 32 | "mosquitto", "-p", str(port), 33 | stdout=asyncio.subprocess.DEVNULL, 34 | stderr=asyncio.subprocess.DEVNULL, 35 | loop=event_loop)) 36 | 37 | try: 38 | yield 39 | finally: 40 | mosquitto.terminate() 41 | event_loop.run_until_complete(mosquitto.wait()) 42 | 43 | 44 | def test_native_client(server, hostname, port): 45 | """Sanity check: Make sure the paho-mqtt client can connect to the test 46 | MQTT server. 47 | """ 48 | 49 | import paho.mqtt.client as mqtt 50 | import threading 51 | 52 | c = mqtt.Client() 53 | c.loop_start() 54 | try: 55 | # Just make sure the client connects successfully 56 | on_connect = threading.Event() 57 | c.on_connect = Mock(side_effect=lambda *_: on_connect.set()) 58 | c.connect_async(hostname, port) 59 | assert on_connect.wait(5) 60 | finally: 61 | c.loop_stop() 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_connect_and_loop_forever(server, hostname, port, event_loop): 66 | """Tests connecting and then disconnecting from the MQTT server while using 67 | the loop_forever construct. 68 | """ 69 | c = aiomqtt.Client(loop=event_loop) 70 | 71 | # Immediately disconnect on connection 72 | connect_event = asyncio.Event(loop=event_loop) 73 | 74 | def on_connect(client, userdata, flags, rc): 75 | assert client is c 76 | assert userdata is None 77 | assert isinstance(flags, dict) 78 | assert rc == aiomqtt.MQTT_ERR_SUCCESS 79 | 80 | connect_event.set() 81 | c.disconnect() 82 | c.on_connect = Mock(side_effect=on_connect) 83 | 84 | # Just check disconnect event is as expected 85 | disconnect_event = asyncio.Event(loop=event_loop) 86 | 87 | def on_disconnect(client, userdata, rc): 88 | assert client is c 89 | assert userdata is None 90 | assert rc == aiomqtt.MQTT_ERR_SUCCESS 91 | 92 | disconnect_event.set() 93 | c.on_disconnect = Mock(side_effect=on_disconnect) 94 | 95 | # When the client disconnects, this call should end 96 | c.connect_async(hostname, port) 97 | await asyncio.wait_for(c.loop_forever(), timeout=5, loop=event_loop) 98 | 99 | # Should definately have connected and disconnected 100 | assert connect_event.is_set() 101 | assert c.on_connect.called 102 | assert disconnect_event.is_set() 103 | assert c.on_disconnect.called 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_loop_start_stop(server, hostname, port, event_loop): 108 | """Tests that starting/stopping the loop works as expected.""" 109 | c = aiomqtt.Client(loop=event_loop) 110 | 111 | connect_event = asyncio.Event(loop=event_loop) 112 | c.on_connect = Mock(side_effect=lambda *_: connect_event.set()) 113 | 114 | # Wait or the client to connect 115 | c.loop_start() 116 | c.connect_async(hostname, port) 117 | await asyncio.wait_for(connect_event.wait(), timeout=5, loop=event_loop) 118 | 119 | # Wait for shutdown 120 | await asyncio.wait_for(c.loop_stop(), timeout=5, loop=event_loop) 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_pub_sub(server, hostname, port, event_loop): 125 | """Make sure the full set of publish and subscribe functions and callbacks 126 | work. 127 | """ 128 | c = aiomqtt.Client(loop=event_loop) 129 | 130 | connect_event = asyncio.Event(loop=event_loop) 131 | c.on_connect = Mock(side_effect=lambda *_: connect_event.set()) 132 | 133 | subscribe_event = asyncio.Event(loop=event_loop) 134 | c.on_subscribe = Mock(side_effect=lambda *_: subscribe_event.set()) 135 | 136 | publish_event = asyncio.Event(loop=event_loop) 137 | c.on_publish = Mock(side_effect=lambda *_: publish_event.set()) 138 | 139 | message_event = asyncio.Event(loop=event_loop) 140 | c.on_message = Mock(side_effect=lambda *_: message_event.set()) 141 | 142 | # For message_callback_add 143 | message_callback_event = asyncio.Event(loop=event_loop) 144 | message_callback = Mock( 145 | side_effect=lambda *_: message_callback_event.set()) 146 | 147 | unsubscribe_event = asyncio.Event(loop=event_loop) 148 | c.on_unsubscribe = Mock(side_effect=lambda *_: unsubscribe_event.set()) 149 | 150 | c.loop_start() 151 | try: 152 | c.connect_async(hostname, port) 153 | await asyncio.wait_for( 154 | connect_event.wait(), timeout=5, loop=event_loop) 155 | 156 | # Test subscription 157 | result, mid = c.subscribe("test") 158 | assert result == aiomqtt.MQTT_ERR_SUCCESS 159 | assert mid is not None 160 | await asyncio.wait_for( 161 | subscribe_event.wait(), timeout=5, loop=event_loop) 162 | c.on_subscribe.assert_called_once_with(c, None, mid, (0,)) 163 | 164 | # Test publishing 165 | message_info = c.publish("test", "Hello, world!") 166 | result, mid = message_info 167 | assert result == aiomqtt.MQTT_ERR_SUCCESS 168 | assert mid is not None 169 | await asyncio.wait_for( 170 | message_info.wait_for_publish(), timeout=5, loop=event_loop) 171 | c.on_publish.assert_called_once_with(c, None, mid) 172 | 173 | # Test message arrives 174 | await asyncio.wait_for( 175 | message_event.wait(), timeout=5, loop=event_loop) 176 | assert len(c.on_message.mock_calls) == 1 177 | assert c.on_message.mock_calls[0][1][0] is c 178 | assert c.on_message.mock_calls[0][1][1] is None 179 | assert c.on_message.mock_calls[0][1][2].topic == "test" 180 | assert c.on_message.mock_calls[0][1][2].payload == b"Hello, world!" 181 | 182 | # Now test with alternative message callback 183 | c.message_callback_add("test", message_callback) 184 | 185 | # Send another message 186 | message_info = c.publish("test", "Hello, again!") 187 | 188 | # Test message arrives 189 | await asyncio.wait_for( 190 | message_callback_event.wait(), timeout=5, loop=event_loop) 191 | assert len(message_callback.mock_calls) == 1 192 | assert message_callback.mock_calls[0][1][0] is c 193 | assert message_callback.mock_calls[0][1][1] is None 194 | assert message_callback.mock_calls[0][1][2].topic == "test" 195 | assert message_callback.mock_calls[0][1][2].payload == b"Hello, again!" 196 | 197 | # Test un-subscription 198 | result, mid = c.unsubscribe("test") 199 | assert result == aiomqtt.MQTT_ERR_SUCCESS 200 | assert mid is not None 201 | await asyncio.wait_for( 202 | unsubscribe_event.wait(), timeout=5, loop=event_loop) 203 | c.on_unsubscribe.assert_called_once_with(c, None, mid) 204 | 205 | finally: 206 | await asyncio.wait_for(c.loop_stop(), timeout=5, loop=event_loop) 207 | 208 | assert len(c.on_connect.mock_calls) == 1 209 | assert len(c.on_subscribe.mock_calls) == 1 210 | assert len(c.on_publish.mock_calls) == 2 211 | assert len(c.on_message.mock_calls) == 1 212 | assert len(message_callback.mock_calls) == 1 213 | assert len(c.on_unsubscribe.mock_calls) == 1 214 | --------------------------------------------------------------------------------