├── .github └── workflows │ ├── codeql-analysis.yml │ ├── pylint.yml │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── asyncio_paho ├── __init__.py ├── client.py └── py.typed ├── conftest.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_integration.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '37 6 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 22 | - name: Analysing the code with pylint 23 | run: | 24 | pylint $(git ls-files '*.py') 25 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10"] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .venv/ 132 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Debug Tests", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "purpose": [ 10 | "debug-test" 11 | ], 12 | "console": "integratedTerminal", 13 | "justMyCode": false, 14 | "pythonArgs": [ 15 | "-X", 16 | "dev" 17 | ] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": true, 5 | "editor.defaultFormatter": "ms-python.python" 6 | } 7 | }, 8 | "editor.formatOnSave": true, 9 | "editor.formatOnType": true, 10 | "files.trimTrailingWhitespace": true, 11 | "python.formatting.provider": "black", 12 | "python.linting.pylintEnabled": true, 13 | "python.linting.flake8Enabled": true, 14 | "python.linting.mypyEnabled": true, 15 | "python.testing.pytestArgs": [ 16 | "-s" 17 | ], 18 | "python.analysis.autoImportCompletions": false, 19 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tore Amundsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release](https://img.shields.io/github/release/toreamun/asyncio-paho)](https://github.com/toreamun/asyncio-paho/releases) 2 | [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/toreamun/asyncio-paho.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/toreamun/asyncio-paho/context:python) 3 | [![CodeQL](https://github.com/toreamun/asyncio-paho/workflows/CodeQL/badge.svg)](https://github.com/toreamun/asyncio-paho/actions?query=workflow%3ACodeQL&) 4 | [![License](https://img.shields.io/github/license/toreamun/asyncio-paho)](LICENSE) 5 | ![Project Maintenance](https://img.shields.io/badge/maintainer-Tore%20Amundsen%20%40toreamun-blue.svg) 6 | [![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-orange.svg)](https://www.buymeacoffee.com/toreamun) 7 | 8 | # Asynchronous I/O (asyncio) Paho MQTT client 9 | 10 | A [Paho MQTT](https://github.com/eclipse/paho.mqtt.python) client supporting [asyncio](https://docs.python.org/3/library/asyncio.html) loop without additional setup. Forget about configuring the [Paho network-loop](https://github.com/eclipse/paho.mqtt.python#network-loop). The client can almost be used as a drop-in replacement for Paho Client. The asyncio loop is automatically configured when you connect. 11 | 12 | ### Features 13 | 14 | - Drop-in replacement of Paho Client (inherits from Paho Client) 15 | - Automatic configuration of asyncio loop. 16 | - Reconnect on connection loss. 17 | - Type hinted. 18 | - Async callbacks. 19 | - Non blocking connect (`await client.asyncio_connect()`). 20 | - Python Asynchronous Context Manager handles cleanup. 21 | - No threading, only asyncio. 22 | 23 | ## Installation 24 | 25 | ``` 26 | pip install asyncio-paho 27 | ``` 28 | 29 | ## Usage 30 | 31 | You should use Paho [`connect_async()`](https://github.com/eclipse/paho.mqtt.python#connect_async) or extension [`asyncio_connect()`](#asyncio_connect) when connecting to avoid blocking. It is often usefull to configure subscriptions in on_connect callback to make sure all subscriptions is also setup on reconnect after connection loss. 32 | 33 | ### Drop-in replacement 34 | 35 | Remove all you calls to Paho looping like loop_forever() etc. 36 | 37 | ```python 38 | client = AsyncioPahoClient() 39 | client.connect_async("mqtt.eclipseprojects.io") 40 | 41 | # remove your current looping (loop_forever() etc) 42 | # do mqtt stuff 43 | 44 | client.Disconnect() 45 | 46 | ``` 47 | 48 | ### Asynchronous Context Manager 49 | 50 | The client is an Asynchronous Context Manager and can be used with the Python with statement to atomatically disconnect and clean up. 51 | 52 | ```python 53 | async with AsyncioPahoClient() as client: 54 | client.connect_async("mqtt.eclipseprojects.io") 55 | 56 | # do mqtt stuff - client.Disconnect() is called when exiting context. 57 | 58 | ``` 59 | 60 | ## Extensions 61 | 62 | The client has some additional async features (functions prefixed with `asyncio_`). 63 | 64 | ### asyncio_connect 65 | 66 | The classic Paho [`connect()`](https://github.com/eclipse/paho.mqtt.python#connect) is blocking. Paho [`connect_async()`](https://github.com/eclipse/paho.mqtt.python#connect_async) is not blocking, but returns before the connect is complete. Use `asyncio_connect()` to wait for connect to complete without blocking. This function also throws exception on connect failure. Please note that `asyncio_connect()` cannot be used together with `on_connect` /`on_connect_fail` (use `asyncio_add_on_connect_listener` and `asyncio_add_on_connect_fail_listener` instead of `on_connect` and `on_connect_fail`). 67 | 68 | ```python 69 | async with AsyncioPahoClient() as client: 70 | await client.asyncio_connect("mqtt.eclipseprojects.io") 71 | ``` 72 | 73 | ### asyncio_subscribe 74 | 75 | The classic Paho [`connect()`](https://github.com/eclipse/paho.mqtt.python#subscribe) returns before the subscriptions is acknowledged by the broker, and [`on_subscribe`](https://github.com/eclipse/paho.mqtt.python#on_subscribe) / `asyncio_listeners.add_on_subscribe()` has to be uses to capture the acknowledge if needed. The async extension `asyncio_subscribe()` can be used to subscribe and wait for the acknowledge without blocking. It is often usefull to configure subscriptions when connecting to make sure subscriptions are reconfigured on reconnect (connection lost). 76 | 77 | ```python 78 | async def on_connect_async(client, userdata, flags_dict, result): 79 | await client.asyncio_subscribe("mytopic") 80 | 81 | async def on_message_async(client, userdata, msg): 82 | print(f"Received from {msg.topic}: {str(msg.payload)}") 83 | 84 | async with AsyncioPahoClient() as client: 85 | client.asyncio_listeners.add_on_connect(on_connect_async) 86 | client.asyncio_listeners.add_on_message(on_message_async) 87 | await client.asyncio_connect("mqtt.eclipseprojects.io") 88 | ``` 89 | 90 | ### Callbacks 91 | 92 | Paho has a lot of callbacks. Async alternatives have been added for some of them, but they are mutally exclusive (you have to pick sync or async for eatch callback type). Multiple async listeners can be added to the same event, and a function handle to unsubscribe is returned when adding. 93 | 94 | | Classic Paho | Extension alternative | Called when | 95 | | ---------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------- | 96 | | [on_connect](https://github.com/eclipse/paho.mqtt.python#callback-connect) | asyncio_listeners.add_on_connect() | the broker responds to our connection | 97 | | on_connect_fail | asyncio_listeners.add_on_connect_fail() | the client failed to connect to the broker | 98 | | [on_message](https://github.com/eclipse/paho.mqtt.python#on_message) | asyncio_listeners.add_on_message() | a message has been received on a topic that the client subscribes to | 99 | | [message_callback_add](https://github.com/eclipse/paho.mqtt.python#message_callback_add) | asyncio_listeners.message_callback_add() | a message has been received on a topic for specific subscription filters | 100 | | [on_subscribe](https://github.com/eclipse/paho.mqtt.python#on_subscribe) | asyncio_listeners.add_on_subscribe() | the broker responds to a subscribe request | 101 | | [on_publish](https://github.com/eclipse/paho.mqtt.python#on_publish) | asyncio_listeners.add_on_publish() | a message that was to be sent using the publish() call has completed transmission to the broker | 102 | 103 | ```python 104 | 105 | async def on_connect_async(client, userdata, message) -> None: 106 | client.subscribe("mytopic") 107 | 108 | async with AsyncioPahoClient() as client: 109 | client.asyncio_add_on_connect_listener(on_connect_async) 110 | await client.asyncio_connect("mqtt.eclipseprojects.io") 111 | ``` 112 | 113 | #### asyncio_listeners.add_on_connect() 114 | 115 | Add async on_connect event listener. 116 | 117 | MQTT v3 callback signature: 118 | 119 | ```python 120 | async def callback(client: AsyncioPahoClient, userdata: Any, flags: dict[str, Any], rc: int) 121 | ``` 122 | 123 | MQTT v5 callback signature: 124 | 125 | ```python 126 | async def callback(client: AsyncioPahoClient, userdata: Any, flags: dict[str, reasonCode: ReasonCodes, properties: Properties]) 127 | ``` 128 | 129 | #### asyncio_listeners.add_on_message() 130 | 131 | Add async on_connect event listener. Callback signature: 132 | 133 | ```python 134 | async def callback(client: AsyncioPahoClient, userdata: Any, msg: MQTTMessage) 135 | ``` 136 | 137 | ## Dependencies 138 | 139 | - Python 3.8 or later. 140 | - [Paho MQTT](https://github.com/eclipse/paho.mqtt.python) 141 | 142 | The client uses asyncio event loop `add_reader()` and `add_writer()` methods. These methods are not supported on [Windows](https://docs.python.org/3/library/asyncio-platforms.html#windows) by [ProactorEventLoop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.ProactorEventLoop) (default on **Windows** from Python 3.8). You should be able to use another event loop like [SelectorEventLoop](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.SelectorEventLoop). 143 | -------------------------------------------------------------------------------- /asyncio_paho/__init__.py: -------------------------------------------------------------------------------- 1 | """Asyncio Paho MQTT client module.""" 2 | # flake8: noqa: F401 3 | from .client import AsyncioMqttAuthError, AsyncioMqttConnectError, AsyncioPahoClient 4 | 5 | __all__ = ["AsyncioMqttAuthError", "AsyncioMqttConnectError", "AsyncioPahoClient"] 6 | -------------------------------------------------------------------------------- /asyncio_paho/client.py: -------------------------------------------------------------------------------- 1 | """Asyncio Paho MQTT Client module.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import socket 6 | import time 7 | from collections.abc import Awaitable, Callable 8 | from enum import Enum, auto 9 | from typing import Any, Coroutine, Generator, List, Optional, Tuple, Union 10 | 11 | import paho.mqtt.client as paho 12 | from paho.mqtt import MQTTException 13 | 14 | TopicList = List[Union[str, int]] 15 | CONNECTION_ERROR_CODES = { 16 | 1: "Connection refused - incorrect protocol version", 17 | 2: "Connection refused - invalid client identifier", 18 | 3: "Connection refused - server unavailable", 19 | 4: "Connection refused - bad username or password", 20 | 5: "Connection refused - not authorised", 21 | } 22 | 23 | 24 | def connect_result_code_to_exception(result_code: int) -> AsyncioMqttConnectError: 25 | """Create exception from connect result code.""" 26 | if result_code in {4, 5}: 27 | return AsyncioMqttAuthError(result_code) 28 | return AsyncioMqttConnectError(result_code) 29 | 30 | 31 | class AsyncioMqttConnectError(MQTTException): # type: ignore 32 | """MQTT connect error.""" 33 | 34 | def __init__(self, result_code: int): 35 | """Initialize AsyncioMqttConnectError.""" 36 | self.result_code = result_code 37 | self.message = CONNECTION_ERROR_CODES.get( 38 | result_code, paho.error_string(result_code) 39 | ) 40 | super().__init__(self.message) 41 | 42 | def __str__(self) -> str: 43 | """Get exception string representation.""" 44 | return f"{self.result_code} - {self.message}" 45 | 46 | 47 | class AsyncioMqttAuthError(AsyncioMqttConnectError): 48 | """MQTT authentication error.""" 49 | 50 | 51 | class AsyncioPahoClient(paho.Client): # type: ignore 52 | # pylint: disable=too-many-instance-attributes 53 | """Paho MQTT Client using asyncio for connection loop.""" 54 | 55 | def __init__( 56 | self, 57 | client_id: str = "", 58 | clean_session: bool | None = None, 59 | userdata: Any | None = None, 60 | protocol: int = paho.MQTTv311, 61 | transport: str = "tcp", 62 | reconnect_on_failure: bool = True, 63 | loop: Optional[asyncio.AbstractEventLoop] = None, 64 | ) -> None: 65 | # pylint: disable=too-many-arguments 66 | """Initialize AsyncioPahoClient. See Paho Client for documentation.""" 67 | super().__init__( 68 | client_id, 69 | clean_session, 70 | userdata, 71 | protocol, 72 | transport, 73 | reconnect_on_failure, 74 | ) 75 | self._event_loop = loop or asyncio.get_running_loop() 76 | self._userdata = userdata 77 | self._reconnect_on_failure = reconnect_on_failure 78 | self._is_disconnecting = False 79 | self._is_connect_async = False 80 | self._connect_ex: Exception | None = None 81 | self._connect_callback_ex: Exception | None = None 82 | self._loop_misc_task: asyncio.Task[None] | None = None 83 | 84 | self._asyncio_listeners = _Listeners(self, self._event_loop, self._log) 85 | self.on_socket_open = self._on_socket_open_asyncio 86 | self.on_socket_close = self._on_socket_close_asyncio 87 | self.on_socket_register_write = self._on_socket_register_write_asyncio 88 | self.on_socket_unregister_write = self._on_socket_unregister_write_asyncio 89 | 90 | async def __aenter__(self) -> AsyncioPahoClient: 91 | """Enter contex.""" 92 | return self 93 | 94 | async def __aexit__(self, *args: Any, **kwargs: Any) -> None: 95 | """Exit context.""" 96 | self.disconnect() 97 | if self._loop_misc_task: 98 | try: 99 | await self._loop_misc_task 100 | except asyncio.CancelledError: 101 | return 102 | except Exception as ex: # pylint: disable=broad-except 103 | self._log(paho.MQTT_LOG_WARNING, "Error from loop_misc: %s", ex) 104 | 105 | @property 106 | def asyncio_listeners(self) -> _Listeners: 107 | """Async listeners.""" 108 | return self._asyncio_listeners 109 | 110 | def connect_async( 111 | self, 112 | host: str, 113 | port: int = 1883, 114 | keepalive: int = 60, 115 | bind_address: str = "", 116 | bind_port: int = 0, 117 | clean_start: bool | int = paho.MQTT_CLEAN_START_FIRST_ONLY, 118 | properties: paho.Properties | None = None, 119 | ) -> None: 120 | # pylint: disable=too-many-arguments 121 | """ 122 | Connect to a remote broker asynchronously. 123 | 124 | This is a non-blocking connect call that can be used to provide very quick start. 125 | """ 126 | self._is_connect_async = True 127 | self._ensure_loop_misc_started() # loop must be started for connect to proceed 128 | super().connect_async( 129 | host, port, keepalive, bind_address, bind_port, clean_start, properties 130 | ) 131 | 132 | def disconnect( 133 | self, 134 | reasoncode: paho.ReasonCodes = None, 135 | properties: paho.Properties | None = None, 136 | ) -> int: 137 | """Disconnect a connected client from the broker.""" 138 | result: int = super().disconnect(reasoncode, properties) 139 | self._is_disconnecting = True 140 | if self._loop_misc_task: 141 | self._loop_misc_task.cancel() 142 | self._loop_misc_task = None 143 | return result 144 | 145 | async def asyncio_connect( 146 | self, 147 | host: str, 148 | port: int = 1883, 149 | keepalive: int = 60, 150 | bind_address: str = "", 151 | bind_port: int = 0, 152 | clean_start: bool | int = paho.MQTT_CLEAN_START_FIRST_ONLY, 153 | properties: paho.Properties | None = None, 154 | ignore_connect_error: bool = False, 155 | ) -> Optional[Any]: 156 | # pylint: disable=too-many-arguments 157 | """Connect to a remote broker asynchronously and return when done.""" 158 | connect_future: asyncio.Future[int] = self._event_loop.create_future() 159 | self._connect_callback_ex = None 160 | self._connect_callback_ex = None 161 | 162 | if self.on_connect not in ( 163 | None, 164 | self._asyncio_listeners._on_connect_forwarder, # pylint: disable=protected-access 165 | ) or self.on_connect_fail not in ( 166 | None, 167 | self._asyncio_listeners._on_connect_fail_forwarder, # pylint: disable=protected-access 168 | ): 169 | raise RuntimeError( 170 | ( 171 | "async_connect cannot be used when on_connect or on_connect_fail is set. " 172 | "Use asyncio_listeners instead of setting on_connect." 173 | ) 174 | ) 175 | 176 | async def connect_callback(*args: Any) -> None: 177 | # pylint: disable=unused-argument 178 | nonlocal connect_future 179 | if self._connect_callback_ex or self._connect_ex: 180 | connect_exception = self._connect_ex or self._connect_callback_ex 181 | assert connect_exception 182 | connect_future.set_exception(connect_exception) 183 | else: 184 | result_code = args[3] 185 | connect_future.set_result(result_code) 186 | 187 | unsubscribe_connect = self.asyncio_listeners.add_on_connect( 188 | connect_callback, is_high_pri=True 189 | ) 190 | unsubscribe_connect_fail = self.asyncio_listeners.add_on_connect_fail( 191 | connect_callback, is_high_pri=True 192 | ) 193 | try: 194 | self.connect_async( 195 | host, port, keepalive, bind_address, bind_port, clean_start, properties 196 | ) 197 | 198 | result_code = await connect_future 199 | 200 | if not ignore_connect_error and result_code != paho.MQTT_ERR_SUCCESS: 201 | self.disconnect(result_code, properties) 202 | raise connect_result_code_to_exception(result_code) 203 | 204 | return result_code 205 | finally: 206 | unsubscribe_connect() 207 | unsubscribe_connect_fail() 208 | 209 | async def asyncio_publish( 210 | self, 211 | topic: str, 212 | payload: Any | None = None, 213 | qos: int = 0, 214 | retain: bool = False, 215 | properties: paho.Properties | None = None, 216 | ) -> int: 217 | # pylint: disable=too-many-arguments 218 | """Publish a message on a topic.""" 219 | subscribed_future: asyncio.Future[int] = self._event_loop.create_future() 220 | 221 | result: paho.MQTTMessageInfo 222 | 223 | async def on_publish(client: paho.Client, userdata: Any, mid: int) -> None: 224 | # pylint: disable=unused-argument 225 | nonlocal result 226 | if result.mid == mid: 227 | nonlocal subscribed_future 228 | subscribed_future.set_result(mid) 229 | 230 | unsubscribe = self.asyncio_listeners.add_on_publish( 231 | on_publish, is_high_pri=True 232 | ) 233 | try: 234 | result = super().publish(topic, payload, qos, retain, properties) 235 | while not result.is_published(): 236 | await asyncio.sleep(0.001) 237 | return await subscribed_future 238 | finally: 239 | unsubscribe() 240 | 241 | async def asyncio_subscribe( 242 | self, 243 | topic: str | Tuple[Union[str, List[TopicList], Any] | TopicList], 244 | qos: int = 0, 245 | options: paho.SubscribeOptions | None = None, 246 | properties: paho.Properties | None = None, 247 | ) -> Optional[Tuple[int, int]]: 248 | """Subscribe the client to one or more topics.""" 249 | subscribed_future = self._event_loop.create_future() 250 | result: tuple[int, int] 251 | 252 | async def on_subscribe(*args: Any) -> None: 253 | # pylint: disable=unused-argument 254 | nonlocal result 255 | if result[1] == args[2]: # mid should match if relevant 256 | nonlocal subscribed_future 257 | subscribed_future.set_result(None) 258 | 259 | unsubscribe = self.asyncio_listeners.add_on_subscribe( 260 | on_subscribe, is_high_pri=True 261 | ) 262 | try: 263 | result = super().subscribe(topic, qos, options, properties) 264 | 265 | if result[0] == paho.MQTT_ERR_NO_CONN: 266 | return result 267 | 268 | await subscribed_future 269 | return result 270 | finally: 271 | unsubscribe() 272 | 273 | def user_data_set(self, userdata: Any) -> None: 274 | """Set the user data variable passed to callbacks. May be any data type.""" 275 | self._userdata = userdata 276 | super().user_data_set(userdata) 277 | 278 | def loop_forever(self, *args: Any) -> None: 279 | """Invalid operation.""" 280 | raise NotImplementedError( 281 | "loop_forever() cannot be used with AsyncioPahoClient." 282 | ) 283 | 284 | def loop_start(self) -> None: 285 | """Invalid operation.""" 286 | raise NotImplementedError( 287 | "The threaded interface of loop_start() cannot be used with AsyncioPahoClient." 288 | ) 289 | 290 | def loop_stop(self, force: bool = ...) -> None: 291 | """Invalid operation.""" 292 | raise NotImplementedError( 293 | "The threaded interface of loop_stop() cannot be used with AsyncioPahoClient." 294 | ) 295 | 296 | def _on_socket_open_asyncio( 297 | self, client: paho.Client, _: Any, sock: Union[socket.socket, Any] 298 | ) -> None: 299 | self._event_loop.add_reader(sock, client.loop_read) 300 | # When transport="websockets", sock is WebsocketWrapper which has no setsockopt: 301 | if isinstance(sock, socket.socket): 302 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048) 303 | self._ensure_loop_misc_started() 304 | 305 | def _on_socket_close_asyncio( 306 | self, 307 | client: paho.Client, 308 | userdata: Any, 309 | sock: socket.socket, 310 | ) -> None: 311 | # pylint: disable=unused-argument 312 | self._event_loop.remove_reader(sock) 313 | 314 | def _on_socket_register_write_asyncio( 315 | self, client: paho.Client, userdata: Any, sock: socket.socket 316 | ) -> None: 317 | # pylint: disable=unused-argument 318 | self._event_loop.add_writer(sock, client.loop_write) 319 | 320 | def _on_socket_unregister_write_asyncio( 321 | self, client: paho.Client, userdata: Any, sock: socket.socket 322 | ) -> None: 323 | # pylint: disable=unused-argument 324 | self._event_loop.remove_writer(sock) 325 | 326 | def _ensure_loop_misc_started(self) -> None: 327 | if self._loop_misc_task is None: 328 | self._loop_misc_task = self._event_loop.create_task(self._loop_misc()) 329 | 330 | if self._loop_misc_task.done(): 331 | try: 332 | self._loop_misc_task.result() 333 | self._log(paho.MQTT_LOG_DEBUG, "loop_misc task was done.") 334 | except asyncio.CancelledError: 335 | pass # Task cancellation should not be logged as an error. 336 | except Exception as ex: # pylint: disable=broad-except 337 | self._log( 338 | paho.MQTT_LOG_WARNING, "Exception raised by loop_misc task: %s", ex 339 | ) 340 | self._loop_misc_task = self._event_loop.create_task(self._loop_misc()) 341 | 342 | async def _loop_misc(self) -> None: 343 | try: 344 | self._connect_ex = None 345 | self._is_disconnecting = False 346 | if self._is_connect_async: 347 | try: 348 | self.reconnect() 349 | except Exception as ex: 350 | self._connect_ex = ex 351 | on_connect_fail = super().on_connect_fail 352 | if on_connect_fail: 353 | on_connect_fail(self, self._userdata) 354 | raise 355 | 356 | self._is_connect_async = False 357 | 358 | while True: 359 | 360 | return_code = paho.MQTT_ERR_SUCCESS 361 | while return_code == paho.MQTT_ERR_SUCCESS: 362 | return_code = self.loop_misc() 363 | await asyncio.sleep(1) 364 | 365 | if self._is_disconnecting or not self._reconnect_on_failure: 366 | self._log(paho.MQTT_LOG_DEBUG, "Disconnecting. Exit misc loop.") 367 | return 368 | 369 | await self._async_reconnect_wait() 370 | 371 | if self._is_disconnecting: 372 | self._log(paho.MQTT_LOG_DEBUG, "Disconnecting. Exit misc loop.") 373 | return 374 | 375 | self._reconnect() 376 | except asyncio.CancelledError: 377 | self._log(paho.MQTT_LOG_DEBUG, "Loop misc cancelled.") 378 | return 379 | 380 | def _reconnect(self) -> None: 381 | try: 382 | self.reconnect() 383 | except (OSError, paho.WebsocketConnectionError): 384 | on_connect_fail = super().on_connect_fail 385 | if on_connect_fail: 386 | on_connect_fail(self, self._userdata) 387 | self._log(paho.MQTT_LOG_DEBUG, "Connection failed, retrying") 388 | 389 | async def _async_reconnect_wait(self) -> None: 390 | # See reconnect_delay_set for details 391 | now = time.monotonic() 392 | with self._reconnect_delay_mutex: 393 | self._reconnect_delay: int 394 | if self._reconnect_delay is None: 395 | self._reconnect_delay = self._reconnect_min_delay 396 | else: 397 | self._reconnect_delay = min( 398 | self._reconnect_delay * 2, 399 | self._reconnect_max_delay, 400 | ) 401 | 402 | target_time = now + self._reconnect_delay 403 | 404 | remaining = target_time - now 405 | await asyncio.sleep(remaining) 406 | 407 | def _log(self, level: Any, fmt: object, *args: object) -> None: 408 | easy_log = getattr(super(), "_easy_log", None) 409 | if easy_log is not None: 410 | easy_log(level, fmt, *args) 411 | 412 | 413 | class _EventType(Enum): 414 | ON_CONNECT = auto() 415 | ON_CONNECT_FAILED = auto() 416 | ON_MESSAGE = auto() 417 | ON_SUBSCRIBE = auto() 418 | ON_PUBLISH = auto() 419 | 420 | 421 | class _Listeners: 422 | def __init__( 423 | self, 424 | client: AsyncioPahoClient, 425 | loop: asyncio.AbstractEventLoop, 426 | log: Callable[[int, str, str], None], 427 | ) -> None: 428 | self._client = client 429 | self._event_loop = loop 430 | self._async_listeners: dict[_EventType, List[Any]] = {} 431 | self._log = log 432 | 433 | def _handle_callback_result(self, task: asyncio.Task[None]) -> None: 434 | try: 435 | task.result() 436 | self._log( 437 | paho.MQTT_LOG_DEBUG, "%s callback task completed", task.get_name() 438 | ) 439 | except asyncio.CancelledError: 440 | pass # Task cancellation should not be logged as an error. 441 | except Exception: # pylint: disable=broad-except 442 | self._log( 443 | paho.MQTT_LOG_WARNING, 444 | "Exception raised by %s callback task", 445 | task.get_name(), 446 | ) 447 | 448 | def _get_async_listeners(self, event_type: _EventType) -> List[Any]: 449 | return self._async_listeners.setdefault(event_type, []) 450 | 451 | def _add_async_listener( 452 | self, event_type: _EventType, callback, is_high_pri=False 453 | ) -> Callable[[], None]: 454 | listeners = self._get_async_listeners(event_type) 455 | if is_high_pri: 456 | listeners.insert(0, callback) 457 | else: 458 | listeners.append(callback) 459 | 460 | def unsubscribe() -> None: 461 | if callback in listeners: 462 | listeners.remove(callback) 463 | 464 | return unsubscribe 465 | 466 | def _async_forwarder(self, event_type: _EventType, *args: Any) -> None: 467 | async_listeners = self._get_async_listeners(event_type) 468 | for listener in async_listeners: 469 | self._event_loop.create_task( 470 | listener(*args), name=event_type.name 471 | ).add_done_callback(self._handle_callback_result) 472 | 473 | def add_on_connect( 474 | self, 475 | callback: Callable[[paho.Client, Any, dict[str, Any], int], Awaitable[None]] 476 | | Callable[ 477 | [paho.Client, Any, dict[str, Any], paho.ReasonCodes, paho.Properties], 478 | Awaitable[None], 479 | ], 480 | is_high_pri: bool = False, 481 | ) -> Callable[[], None]: 482 | """Add on_connect async listener.""" 483 | paho.Client.on_connect.fset(self._client, self._on_connect_forwarder) 484 | return self._add_async_listener(_EventType.ON_CONNECT, callback, is_high_pri) 485 | 486 | def _on_connect_forwarder(self, *args: Any) -> None: 487 | self._client._connect_callback_ex = None # pylint: disable=protected-access 488 | try: 489 | self._async_forwarder(_EventType.ON_CONNECT, *args) 490 | except Exception as ex: # pylint: disable=broad-except 491 | self._client._connect_callback_ex = ex # pylint: disable=protected-access 492 | 493 | def add_on_connect_fail( 494 | self, 495 | callback: Callable[[paho.Client, Any], Awaitable[None]], 496 | is_high_pri: bool = False, 497 | ) -> Callable[[], None]: 498 | """Add on_connect_fail async listener.""" 499 | on_connect_fail = paho.Client.on_connect_fail 500 | on_connect_fail.fset(self._client, self._on_connect_fail_forwarder) 501 | return self._add_async_listener( 502 | _EventType.ON_CONNECT_FAILED, callback, is_high_pri 503 | ) 504 | 505 | def _on_connect_fail_forwarder(self, *args: Any) -> None: 506 | self._async_forwarder(_EventType.ON_CONNECT_FAILED, *args) 507 | 508 | def add_on_message( 509 | self, 510 | callback: Callable[[paho.Client, Any, paho.MQTTMessage], Awaitable[None]], 511 | ) -> Callable[[], None]: 512 | """Add on_connect_fail async listener.""" 513 | 514 | def forwarder(*args: Any) -> None: 515 | self._async_forwarder(_EventType.ON_MESSAGE, *args) 516 | 517 | paho.Client.on_message.fset(self._client, forwarder) 518 | return self._add_async_listener(_EventType.ON_MESSAGE, callback) 519 | 520 | def message_callback_add( 521 | self, 522 | sub: str, 523 | callback: Callable[ 524 | [paho.Client, Any, paho.MQTTMessage], 525 | Union[Coroutine[Any, Any, None], Generator[Any, None, None]], 526 | ], 527 | ) -> None: 528 | """Register an async message callback for a specific topic.""" 529 | 530 | def forwarder(*args: Any) -> None: 531 | self._event_loop.create_task( 532 | callback(*args), name="message_callback" 533 | ).add_done_callback(self._handle_callback_result) 534 | 535 | self._client.message_callback_add(sub, forwarder) 536 | 537 | def add_on_subscribe( 538 | self, 539 | callback: Callable[[paho.Client, Any, int, tuple[int, ...]], Awaitable[None]] 540 | | Callable[ 541 | [paho.Client, Any, int, list[int], paho.Properties], Awaitable[None] 542 | ], 543 | is_high_pri: bool = False, 544 | ) -> Callable[[], None]: 545 | """Add on_subscribe async listener.""" 546 | 547 | def forwarder(*args: Any) -> None: 548 | self._async_forwarder(_EventType.ON_SUBSCRIBE, *args) 549 | 550 | paho.Client.on_subscribe.fset(self._client, forwarder) 551 | return self._add_async_listener(_EventType.ON_SUBSCRIBE, callback, is_high_pri) 552 | 553 | def add_on_publish( 554 | self, 555 | callback: Callable[[paho.Client, Any, int], Awaitable[None]], 556 | is_high_pri: bool = False, 557 | ) -> Callable[[], None]: 558 | """Add on_publish async listener.""" 559 | 560 | def forwarder(*args: Any) -> None: 561 | self._async_forwarder(_EventType.ON_PUBLISH, *args) 562 | 563 | paho.Client.on_publish.fset(self._client, forwarder) 564 | return self._add_async_listener(_EventType.ON_PUBLISH, callback, is_high_pri) 565 | -------------------------------------------------------------------------------- /asyncio_paho/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toreamun/asyncio-paho/2c7ccd22e8f83342af4c3ed81939397e6cd923a7/asyncio_paho/py.typed -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toreamun/asyncio-paho/2c7ccd22e8f83342af4c3ed81939397e6cd923a7/conftest.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt 2 | pytest-asyncio 3 | paho-mqtt-stubs -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = False 4 | 5 | # To work with Black 6 | max-line-length = 88 7 | # E501: line too long 8 | # W503: Line break occurred before a binary operator 9 | # E203: Whitespace before ':' 10 | # D202 No blank lines allowed after function docstring 11 | # W504 line break after binary operator 12 | ignore = E501, W503, E203, D202, W504, D102, D101, D107 13 | 14 | [tool:pytest] 15 | asyncio_mode = auto 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python package setup.""" 2 | import setuptools 3 | 4 | with open("README.md", "r", encoding="UTF-8") as fh: 5 | long_description = fh.read() 6 | 7 | setuptools.setup( 8 | name="asyncio_paho", 9 | version="0.6.0", 10 | author="Tore Amundsen", 11 | author_email="tore@amundsen.org", 12 | description="A Paho MQTT client supporting asyncio loop without additional setup.", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/toreamun/asyncio-paho", 16 | packages=["asyncio_paho"], 17 | package_data={"asyncio_paho": ["py.typed"]}, 18 | keywords=[ 19 | "paho", 20 | "mqtt", 21 | "asyncio", 22 | ], 23 | classifiers=[ 24 | "Intended Audience :: Developers", 25 | "Programming Language :: Python :: 3", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: MacOS :: MacOS X", 28 | "Operating System :: Microsoft :: Windows", 29 | "Operating System :: POSIX", 30 | "Natural Language :: English", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.8", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | ], 36 | python_requires=">=3.8", 37 | install_requires=["paho-mqtt~=1.6"], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toreamun/asyncio-paho/2c7ccd22e8f83342af4c3ed81939397e6cd923a7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | """AsyncioPahoClient integration tests.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | import uuid 7 | from typing import Any 8 | 9 | import paho.mqtt.client as paho 10 | import pytest 11 | from asyncio_paho import AsyncioPahoClient 12 | from asyncio_paho.client import AsyncioMqttAuthError 13 | 14 | TOPIC = "asyncioclient" 15 | MQTT_HOST = "test.mosquitto.org" 16 | MQTT_PORT_TCP_AUTH = 1884 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_connect_publish_subscribe(event_loop: asyncio.AbstractEventLoop, caplog): 21 | """Test connect.""" 22 | caplog.set_level(logging.DEBUG) 23 | 24 | subscribed_future = event_loop.create_future() 25 | done_future = event_loop.create_future() 26 | subscribe_result: tuple[int, int] = (-1, -1) 27 | test_topic = f"{TOPIC}/{uuid.uuid4()}" 28 | 29 | def on_connect( 30 | client: paho.Client, userdata: Any, flags_dict: dict[str, Any], result: int 31 | ) -> None: 32 | # pylint: disable=unused-argument 33 | nonlocal subscribe_result 34 | subscribe_result = client.subscribe(test_topic) 35 | 36 | assert subscribe_result[0] == paho.MQTT_ERR_SUCCESS 37 | 38 | print( 39 | f"Connected and subscribed to topic {test_topic} with result {subscribe_result}" 40 | ) 41 | 42 | def on_connect_fail(client: paho.Client, userdata: Any) -> None: 43 | # pylint: disable=unused-argument 44 | print("Connect failed") 45 | 46 | def on_subscribe( 47 | client: paho.Client, userdata: Any, mid: int, granted_qos: tuple[int, ...] 48 | ) -> None: 49 | # pylint: disable=unused-argument 50 | print("Subscription done.") 51 | 52 | nonlocal subscribe_result 53 | assert mid == subscribe_result[1] 54 | 55 | subscribed_future.set_result(None) 56 | 57 | def on_message(client, userdata, msg: paho.MQTTMessage): 58 | # pylint: disable=unused-argument 59 | print(f"Received from {msg.topic}: {str(msg.payload)}") 60 | done_future.set_result(msg.payload) 61 | 62 | def on_log(client, userdata, level, buf): 63 | # pylint: disable=unused-argument 64 | print(f"LOG: {buf}") 65 | 66 | async with AsyncioPahoClient(loop=event_loop) as client: 67 | client.on_connect = on_connect 68 | client.on_connect_fail = on_connect_fail 69 | client.on_subscribe = on_subscribe 70 | client.on_message = on_message 71 | client.on_log = on_log 72 | 73 | client.connect_async(MQTT_HOST) 74 | 75 | # wait for subscription be done before publishing 76 | await subscribed_future 77 | client.publish(test_topic, "this is a test") 78 | 79 | received = await done_future 80 | 81 | assert received == b"this is a test" 82 | 83 | 84 | @pytest.mark.asyncio 85 | @pytest.mark.parametrize("protocol", [paho.MQTTv31, paho.MQTTv311, paho.MQTTv5]) 86 | @pytest.mark.parametrize("qos", [0, 1, 2]) 87 | async def test_async_connect_publish_subscribe( 88 | event_loop: asyncio.AbstractEventLoop, caplog, protocol, qos 89 | ): 90 | """Test connect.""" 91 | caplog.set_level(logging.DEBUG) 92 | subscribed_future = event_loop.create_future() 93 | received_future = event_loop.create_future() 94 | test_topic = f"{TOPIC}/{protocol}/{uuid.uuid4()}" 95 | 96 | async def on_connect() -> None: 97 | subscribe_result = await client.asyncio_subscribe(test_topic, qos=2) 98 | assert subscribe_result[0] == paho.MQTT_ERR_SUCCESS 99 | print( 100 | f"Connected and subscribed to topic {test_topic} with result {subscribe_result}" 101 | ) 102 | nonlocal subscribed_future 103 | subscribed_future.set_result(subscribe_result) 104 | 105 | async def on_connect_v3_async( 106 | client: AsyncioPahoClient, 107 | userdata: Any, 108 | flags_dict: dict[str, Any], 109 | result: int, 110 | ) -> None: 111 | # pylint: disable=unused-argument 112 | await on_connect() 113 | 114 | async def on_connect_v5_async( 115 | client: AsyncioPahoClient, 116 | userdata: Any, 117 | flags: dict[str, Any], 118 | reason_code: paho.ReasonCodes, 119 | properties: paho.Properties, 120 | ) -> None: 121 | # pylint: disable=unused-argument 122 | await on_connect() 123 | 124 | async def on_message_async(client, userdata, msg: paho.MQTTMessage): 125 | # pylint: disable=unused-argument 126 | print(f"Received from {msg.topic}: {str(msg.payload)}") 127 | nonlocal received_future 128 | received_future.set_result(msg) 129 | 130 | async with AsyncioPahoClient(loop=event_loop, protocol=protocol) as client: 131 | if protocol == paho.MQTTv5: 132 | # client.asyncio_listeners.add_on_connect(on_connect_v3_async) 133 | client.asyncio_listeners.add_on_connect(on_connect_v5_async) 134 | else: 135 | client.asyncio_listeners.add_on_connect(on_connect_v3_async) 136 | 137 | client.asyncio_listeners.add_on_message(on_message_async) 138 | client.on_log = lambda client, userdata, level, buf: print(f"LOG: {buf}") 139 | 140 | await client.asyncio_connect(MQTT_HOST) 141 | await subscribed_future 142 | 143 | await client.asyncio_publish(test_topic, "this is a test", qos=qos) 144 | received: paho.MQTTMessage = await received_future 145 | assert received.payload == b"this is a test" 146 | assert received.qos == qos 147 | 148 | 149 | @pytest.mark.asyncio 150 | @pytest.mark.parametrize("protocol", [paho.MQTTv31, paho.MQTTv311, paho.MQTTv5]) 151 | @pytest.mark.parametrize("qos", [0, 1, 2]) 152 | async def test_async_connect_publish_subscribe_retained( 153 | event_loop: asyncio.AbstractEventLoop, caplog, protocol, qos 154 | ): 155 | """Test connect.""" 156 | caplog.set_level(logging.DEBUG) 157 | received_future = event_loop.create_future() 158 | test_topic = f"{TOPIC}/{uuid.uuid4()}" 159 | 160 | async with AsyncioPahoClient(loop=event_loop, protocol=protocol) as client_send: 161 | client_send.on_log = lambda client, userdata, level, buf: print(f"LOG: {buf}") 162 | await client_send.asyncio_connect(MQTT_HOST) 163 | await client_send.asyncio_publish( 164 | test_topic, "this is a test", qos=qos, retain=True 165 | ) 166 | 167 | async def on_message_async(client, userdata, msg: paho.MQTTMessage): 168 | # pylint: disable=unused-argument 169 | print(f"Received from {msg.topic}: {str(msg.payload)}") 170 | nonlocal received_future 171 | received_future.set_result(msg) 172 | 173 | async with AsyncioPahoClient(loop=event_loop, protocol=protocol) as client_retained: 174 | client_retained.on_log = lambda client, userdata, level, buf: print( 175 | f"LOG: {buf}" 176 | ) 177 | client_retained.asyncio_listeners.add_on_message(on_message_async) 178 | await client_retained.asyncio_connect(MQTT_HOST) 179 | await client_retained.asyncio_subscribe(test_topic) 180 | 181 | received: paho.MQTTMessage = await received_future 182 | assert received.payload == b"this is a test" 183 | assert received.retain == 1 184 | 185 | # remove retained 186 | await client_retained.asyncio_publish(test_topic, None) 187 | 188 | 189 | @pytest.mark.asyncio 190 | async def test_connect_connection_refused( 191 | event_loop: asyncio.AbstractEventLoop, caplog 192 | ): 193 | """Test ConnectionRefusedError.""" 194 | caplog.set_level(logging.DEBUG) 195 | 196 | async with AsyncioPahoClient(loop=event_loop) as client: 197 | with pytest.raises(ConnectionRefusedError): 198 | await client.asyncio_connect("127.0.0.1", 1) 199 | 200 | 201 | @pytest.mark.asyncio 202 | async def test_connect_not_authorised(event_loop: asyncio.AbstractEventLoop, caplog): 203 | """Test ConnectionRefusedError.""" 204 | caplog.set_level(logging.DEBUG) 205 | 206 | async with AsyncioPahoClient(loop=event_loop) as client: 207 | client.username_pw_set("unknown_username", "") 208 | with pytest.raises(AsyncioMqttAuthError): 209 | await client.asyncio_connect(MQTT_HOST, MQTT_PORT_TCP_AUTH) 210 | --------------------------------------------------------------------------------