├── .github ├── FUNDING.yml └── workflows │ └── pythonpackage.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat └── monitor.png ├── examples ├── broker_and_clients.py ├── increment.py ├── on_message.py ├── publish.py ├── publish_to_self.py ├── reconnect.py ├── resume_session.py ├── subscribe.py ├── topic_alias.py ├── unsubscribe.py └── will.py ├── mqttools ├── __init__.py ├── __main__.py ├── broker.py ├── client.py ├── common.py ├── subparsers │ ├── __init__.py │ ├── broker.py │ ├── monitor.py │ ├── publish.py │ └── subscribe.py └── version.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_broker.py ├── test_broker_thread.py ├── test_client.py ├── test_format_packet.py ├── test_mqttools.py ├── test_pack_unpack.py ├── test_reconnect.py ├── test_request_respose.py └── utils.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: eerimoq 2 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: [3.7, 3.9] 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install -r requirements.txt 22 | - name: Test 23 | run: | 24 | python -m unittest 25 | 26 | release: 27 | needs: [test] 28 | runs-on: ubuntu-20.04 29 | if: startsWith(github.ref, 'refs/tags') 30 | 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v1 34 | - name: Set up Python 3.9 35 | uses: actions/setup-python@v1 36 | with: 37 | python-version: 3.9 38 | - name: Install pypa/build 39 | run: | 40 | python -m pip install build --user 41 | - name: Build a binary wheel and a source tarball 42 | run: | 43 | git clean -dfx 44 | python -m build --sdist --outdir dist/ . 45 | - name: Publish distribution 📦 to PyPI 46 | uses: pypa/gh-action-pypi-publish@master 47 | with: 48 | skip_existing: true 49 | password: ${{ secrets.pypi_password }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | *.o 8 | a.out 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | venv/ 14 | .venv/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 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 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # Vim IDE 64 | *~ 65 | *.swp 66 | *.swo 67 | 68 | # IntelliJ IDEA 69 | .idea/ 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Erik Moqvist 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 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include Makefile 3 | recursive-include tests *.py -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python3 -m unittest 3 | 4 | style: 5 | isort --force-single-line-imports . 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | MQTT Tools 2 | ========== 3 | 4 | MQTT tools in Python 3.7 and later. 5 | 6 | Both the client and the broker implements MQTT version 5.0 using 7 | ``asyncio``. 8 | 9 | Client features: 10 | 11 | - Subscribe to and publish QoS level 0 topics. 12 | 13 | - Broker session resume (or clean start support) for less initial 14 | communication. 15 | 16 | - Topic aliases for smaller publish packets. 17 | 18 | - ``monitor``, ``subscribe`` and ``publish`` command line commands. 19 | 20 | Broker features: 21 | 22 | - Subscribe to and publish QoS level 0 topics. 23 | 24 | - Session resume (or clean start support) for less initial 25 | communication. Session state storage in RAM. 26 | 27 | - ``broker`` command line command. 28 | 29 | Limitations: 30 | 31 | There are lots of limitations in both the client and the broker. Here 32 | are a few of them: 33 | 34 | - QoS level 1 and 2 messages are not supported. A session state storage 35 | is required to do so, both in the client and the broker. 36 | 37 | - Authentication is not supported. 38 | 39 | MQTT version 5.0 specification: 40 | https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html 41 | 42 | Project homepage: https://github.com/eerimoq/mqttools 43 | 44 | Documentation: https://mqttools.readthedocs.io 45 | 46 | Installation 47 | ============ 48 | 49 | .. code-block:: python 50 | 51 | pip install mqttools 52 | 53 | Examples 54 | ======== 55 | 56 | There are plenty of examples in the `examples folder`_. 57 | 58 | Command line 59 | ------------ 60 | 61 | Subscribe 62 | ^^^^^^^^^ 63 | 64 | Connect to given MQTT broker and subscribe to a topic. All received 65 | messages are printed to standard output. 66 | 67 | .. code-block:: text 68 | 69 | $ mqttools subscribe /test/# 70 | Connecting to 'localhost:1883'. 71 | Connected. 72 | Topic: /test 73 | Message: 11 74 | Topic: /test/mqttools/foo 75 | Message: bar 76 | 77 | Publish 78 | ^^^^^^^ 79 | 80 | Connect to given MQTT broker and publish a message to a topic. 81 | 82 | .. code-block:: text 83 | 84 | $ mqttools publish /test/mqttools/foo bar 85 | Connecting to 'localhost:1883'. 86 | 87 | Published 1 message(s) in 0 seconds from 1 concurrent task(s). 88 | 89 | Publish multiple messages as quickly as possible with ``--count`` to 90 | benchmark the client and the broker. 91 | 92 | .. code-block:: text 93 | 94 | $ mqttools publish --count 100 /test/mqttools/foo 95 | Connecting to 'localhost:1883'. 96 | 97 | Published 100 message(s) in 0.39 seconds from 10 concurrent task(s). 98 | 99 | Monitor 100 | ^^^^^^^ 101 | 102 | Connect to given MQTT broker and monitor given topics in a text based 103 | user interface. 104 | 105 | .. code-block:: text 106 | 107 | $ mqttools monitor /test/# 108 | 109 | .. image:: https://github.com/eerimoq/mqttools/raw/master/docs/monitor.png 110 | 111 | The menu at the bottom of the monitor shows the available commands. 112 | 113 | - Quit: Quit the monitor. Ctrl-C can be used as well. 114 | 115 | - Play/Pause: Toggle between playing and paused (or running and freezed). 116 | 117 | - Format: Message formatting; `auto`, `binary` or `text`. 118 | 119 | Broker 120 | ^^^^^^ 121 | 122 | Start a broker to serve clients. 123 | 124 | .. code-block:: text 125 | 126 | $ mqttools broker 127 | 128 | Scripting 129 | --------- 130 | 131 | Subscribe 132 | ^^^^^^^^^ 133 | 134 | An example connecting to an MQTT broker, subscribing to the topic 135 | ``/test/#``, and printing all published messaged. 136 | 137 | .. code-block:: python 138 | 139 | import asyncio 140 | import mqttools 141 | 142 | async def subscriber(): 143 | client = mqttools.Client('localhost', 1883) 144 | 145 | await client.start() 146 | await client.subscribe('/test/#') 147 | 148 | while True: 149 | message = await client.messages.get() 150 | 151 | if message is None: 152 | print('Broker connection lost!') 153 | break 154 | 155 | print(f'Topic: {message.topic}') 156 | print(f'Message: {message.message}') 157 | 158 | asyncio.run(subscriber()) 159 | 160 | Publish 161 | ^^^^^^^ 162 | 163 | An example connecting to an MQTT broker and publishing the message 164 | ``bar`` to the topic ``/test/mqttools/foo``. 165 | 166 | .. code-block:: python 167 | 168 | import asyncio 169 | import mqttools 170 | 171 | async def publisher(): 172 | async with mqttools.Client('localhost', 1883) as client: 173 | client.publish(mqttools.Message('/test/mqttools/foo', b'bar')) 174 | 175 | asyncio.run(publisher()) 176 | 177 | .. _examples folder: https://github.com/eerimoq/mqttools/tree/master/examples 178 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/mqttools.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/mqttools.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/mqttools" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/mqttools" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # mqttools documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Apr 25 11:54:09 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import shlex 17 | import sys 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | import mqttools 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.viewcode', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | #source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = u'mqttools' 55 | copyright = u'2019, Erik Moqvist' 56 | author = u'Erik Moqvist' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = mqttools.__version__ 64 | # The full version, including alpha/beta/rc tags. 65 | release = mqttools.__version__ 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | # If true, `todo` and `todoList` produce output, else they produce nothing. 109 | todo_include_todos = False 110 | 111 | 112 | # -- Options for HTML output ---------------------------------------------- 113 | 114 | # The theme to use for HTML and HTML Help pages. See the documentation for 115 | # a list of builtin themes. 116 | html_theme = 'sphinx_rtd_theme' 117 | 118 | # Theme options are theme-specific and customize the look and feel of a theme 119 | # further. For a list of options available for each theme, see the 120 | # documentation. 121 | #html_theme_options = {} 122 | 123 | # Add any paths that contain custom themes here, relative to this directory. 124 | #html_theme_path = [] 125 | 126 | # The name for this set of Sphinx documents. If None, it defaults to 127 | # " v documentation". 128 | #html_title = None 129 | 130 | # A shorter title for the navigation bar. Default is the same as html_title. 131 | #html_short_title = None 132 | 133 | # The name of an image file (relative to this directory) to place at the top 134 | # of the sidebar. 135 | #html_logo = None 136 | 137 | # The name of an image file (within the static path) to use as favicon of the 138 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 139 | # pixels large. 140 | #html_favicon = None 141 | 142 | # Add any paths that contain custom static files (such as style sheets) here, 143 | # relative to this directory. They are copied after the builtin static files, 144 | # so a file named "default.css" will overwrite the builtin "default.css". 145 | html_static_path = ['_static'] 146 | 147 | # Add any extra paths that contain custom files (such as robots.txt or 148 | # .htaccess) here, relative to this directory. These files are copied 149 | # directly to the root of the documentation. 150 | #html_extra_path = [] 151 | 152 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 153 | # using the given strftime format. 154 | #html_last_updated_fmt = '%b %d, %Y' 155 | 156 | # If true, SmartyPants will be used to convert quotes and dashes to 157 | # typographically correct entities. 158 | #html_use_smartypants = True 159 | 160 | # Custom sidebar templates, maps document names to template names. 161 | #html_sidebars = {} 162 | 163 | # Additional templates that should be rendered to pages, maps page names to 164 | # template names. 165 | #html_additional_pages = {} 166 | 167 | # If false, no module index is generated. 168 | #html_domain_indices = True 169 | 170 | # If false, no index is generated. 171 | #html_use_index = True 172 | 173 | # If true, the index is split into individual pages for each letter. 174 | #html_split_index = False 175 | 176 | # If true, links to the reST sources are added to the pages. 177 | #html_show_sourcelink = True 178 | 179 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 180 | #html_show_sphinx = True 181 | 182 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 183 | #html_show_copyright = True 184 | 185 | # If true, an OpenSearch description file will be output, and all pages will 186 | # contain a tag referring to it. The value of this option must be the 187 | # base URL from which the finished HTML is served. 188 | #html_use_opensearch = '' 189 | 190 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 191 | #html_file_suffix = None 192 | 193 | # Language to be used for generating the HTML full-text search index. 194 | # Sphinx supports the following languages: 195 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 196 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 197 | #html_search_language = 'en' 198 | 199 | # A dictionary with options for the search language support, empty by default. 200 | # Now only 'ja' uses this config value 201 | #html_search_options = {'type': 'default'} 202 | 203 | # The name of a javascript file (relative to the configuration directory) that 204 | # implements a search results scorer. If empty, the default will be used. 205 | #html_search_scorer = 'scorer.js' 206 | 207 | # Output file base name for HTML help builder. 208 | htmlhelp_basename = 'mqttoolsdoc' 209 | 210 | # -- Options for LaTeX output --------------------------------------------- 211 | 212 | latex_elements = { 213 | # The paper size ('letterpaper' or 'a4paper'). 214 | #'papersize': 'letterpaper', 215 | 216 | # The font size ('10pt', '11pt' or '12pt'). 217 | #'pointsize': '10pt', 218 | 219 | # Additional stuff for the LaTeX preamble. 220 | #'preamble': '', 221 | 222 | # Latex figure (float) alignment 223 | #'figure_align': 'htbp', 224 | } 225 | 226 | # Grouping the document tree into LaTeX files. List of tuples 227 | # (source start file, target name, title, 228 | # author, documentclass [howto, manual, or own class]). 229 | latex_documents = [ 230 | (master_doc, 'mqttools.tex', u'mqttools Documentation', 231 | u'Erik Moqvist', 'manual'), 232 | ] 233 | 234 | # The name of an image file (relative to this directory) to place at the top of 235 | # the title page. 236 | #latex_logo = None 237 | 238 | # For "manual" documents, if this is true, then toplevel headings are parts, 239 | # not chapters. 240 | #latex_use_parts = False 241 | 242 | # If true, show page references after internal links. 243 | #latex_show_pagerefs = False 244 | 245 | # If true, show URL addresses after external links. 246 | #latex_show_urls = False 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #latex_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #latex_domain_indices = True 253 | 254 | 255 | # -- Options for manual page output --------------------------------------- 256 | 257 | # One entry per manual page. List of tuples 258 | # (source start file, name, description, authors, manual section). 259 | man_pages = [ 260 | (master_doc, 'mqttools', u'Mqttools Documentation', 261 | [author], 1) 262 | ] 263 | 264 | # If true, show URL addresses after external links. 265 | #man_show_urls = False 266 | 267 | 268 | # -- Options for Texinfo output ------------------------------------------- 269 | 270 | # Grouping the document tree into Texinfo files. List of tuples 271 | # (source start file, target name, title, author, 272 | # dir menu entry, description, category) 273 | texinfo_documents = [ 274 | (master_doc, 'mqttools', u'Mqttools Documentation', 275 | author, 'mqttools', 'One line description of project.', 276 | 'Miscellaneous'), 277 | ] 278 | 279 | # Documents to append as an appendix to all manuals. 280 | #texinfo_appendices = [] 281 | 282 | # If false, no module index is generated. 283 | #texinfo_domain_indices = True 284 | 285 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 286 | #texinfo_show_urls = 'footnote' 287 | 288 | # If true, do not generate a @detailmenu in the "Top" node's menu. 289 | #texinfo_no_detailmenu = False 290 | 291 | autodoc_member_order = 'bysource' 292 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. mqttools documentation master file, created by 2 | sphinx-quickstart on Sat Apr 25 11:54:09 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | .. include:: ../README.rst 10 | 11 | Functions and classes 12 | ===================== 13 | 14 | .. autoclass:: mqttools.Client 15 | :members: 16 | 17 | .. autoclass:: mqttools.Message 18 | :members: 19 | 20 | .. autoclass:: mqttools.Broker 21 | :members: serve_forever 22 | 23 | .. autoclass:: mqttools.BrokerThread 24 | :members: stop 25 | 26 | .. method:: start() 27 | 28 | Start the broker in a thread. This function returns immediately. 29 | 30 | .. autoclass:: mqttools.ConnectError 31 | :members: 32 | 33 | .. autoclass:: mqttools.SessionResumeError 34 | :members: 35 | 36 | .. autoclass:: mqttools.SubscribeError 37 | :members: 38 | 39 | .. autoclass:: mqttools.UnsubscribeError 40 | :members: 41 | 42 | .. autoclass:: mqttools.TimeoutError 43 | :members: 44 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\mqttools.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\mqttools.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/monitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eerimoq/mqttools/a28c86e89af0852249a5d6f33f9e67036c3eb8fe/docs/monitor.png -------------------------------------------------------------------------------- /examples/broker_and_clients.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | +--------+ +--------+ +-------------+ 4 | | |--- ping -->| |--- ping -->| | 5 | | client | | broker | | echo client | 6 | | |<-- pong ---| |<-- pong ---| | 7 | +--------+ +--------+ +-------------+ 8 | 9 | """ 10 | 11 | import asyncio 12 | import time 13 | 14 | import mqttools 15 | 16 | BROKER_PORT = 10008 17 | 18 | 19 | async def start_client(): 20 | client = mqttools.Client('localhost', BROKER_PORT, connect_delays=[0.1]) 21 | await client.start() 22 | 23 | return client 24 | 25 | 26 | async def client_main(): 27 | """Publish the current time to /ping and wait for the echo client to 28 | publish it back on /pong, with a one second interval. 29 | 30 | """ 31 | 32 | client = await start_client() 33 | await client.subscribe('/pong') 34 | 35 | while True: 36 | print() 37 | message = str(int(time.time())).encode('ascii') 38 | print(f'client: Publishing {message} on /ping.') 39 | client.publish(mqttools.Message('/ping', message)) 40 | message = await client.messages.get() 41 | print(f'client: Got {message.message} on {message.topic}.') 42 | 43 | if message is None: 44 | print('Client connection lost.') 45 | break 46 | 47 | await asyncio.sleep(1) 48 | 49 | 50 | async def echo_client_main(): 51 | """Wait for the client to publish to /ping, and publish /pong in 52 | response. 53 | 54 | """ 55 | 56 | client = await start_client() 57 | await client.subscribe('/ping') 58 | 59 | while True: 60 | message = await client.messages.get() 61 | print(f'echo_client: Got {message.message} on {message.topic}.') 62 | 63 | if message is None: 64 | print('Echo client connection lost.') 65 | break 66 | 67 | print(f'echo_client: Publishing {message.message} on /pong.') 68 | client.publish(mqttools.Message('/pong', message.message)) 69 | 70 | 71 | async def broker_main(): 72 | """The broker, serving both clients, forever. 73 | 74 | """ 75 | 76 | broker = mqttools.Broker(('localhost', BROKER_PORT)) 77 | await broker.serve_forever() 78 | 79 | 80 | async def main(): 81 | await asyncio.gather( 82 | broker_main(), 83 | echo_client_main(), 84 | client_main() 85 | ) 86 | 87 | 88 | asyncio.run(main()) 89 | -------------------------------------------------------------------------------- /examples/increment.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | HOST = 'localhost' 6 | PORT = 1883 7 | 8 | 9 | async def main(): 10 | client = mqttools.Client(HOST, PORT) 11 | await client.start() 12 | print(f'Connected to {HOST}:{PORT}.') 13 | await client.subscribe('/mqttools/incrementer/value/request') 14 | 15 | print('Subscribed to topic /mqttools/incrementer/value/request.') 16 | 17 | while True: 18 | message = await client.messages.get() 19 | 20 | if message is None: 21 | print('Broker connection lost!') 22 | break 23 | 24 | count = int(message.message) 25 | print(f'Request count: {count}') 26 | count += 1 27 | print(f'Response count: {count}') 28 | client.publish(mqttools.Message('/mqttools/counter-client/value/response', 29 | str(count).encode('ascii'))) 30 | 31 | 32 | asyncio.run(main()) 33 | -------------------------------------------------------------------------------- /examples/on_message.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | class Client(mqttools.Client): 7 | 8 | async def on_message(self, message): 9 | await self._messages.put((message.topic, 10 | message.message, 11 | message.retain, 12 | message.response_topic)) 13 | 14 | 15 | async def subscriber(): 16 | client = Client('localhost', 1883, subscriptions=['/test/mqttools/foo']) 17 | 18 | await client.start() 19 | 20 | print('Waiting for messages.') 21 | 22 | while True: 23 | topic, message, retain, response_topic = await client.messages.get() 24 | 25 | if topic is None: 26 | print('Broker connection lost!') 27 | break 28 | 29 | print(f'Topic: {topic}') 30 | print(f'Message: {message}') 31 | print(f'Retain: {retain}') 32 | print(f'ResponseTopic: {response_topic}') 33 | 34 | 35 | asyncio.run(subscriber()) 36 | -------------------------------------------------------------------------------- /examples/publish.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def publisher(): 7 | async with mqttools.Client('localhost', 1883) as client: 8 | client.publish(mqttools.Message('/test/mqttools/foo', b'bar')) 9 | print("Successfully published b'bar' on /test/mqttools/foo.") 10 | 11 | 12 | asyncio.run(publisher()) 13 | -------------------------------------------------------------------------------- /examples/publish_to_self.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def publish_to_self(): 7 | client = mqttools.Client('localhost', 1883) 8 | 9 | await client.start() 10 | await client.subscribe('/test/mqttools/foo') 11 | 12 | client.publish(mqttools.Message('/test/mqttools/foo', b'publish_to_self message')) 13 | message = await client.messages.get() 14 | 15 | if message is None: 16 | print('Broker connection lost!') 17 | else: 18 | print(f'Topic: {message.topic}') 19 | print(f'Message: {message.message}') 20 | 21 | 22 | asyncio.run(publish_to_self()) 23 | -------------------------------------------------------------------------------- /examples/reconnect.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def handle_messages(client): 7 | while True: 8 | message = await client.messages.get() 9 | 10 | if message is None: 11 | print('Connection lost.') 12 | break 13 | 14 | print(f'Got {message.message} on {message.topic}.') 15 | 16 | 17 | async def reconnector(): 18 | client = mqttools.Client('localhost', 19 | 1883, 20 | subscriptions=['foobar'], 21 | connect_delays=[1, 2, 4, 8]) 22 | 23 | while True: 24 | await client.start() 25 | await handle_messages(client) 26 | await client.stop() 27 | 28 | asyncio.run(reconnector()) 29 | -------------------------------------------------------------------------------- /examples/resume_session.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from uuid import uuid1 3 | 4 | import mqttools 5 | 6 | 7 | async def resume_session(): 8 | client = mqttools.Client('localhost', 9 | 1883, 10 | client_id='mqttools-{}'.format(uuid1().node), 11 | session_expiry_interval=15) 12 | 13 | try: 14 | await client.start(resume_session=True) 15 | print('Session resumed.') 16 | except mqttools.SessionResumeError: 17 | print('No session to resume. Subscribing to topics.') 18 | 19 | # Subscribe to three topics in parallel. 20 | print('Subscribing to topics.') 21 | await asyncio.gather( 22 | client.subscribe('$SYS/#'), 23 | client.subscribe('/test/mqttools/foo') 24 | ) 25 | 26 | print('Waiting for messages.') 27 | 28 | while True: 29 | message = await client.messages.get() 30 | 31 | if message is None: 32 | print('Broker connection lost!') 33 | break 34 | 35 | print(f'Topic: {message.topic}') 36 | print(f'Message: {message.message}') 37 | 38 | asyncio.run(resume_session()) 39 | -------------------------------------------------------------------------------- /examples/subscribe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def subscriber(): 7 | client = mqttools.Client('localhost', 1883) 8 | 9 | await client.start() 10 | 11 | # Subscribe to two topics in parallel. 12 | await asyncio.gather( 13 | client.subscribe('$SYS/#'), 14 | client.subscribe('/test/mqttools/foo') 15 | ) 16 | 17 | print('Waiting for messages.') 18 | 19 | while True: 20 | message = await client.messages.get() 21 | 22 | if message is None: 23 | print('Broker connection lost!') 24 | break 25 | 26 | print(f'Topic: {message.topic}') 27 | print(f'Message: {message.message}') 28 | 29 | 30 | asyncio.run(subscriber()) 31 | -------------------------------------------------------------------------------- /examples/topic_alias.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def publisher(): 7 | client = mqttools.Client('localhost', 8 | 1883, 9 | topic_aliases=[ 10 | '/test/mqttools/foo' 11 | ]) 12 | 13 | await client.start() 14 | client.publish(mqttools.Message('/test/mqttools/foo', b'sets-alias-in-broker')) 15 | client.publish(mqttools.Message('/test/mqttools/foo', b'published-with-alias')) 16 | client.publish(mqttools.Message('/test/mqttools/fie', b'not-using-alias')) 17 | client.publish(mqttools.Message('/test/mqttools/fie', b'not-using-alias')) 18 | await client.stop() 19 | print("Successfully published b'bar' on /test/mqttools/foo.") 20 | 21 | 22 | asyncio.run(publisher()) 23 | -------------------------------------------------------------------------------- /examples/unsubscribe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def unsubscriber(): 7 | client = mqttools.Client('localhost', 8 | 1883, 9 | keep_alive_s=5) 10 | 11 | await client.start() 12 | 13 | print('Subscribing to /test/mqttools/foo.') 14 | await client.subscribe('/test/mqttools/foo') 15 | message = await client.messages.get() 16 | 17 | print(f'Topic: {message.topic}') 18 | print(f'Message: {message.message}') 19 | 20 | print('Unsubscribing from /test/mqttools/foo.') 21 | await client.unsubscribe('/test/mqttools/foo') 22 | 23 | # Should only return when the broker connection is lost. 24 | message = await client.messages.get() 25 | 26 | if message is not None: 27 | print('Got unexpected message:') 28 | print(f' Topic: {message.topic}') 29 | print(f' Message: {message.message}') 30 | 31 | 32 | asyncio.run(unsubscriber()) 33 | -------------------------------------------------------------------------------- /examples/will.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import mqttools 4 | 5 | 6 | async def will(): 7 | client = mqttools.Client('localhost', 8 | 1883, 9 | will_topic='/my/will/topic', 10 | will_message=b'my-will-message', 11 | will_qos=0) 12 | 13 | await client.start() 14 | await client.stop() 15 | print("Successfully connected with will.") 16 | 17 | 18 | asyncio.run(will()) 19 | -------------------------------------------------------------------------------- /mqttools/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | 5 | from .broker import Broker 6 | from .broker import BrokerThread 7 | from .client import Client 8 | from .client import ConnectError 9 | from .client import Message 10 | from .client import SessionResumeError 11 | from .client import SubscribeError 12 | from .client import UnsubscribeError 13 | from .common import MalformedPacketError 14 | from .common import SubackReasonCode 15 | from .common import TimeoutError 16 | from .common import UnsubackReasonCode 17 | from .version import __version__ 18 | 19 | 20 | def main(): 21 | parser = argparse.ArgumentParser(description='MQTT Tools.') 22 | 23 | parser.add_argument('-d', '--debug', action='store_true') 24 | parser.add_argument('-l', '--log-level', 25 | default='error', 26 | choices=[ 27 | 'debug', 'info', 'warning', 'error', 'critical' 28 | ], 29 | help='Set the logging level (default: %(default)s).') 30 | parser.add_argument('--version', 31 | action='version', 32 | version=__version__, 33 | help='Print version information and exit.') 34 | 35 | # Workaround to make the subparser required in Python 3. 36 | subparsers = parser.add_subparsers(title='subcommands', 37 | dest='subcommand') 38 | subparsers.required = True 39 | 40 | # Import when used for less dependencies. For example, curses is 41 | # not part of all Python builds. 42 | from .subparsers import broker 43 | from .subparsers import monitor 44 | from .subparsers import publish 45 | from .subparsers import subscribe 46 | 47 | subscribe.add_subparser(subparsers) 48 | publish.add_subparser(subparsers) 49 | monitor.add_subparser(subparsers) 50 | broker.add_subparser(subparsers) 51 | 52 | args = parser.parse_args() 53 | 54 | level = logging.getLevelName(args.log_level.upper()) 55 | logging.basicConfig(level=level, format='%(asctime)s %(message)s') 56 | 57 | if args.debug: 58 | args.func(args) 59 | else: 60 | try: 61 | args.func(args) 62 | except BaseException as e: 63 | sys.exit('error: ' + str(e)) 64 | -------------------------------------------------------------------------------- /mqttools/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /mqttools/broker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import re 4 | import threading 5 | from collections import defaultdict 6 | 7 | from .common import CF_FIXED_HEADER 8 | from .common import MAXIMUM_PACKET_SIZE 9 | from .common import ConnectReasonCode 10 | from .common import ControlPacketType 11 | from .common import DisconnectReasonCode 12 | from .common import MalformedPacketError 13 | from .common import PayloadReader 14 | from .common import PropertyIds 15 | from .common import SubackReasonCode 16 | from .common import UnsubackReasonCode 17 | from .common import format_packet 18 | from .common import format_packet_compact 19 | from .common import pack_connack 20 | from .common import pack_disconnect 21 | from .common import pack_pingresp 22 | from .common import pack_publish 23 | from .common import pack_suback 24 | from .common import pack_unsuback 25 | from .common import unpack_connect 26 | from .common import unpack_disconnect 27 | from .common import unpack_publish 28 | from .common import unpack_subscribe 29 | from .common import unpack_unsubscribe 30 | 31 | LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | # ToDo: Session expiry. Keep alive. 35 | 36 | 37 | class ConnectError(Exception): 38 | pass 39 | 40 | 41 | class DisconnectError(Exception): 42 | pass 43 | 44 | 45 | class ProtocolError(Exception): 46 | pass 47 | 48 | 49 | class NotRunningError(Exception): 50 | pass 51 | 52 | 53 | class Session(object): 54 | 55 | def __init__(self, client_id): 56 | self.client_id = client_id 57 | self.subscriptions = set() 58 | self.wildcard_subscriptions = set() 59 | self.expiry_interval = 0 60 | self.client = None 61 | self.maximum_packet_size = MAXIMUM_PACKET_SIZE 62 | self.will_topic = None 63 | self.will_message = None 64 | 65 | def clean(self): 66 | self.subscriptions = set() 67 | self.wildcard_subscriptions = set() 68 | self.expiry_interval = 0 69 | self.client = None 70 | self.maximum_packet_size = MAXIMUM_PACKET_SIZE 71 | self.will_topic = None 72 | self.will_message = None 73 | 74 | 75 | def is_wildcards_in_topic(topic): 76 | return '#' in topic or '+' in topic 77 | 78 | 79 | def compile_wildcards_topic(topic): 80 | pattern = topic.replace('+', '[^/]*') 81 | pattern = pattern.replace('/#', '.*') 82 | pattern = pattern.replace('#', '.*') 83 | pattern = '^' + pattern + '$' 84 | 85 | return re.compile(pattern) 86 | 87 | 88 | class Client(object): 89 | 90 | def __init__(self, broker, reader, writer): 91 | self._broker = broker 92 | self._reader = reader 93 | self._writer = writer 94 | self._session = None 95 | self._disconnect_reason = DisconnectReasonCode.UNSPECIFIED_ERROR 96 | 97 | async def serve_forever(self): 98 | addr = self._writer.get_extra_info('peername') 99 | 100 | self.log_info('Serving client %s:%d.', addr[0], addr[1]) 101 | 102 | try: 103 | packet_type, _, payload = await self.read_packet() 104 | 105 | if packet_type == ControlPacketType.CONNECT: 106 | self.on_connect(payload) 107 | else: 108 | raise ConnectError() 109 | 110 | await self.reader_loop() 111 | except ConnectError: 112 | pass 113 | except DisconnectError: 114 | self._disconnect_reason = DisconnectReasonCode.NORMAL_DISCONNECTION 115 | except asyncio.IncompleteReadError: 116 | self.log_debug('Client connection lost.') 117 | except Exception as e: 118 | self.log_debug('Reader task stopped by %r.', e) 119 | 120 | if isinstance(e, MalformedPacketError): 121 | self._disconnect_reason = DisconnectReasonCode.MALFORMED_PACKET 122 | elif isinstance(e, ProtocolError): 123 | self._disconnect_reason = DisconnectReasonCode.PROTOCOL_ERROR 124 | 125 | if self._session is not None: 126 | self.disconnect() 127 | 128 | if self._session is not None: 129 | self._session.client = None 130 | 131 | if self._session.will_topic is not None: 132 | if not self.is_normal_disconnection(): 133 | self._broker.publish(self._session.will_topic, 134 | self._session.will_message, 135 | {}) 136 | 137 | if self._session.will_retain: 138 | self._broker.add_retained_message(self._session.will_topic, 139 | self._session.will_message) 140 | 141 | if self._session.expiry_interval == 0: 142 | self._broker.remove_session(self._session.client_id) 143 | 144 | self.log_info('Closing client %r.', addr) 145 | 146 | async def reader_loop(self): 147 | while True: 148 | packet_type, flags, payload = await self.read_packet() 149 | 150 | if packet_type == ControlPacketType.PUBLISH: 151 | self.on_publish(payload, flags) 152 | elif packet_type == ControlPacketType.SUBSCRIBE: 153 | self.on_subscribe(payload) 154 | elif packet_type == ControlPacketType.UNSUBSCRIBE: 155 | self.on_unsubscribe(payload) 156 | elif packet_type == ControlPacketType.PINGREQ: 157 | self.on_pingreq() 158 | elif packet_type == ControlPacketType.DISCONNECT: 159 | self.on_disconnect(payload) 160 | else: 161 | raise ProtocolError() 162 | 163 | async def read_packet(self): 164 | buf = await self._reader.readexactly(1) 165 | packet_type, flags = CF_FIXED_HEADER.unpack(buf) 166 | size = 0 167 | multiplier = 1 168 | byte = 0x80 169 | 170 | while (byte & 0x80) == 0x80: 171 | buf += await self._reader.readexactly(1) 172 | byte = buf[-1] 173 | size += ((byte & 0x7f) * multiplier) 174 | multiplier <<= 7 175 | 176 | data = await self._reader.readexactly(size) 177 | 178 | if LOGGER.isEnabledFor(logging.DEBUG): 179 | for line in format_packet('Received', buf + data): 180 | self.log_debug(line) 181 | elif LOGGER.isEnabledFor(logging.INFO): 182 | self.log_info(format_packet_compact('Received', buf + data)) 183 | 184 | return packet_type, flags, PayloadReader(data) 185 | 186 | def on_connect(self, payload): 187 | (client_id, 188 | clean_start, 189 | will_topic, 190 | will_message, 191 | will_retain, 192 | keep_alive_s, 193 | properties, 194 | username, 195 | password) = unpack_connect(payload) 196 | self._session, session_present = self._broker.get_session( 197 | client_id, 198 | clean_start) 199 | 200 | if session_present: 201 | self.log_info( 202 | 'Session resumed with %d simple and %d wildcard ' 203 | 'subscriptions.', 204 | len(self._session.subscriptions), 205 | len(self._session.wildcard_subscriptions)) 206 | 207 | self._session.client = self 208 | reason = ConnectReasonCode.SUCCESS 209 | 210 | if PropertyIds.AUTHENTICATION_METHOD in properties: 211 | reason = ConnectReasonCode.BAD_AUTHENTICATION_METHOD 212 | 213 | if PropertyIds.MAXIMUM_PACKET_SIZE in properties: 214 | maximum_packet_size = properties[PropertyIds.MAXIMUM_PACKET_SIZE] 215 | self._session.maximum_packet_size = maximum_packet_size 216 | 217 | self._session.will_topic = will_topic 218 | self._session.will_message = will_message 219 | self._session.will_retain = will_retain 220 | 221 | if PropertyIds.SESSION_EXPIRY_INTERVAL in properties: 222 | session_expiry_interval = properties[PropertyIds.SESSION_EXPIRY_INTERVAL] 223 | self._session.expiry_interval = session_expiry_interval 224 | 225 | if (username is not None) or (password is not None): 226 | reason = ConnectReasonCode.BAD_USER_NAME_OR_PASSWORD 227 | 228 | self._write_packet(pack_connack( 229 | session_present, 230 | reason, 231 | { 232 | PropertyIds.MAXIMUM_QOS: 0, 233 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE: 0, 234 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE: 0 235 | })) 236 | 237 | if reason != ConnectReasonCode.SUCCESS: 238 | raise ConnectError() 239 | 240 | self.log_info('Client connected.') 241 | 242 | def on_publish(self, payload, flags): 243 | topic, message, properties = unpack_publish(payload, (flags >> 1) & 3) 244 | 245 | if is_wildcards_in_topic(topic): 246 | raise MalformedPacketError(f'Invalid topic {topic} in publish.') 247 | 248 | if flags & 1: 249 | if message: 250 | self._broker.add_retained_message(topic, message) 251 | else: 252 | self._broker.remove_retained_message(topic) 253 | 254 | self._broker.publish(topic, message, properties) 255 | 256 | def on_subscribe(self, payload): 257 | packet_identifier, _, subscriptions = unpack_subscribe(payload) 258 | reasons = bytearray() 259 | retained_messages = [] 260 | 261 | for topic, _ in subscriptions: 262 | if is_wildcards_in_topic(topic): 263 | if topic not in self._session.wildcard_subscriptions: 264 | self._session.wildcard_subscriptions.add(topic) 265 | self._broker.add_wildcard_subscriber(topic, self._session) 266 | 267 | retained_messages += list( 268 | self._broker.find_retained_messages_wildcards(topic)) 269 | else: 270 | if topic not in self._session.subscriptions: 271 | self._session.subscriptions.add(topic) 272 | self._broker.add_subscriber(topic, self._session) 273 | 274 | retained_message = self._broker.find_retained_message(topic) 275 | 276 | if retained_message: 277 | retained_messages.append(retained_message) 278 | 279 | reason = SubackReasonCode.GRANTED_QOS_0 280 | reasons.append(reason) 281 | 282 | self._write_packet(pack_suback(packet_identifier, reasons)) 283 | 284 | for topic, message in retained_messages: 285 | self.publish(topic, message, True, {}) 286 | 287 | def on_unsubscribe(self, payload): 288 | packet_identifier, topics = unpack_unsubscribe(payload) 289 | reasons = bytearray() 290 | 291 | for topic in topics: 292 | reason = UnsubackReasonCode.NO_SUBSCRIPTION_EXISTED 293 | 294 | if is_wildcards_in_topic(topic): 295 | if topic in self._session.wildcard_subscriptions: 296 | self._session.wildcard_subscriptions.remove(topic) 297 | self._broker.remove_wildcard_subscriber(topic, self._session) 298 | reason = UnsubackReasonCode.SUCCESS 299 | elif topic in self._session.subscriptions: 300 | self._session.subscriptions.remove(topic) 301 | self._broker.remove_subscriber(topic, self._session) 302 | reason = UnsubackReasonCode.SUCCESS 303 | 304 | reasons.append(reason) 305 | 306 | self._write_packet(pack_unsuback(packet_identifier, reasons)) 307 | 308 | def on_pingreq(self): 309 | self._write_packet(pack_pingresp()) 310 | 311 | def on_disconnect(self, payload): 312 | unpack_disconnect(payload) 313 | 314 | raise DisconnectError() 315 | 316 | def publish(self, topic, message, retain, properties): 317 | self._write_packet(pack_publish(topic, message, retain, properties)) 318 | 319 | def disconnect(self): 320 | self._write_packet(pack_disconnect(self._disconnect_reason)) 321 | 322 | def _send_prefix(self, message): 323 | if len(message) <= self._session.maximum_packet_size: 324 | return 'Sending' 325 | else: 326 | return 'Not sending' 327 | 328 | def _write_packet(self, message): 329 | if LOGGER.isEnabledFor(logging.DEBUG): 330 | for line in format_packet(self._send_prefix(message), message): 331 | self.log_debug(line) 332 | elif LOGGER.isEnabledFor(logging.INFO): 333 | self.log_info(format_packet_compact(self._send_prefix(message), 334 | message)) 335 | 336 | if len(message) <= self._session.maximum_packet_size: 337 | self._writer.write(message) 338 | 339 | def log_debug(self, fmt, *args): 340 | if LOGGER.isEnabledFor(logging.DEBUG): 341 | if self._session is None: 342 | LOGGER.debug(fmt, *args) 343 | else: 344 | LOGGER.debug(f'{self._session.client_id} {fmt}', *args) 345 | 346 | def log_info(self, fmt, *args): 347 | if LOGGER.isEnabledFor(logging.INFO): 348 | if self._session is None: 349 | LOGGER.info(fmt, *args) 350 | else: 351 | LOGGER.info(f'{self._session.client_id} {fmt}', *args) 352 | 353 | def is_normal_disconnection(self): 354 | return self._disconnect_reason == DisconnectReasonCode.NORMAL_DISCONNECTION 355 | 356 | 357 | class Server: 358 | 359 | def __init__(self, serve_client, address): 360 | self.serve_client = serve_client 361 | 362 | if len(address) == 3: 363 | self._ssl = address[2] 364 | else: 365 | self._ssl = None 366 | 367 | self._host = address[0] 368 | self._port = address[1] 369 | self.server = None 370 | self.ready = asyncio.Event() 371 | 372 | async def serve_forever(self): 373 | try: 374 | self.server = await asyncio.start_server(self.serve_client, 375 | self._host, 376 | self._port, 377 | ssl=self._ssl) 378 | except OSError as e: 379 | LOGGER.warning('%s', e) 380 | raise 381 | 382 | self.ready.set() 383 | server_address = self.server.sockets[0].getsockname() 384 | 385 | LOGGER.info('Listening for clients on %s.', server_address) 386 | 387 | async with self.server: 388 | await self.server.serve_forever() 389 | 390 | 391 | class Broker(object): 392 | """A limited MQTT version 5.0 broker. 393 | 394 | `addresses` is a list of ``(host, port)`` and ``(host, port, 395 | ssl)`` tuples. It may also be the host string or one of the 396 | tuples. The broker will listen for clients on all given 397 | addresses. ``ssl`` is an SSL context passed to 398 | `asyncio.start_server()` as `ssl`. 399 | 400 | Create a broker and serve clients: 401 | 402 | >>> broker = Broker('localhost') 403 | >>> await broker.serve_forever() 404 | 405 | """ 406 | 407 | def __init__(self, addresses): 408 | if isinstance(addresses, str): 409 | addresses = (addresses, 1883) 410 | 411 | if isinstance(addresses, tuple): 412 | addresses = [addresses] 413 | 414 | self._sessions = {} 415 | self._subscribers = defaultdict(list) 416 | self._wildcard_subscribers = [] 417 | self._servers = [] 418 | 419 | for address in addresses: 420 | self._servers.append(Server(self.serve_client, address)) 421 | 422 | self._client_tasks = set() 423 | self._retained_messages = {} 424 | 425 | async def getsockname(self, index=0): 426 | server = self._servers[index] 427 | await server.ready.wait() 428 | 429 | return server.server.sockets[0].getsockname() 430 | 431 | async def serve_forever(self): 432 | """Setup a listener socket and forever serve clients. This coroutine 433 | only ends if cancelled by the user. 434 | 435 | """ 436 | 437 | try: 438 | await asyncio.gather( 439 | *[server.serve_forever() for server in self._servers]) 440 | except asyncio.CancelledError: 441 | # Cancel all client tasks as the TCP server leaves them 442 | # running. 443 | for client_task in self._client_tasks: 444 | client_task.cancel() 445 | 446 | self._client_tasks = set() 447 | raise 448 | 449 | async def serve_client(self, reader, writer): 450 | current_task = asyncio.current_task() 451 | self._client_tasks.add(current_task) 452 | client = Client(self, reader, writer) 453 | 454 | try: 455 | await client.serve_forever() 456 | finally: 457 | try: 458 | self._client_tasks.remove(current_task) 459 | except KeyError: 460 | pass 461 | 462 | def add_subscriber(self, topic, session): 463 | topic_sessions = self._subscribers[topic] 464 | 465 | if session not in topic_sessions: 466 | topic_sessions.append(session) 467 | 468 | def remove_subscriber(self, topic, session): 469 | topic_sessions = self._subscribers[topic] 470 | 471 | if session in topic_sessions: 472 | del topic_sessions[topic_sessions.index(session)] 473 | 474 | def add_wildcard_subscriber(self, topic, session): 475 | re_topic = compile_wildcards_topic(topic) 476 | self._wildcard_subscribers.append((topic, session, re_topic)) 477 | 478 | def remove_wildcard_subscriber(self, topic, session): 479 | for index, subscriber in enumerate(self._wildcard_subscribers): 480 | if topic == subscriber[0] and session == subscriber[1]: 481 | del self._wildcard_subscribers[index] 482 | break 483 | 484 | def add_retained_message(self, topic, message): 485 | self._retained_messages[topic] = message 486 | 487 | def remove_retained_message(self, topic): 488 | try: 489 | del self._retained_messages[topic] 490 | except KeyError: 491 | pass 492 | 493 | def find_retained_messages_wildcards(self, topic): 494 | re_topic = compile_wildcards_topic(topic) 495 | 496 | for topic in self._retained_messages: 497 | mo = re_topic.match(topic) 498 | 499 | if mo: 500 | yield (topic, self._retained_messages[topic]) 501 | 502 | def find_retained_message(self, topic): 503 | if topic in self._retained_messages: 504 | return (topic, self._retained_messages[topic]) 505 | else: 506 | return None 507 | 508 | def iter_subscribers(self, topic): 509 | for session in self._subscribers[topic]: 510 | if session.client is not None: 511 | yield session 512 | 513 | for _, session, re_topic in self._wildcard_subscribers: 514 | if session.client is not None: 515 | mo = re_topic.match(topic) 516 | 517 | if mo: 518 | yield session 519 | 520 | def get_session(self, client_id, clean_start): 521 | session_present = False 522 | 523 | if client_id in self._sessions: 524 | session = self._sessions[client_id] 525 | 526 | if clean_start: 527 | for topic in session.subscriptions: 528 | self.remove_subscriber(topic, session) 529 | 530 | for topic in session.wildcard_subscriptions: 531 | self.remove_wildcard_subscriber(topic, session) 532 | 533 | session.clean() 534 | else: 535 | session_present = True 536 | else: 537 | session = Session(client_id) 538 | self._sessions[client_id] = session 539 | 540 | return session, session_present 541 | 542 | def remove_session(self, client_id): 543 | del self._sessions[client_id] 544 | 545 | def publish(self, topic, message, properties): 546 | """Publish given topic and message to all subscribers. 547 | 548 | """ 549 | 550 | for session in self.iter_subscribers(topic): 551 | session.client.publish(topic, message, False, properties) 552 | 553 | 554 | class BrokerThread(threading.Thread): 555 | """The same as :class:`Broker`, but running in a thread. 556 | 557 | Create a broker and serve clients for 60 seconds: 558 | 559 | >>> broker = BrokerThread('broker.hivemq.com') 560 | >>> broker.start() 561 | >>> time.sleep(60) 562 | >>> broker.stop() 563 | 564 | """ 565 | 566 | def __init__(self, addresses): 567 | super().__init__() 568 | self._addresses = addresses 569 | self.daemon = True 570 | self._loop = asyncio.new_event_loop() 571 | self._broker_task = self._loop.create_task(self._run()) 572 | self._running = False 573 | 574 | def run(self): 575 | asyncio.set_event_loop(self._loop) 576 | self._running = True 577 | 578 | try: 579 | self._loop.run_until_complete(self._broker_task) 580 | finally: 581 | self._loop.close() 582 | 583 | def stop(self): 584 | """Stop the broker. All clients will be disconnected and the thread 585 | will terminate. 586 | 587 | """ 588 | 589 | if not self._running: 590 | raise NotRunningError('The broker is already stopped.') 591 | 592 | self._running = False 593 | 594 | def cancel_broker_task(): 595 | self._broker_task.cancel() 596 | 597 | self._loop.call_soon_threadsafe(cancel_broker_task) 598 | self.join() 599 | 600 | async def _run(self): 601 | broker = Broker(self._addresses) 602 | 603 | try: 604 | await broker.serve_forever() 605 | except asyncio.CancelledError: 606 | pass 607 | -------------------------------------------------------------------------------- /mqttools/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import enum 3 | import logging 4 | import uuid 5 | 6 | from .common import CF_FIXED_HEADER 7 | from .common import ConnectReasonCode 8 | from .common import ControlPacketType 9 | from .common import DisconnectReasonCode 10 | from .common import Error 11 | from .common import MalformedPacketError 12 | from .common import PayloadReader 13 | from .common import PropertyIds 14 | from .common import SubackReasonCode 15 | from .common import TimeoutError 16 | from .common import UnsubackReasonCode 17 | from .common import format_packet 18 | from .common import format_packet_compact 19 | from .common import pack_connect 20 | from .common import pack_disconnect 21 | from .common import pack_pingreq 22 | from .common import pack_publish 23 | from .common import pack_subscribe 24 | from .common import pack_unsubscribe 25 | from .common import unpack_connack 26 | from .common import unpack_disconnect 27 | from .common import unpack_publish 28 | from .common import unpack_suback 29 | from .common import unpack_unsuback 30 | 31 | LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class Message: 35 | """A message. 36 | 37 | Give `retain` as ``True`` to make the message retained. 38 | 39 | Give `response_topic` as a topic string to publish a response 40 | topic. 41 | 42 | """ 43 | 44 | def __init__(self, topic, message, retain=False, response_topic=None): 45 | self.topic = topic 46 | self.message = message 47 | self.retain = retain 48 | self.response_topic = response_topic 49 | 50 | 51 | class SessionResumeError(Error): 52 | pass 53 | 54 | 55 | class ReasonError(Error): 56 | 57 | def __init__(self, reason): 58 | super().__init__() 59 | self.reason = reason 60 | 61 | def __str__(self): 62 | return f'{self.reason.name}({self.reason.value})' 63 | 64 | 65 | class ConnectError(ReasonError): 66 | 67 | def __str__(self): 68 | message = f'{self.reason.name}({self.reason.value})' 69 | 70 | if self.reason == ConnectReasonCode.V3_1_1_UNACCEPTABLE_PROTOCOL_VERSION: 71 | message += ': The broker does not support protocol version 5.' 72 | 73 | return message 74 | 75 | 76 | class SubscribeError(ReasonError): 77 | pass 78 | 79 | 80 | class UnsubscribeError(ReasonError): 81 | pass 82 | 83 | 84 | class Transaction(object): 85 | 86 | def __init__(self, client): 87 | self.packet_identifier = None 88 | self._event = asyncio.Event() 89 | self._client = client 90 | self._response = None 91 | 92 | def __enter__(self): 93 | self.packet_identifier = self._client.alloc_packet_identifier() 94 | self._client.transactions[self.packet_identifier] = self 95 | 96 | return self 97 | 98 | def __exit__(self, exc_type, exc_value, exc_traceback): 99 | if self.packet_identifier in self._client.transactions: 100 | del self._client.transactions[self.packet_identifier] 101 | 102 | async def wait_until_completed(self): 103 | await asyncio.wait_for(self._event.wait(), 104 | self._client.response_timeout) 105 | 106 | return self._response 107 | 108 | def set_completed(self, response): 109 | del self._client.transactions[self.packet_identifier] 110 | self._response = response 111 | self._event.set() 112 | 113 | 114 | class Client(object): 115 | """An MQTT version 5.0 client. 116 | 117 | `host` and `port` are the host and port of the broker. 118 | 119 | `client_id` is the client id string. If ``None``, a random client 120 | id is generated on the form ``mqttools-``. 121 | 122 | `will_topic`, `will_message` and `will_qos` are used to ask the 123 | broker to send a will when the session ends. 124 | 125 | `keep_alive_s` is the keep alive time in seconds. 126 | 127 | `response_timeout` is the maximum time to wait for a response from 128 | the broker. 129 | 130 | `topic_aliases` is a list of topics that should be published with 131 | aliases instead of the topic string. 132 | 133 | `topic_alias_maximum` is the maximum number of topic aliases the 134 | client is willing to assign on request from the broker. 135 | 136 | `session_expiry_interval` is the session expiry time in 137 | seconds. Give as 0 to remove the session when the connection 138 | ends. Give as 0xffffffff to never remove the session (given that 139 | the broker supports it). 140 | 141 | `subscriptions` is a list of topics and topic-retain-handling 142 | tuples to subscribe to after connected in :meth:`start()`. 143 | 144 | `connect_delays` is a list of delays in seconds between the 145 | connection attempts in :meth:`start()`. Each delay is used once, 146 | except the last delay, which is used until successfully 147 | connected. If ``[]``, only one connection attempt is performed. If 148 | ``None``, the default delays ``[1, 2, 4, 8]`` are used. 149 | 150 | `username` and `password` are the credentials. The password must 151 | be bytes. By default no credentials are used. 152 | 153 | `kwargs` are passed to ``asyncio.open_connection()``. 154 | 155 | Create a client with default configuration: 156 | 157 | >>> client = Client('broker.hivemq.com', 1883) 158 | 159 | Create a client with all optional arguments given: 160 | 161 | >>> client = Client('broker.hivemq.com', 162 | 1883, 163 | client_id='my-client', 164 | will_topic='/my/last/will', 165 | will_message=b'my-last-message', 166 | will_qos=0, 167 | keep_alive_s=600, 168 | response_timeout=30', 169 | topic_aliases=['/my/topic']', 170 | topic_alias_maximum=100, 171 | session_expiry_interval=1800, 172 | subscriptions=['a/b', ('test/#', 2)], 173 | connect_delays=[1, 2], 174 | username='user', 175 | password=b'pass', 176 | ssl=True) 177 | 178 | Use an async context manager for automatic start and stop: 179 | 180 | >>> async with Client('broker.hivemq.com', 1883) as client: 181 | ... client.publish(Message('foo', b'bar')) 182 | 183 | """ 184 | 185 | def __init__(self, 186 | host, 187 | port, 188 | client_id=None, 189 | will_topic='', 190 | will_message=b'', 191 | will_retain=False, 192 | will_qos=0, 193 | keep_alive_s=60, 194 | response_timeout=5, 195 | topic_aliases=None, 196 | topic_alias_maximum=10, 197 | session_expiry_interval=0, 198 | subscriptions=None, 199 | connect_delays=None, 200 | username=None, 201 | password=None, 202 | **kwargs): 203 | self._host = host 204 | self._port = port 205 | 206 | if client_id is None: 207 | client_id = 'mqttools-{}'.format(uuid.uuid1().hex[:14]) 208 | 209 | self._client_id = client_id 210 | self._will_topic = will_topic 211 | self._will_message = will_message 212 | self._will_retain = will_retain 213 | self._will_qos = will_qos 214 | self._keep_alive_s = keep_alive_s 215 | self._username = username 216 | self._password = password 217 | self._kwargs = kwargs 218 | self.response_timeout = response_timeout 219 | self._connect_properties = {} 220 | self._rx_topic_alias_maximum = topic_alias_maximum 221 | self._rx_topic_aliases = None 222 | 223 | if topic_alias_maximum > 0: 224 | self._connect_properties[PropertyIds.TOPIC_ALIAS_MAXIMUM] = ( 225 | topic_alias_maximum) 226 | 227 | if session_expiry_interval > 0: 228 | self._connect_properties[PropertyIds.SESSION_EXPIRY_INTERVAL] = ( 229 | session_expiry_interval) 230 | 231 | self._subscriptions = [] 232 | 233 | if subscriptions is not None: 234 | for subscription in subscriptions: 235 | if isinstance(subscription, str): 236 | self._subscriptions.append((subscription, 0)) 237 | else: 238 | self._subscriptions.append(subscription) 239 | 240 | if connect_delays is None: 241 | connect_delays = [1, 2, 4, 8] 242 | 243 | self._connect_delays = connect_delays 244 | 245 | if topic_aliases is None: 246 | topic_aliases = [] 247 | 248 | self._tx_topic_aliases = None 249 | self._tx_topic_aliases_init = { 250 | topic: alias 251 | for alias, topic in enumerate(topic_aliases, 1) 252 | } 253 | self._tx_topic_alias_maximum = None 254 | self._registered_broker_topic_aliases = None 255 | self._reader = None 256 | self._writer = None 257 | self._reader_task = None 258 | self._keep_alive_task = None 259 | self._connack_event = None 260 | self._pingresp_event = None 261 | self.transactions = None 262 | self._messages = None 263 | self._connack = None 264 | self._next_packet_identifier = None 265 | self._disconnect_reason = None 266 | 267 | if keep_alive_s == 0: 268 | self._ping_period_s = None 269 | else: 270 | self._ping_period_s = keep_alive_s 271 | 272 | @property 273 | def client_id(self): 274 | """The client identifier string. 275 | 276 | """ 277 | 278 | return self._client_id 279 | 280 | @property 281 | def messages(self): 282 | """An ``asyncio.Queue`` of received messages from the broker. Each 283 | message is a :class:`Message`. 284 | 285 | >>> await client.messages.get() 286 | Message('/my/topic', b'my-message') 287 | 288 | A ``None`` message is put in the queue when the broker 289 | connection is lost. 290 | 291 | >>> await client.messages.get() 292 | None 293 | 294 | """ 295 | 296 | return self._messages 297 | 298 | async def on_message(self, message): 299 | """Called for each received MQTT message and when the broker 300 | connection is lost. Puts the message on the messages queue by 301 | default. 302 | 303 | """ 304 | 305 | await self._messages.put(message) 306 | 307 | async def start(self, resume_session=False): 308 | """Open a TCP connection to the broker and perform the MQTT connect 309 | procedure. This method must be called before any 310 | :meth:`publish()` or :meth:`subscribe()` calls. Call 311 | :meth:`stop()` to close the connection. 312 | 313 | If `resume_session` is ``True``, the client tries to resume 314 | the last session in the broker. A :class:`SessionResumeError` 315 | exception is raised if the resume fails, and a new session has 316 | been created instead. 317 | 318 | The exceptions below are only raised if ``connect_delays`` is 319 | ``[]``. 320 | 321 | Raises ``ConnectionRefusedError`` if the TCP connection 322 | attempt is refused by the broker. 323 | 324 | Raises :class:`TimeoutError` if the broker does not 325 | acknowledge the connect request. 326 | 327 | Raises :class:`ConnectError` if the broker does not accept the 328 | connect request. 329 | 330 | Raises :class:`SubscribeError` if the broker does not accept 331 | the subscribe request(s). 332 | 333 | >>> await client.start() 334 | 335 | Trying to resume a session. 336 | 337 | >>> try: 338 | ... await client.start(resume_session=True) 339 | ... print('Session resumed.') 340 | ... except SessionResumeError: 341 | ... print('Session not resumed. Subscribe to topics.') 342 | 343 | """ 344 | 345 | attempt = 1 346 | delays = self._connect_delays 347 | 348 | while True: 349 | try: 350 | await self._start(resume_session) 351 | break 352 | except SessionResumeError: 353 | raise 354 | except (Exception, asyncio.CancelledError) as e: 355 | if isinstance(e, ConnectionRefusedError): 356 | LOGGER.info('TCP connect refused.') 357 | elif isinstance(e, TimeoutError): 358 | LOGGER.info( 359 | 'MQTT connect or subscribe acknowledge not received.') 360 | elif isinstance(e, ConnectError): 361 | LOGGER.info('MQTT connect failed with reason %s.', e) 362 | elif isinstance(e, SubscribeError): 363 | LOGGER.info('MQTT subscribe failed with reason %s.', e) 364 | 365 | if delays == []: 366 | raise 367 | 368 | # Delay a while before the next connect attempt. 369 | delay = delays[min(attempt, len(delays)) - 1] 370 | LOGGER.info( 371 | 'Waiting %s second(s) before next connection attempt(%d).', 372 | delay, 373 | attempt) 374 | await asyncio.sleep(delay) 375 | attempt += 1 376 | 377 | async def __aenter__(self): 378 | await self.start() 379 | 380 | return self 381 | 382 | async def __aexit__(self, exc_type, exc_value, exc_traceback): 383 | await self.stop() 384 | 385 | async def _start(self, resume_session=False): 386 | self._rx_topic_aliases = {} 387 | self._tx_topic_aliases = {} 388 | self._tx_topic_alias_maximum = 0 389 | self._registered_broker_topic_aliases = set() 390 | self._reader_task = None 391 | self._keep_alive_task = None 392 | self._connack_event = asyncio.Event() 393 | self._pingresp_event = asyncio.Event() 394 | self.transactions = {} 395 | self._messages = asyncio.Queue() 396 | self._connack = None 397 | self._next_packet_identifier = 1 398 | self._disconnect_reason = DisconnectReasonCode.NORMAL_DISCONNECTION 399 | 400 | LOGGER.info('Connecting to %s:%s.', self._host, self._port) 401 | 402 | try: 403 | self._reader, self._writer = await asyncio.open_connection( 404 | self._host, 405 | self._port, 406 | **self._kwargs) 407 | 408 | self._reader_task = asyncio.create_task(self._reader_main()) 409 | 410 | session_present = await self.connect(resume_session) 411 | 412 | if self._keep_alive_s != 0: 413 | self._keep_alive_task = asyncio.create_task(self._keep_alive_main()) 414 | 415 | if not resume_session or not session_present: 416 | for topic, retain_handling in self._subscriptions: 417 | await self.subscribe(topic, retain_handling) 418 | 419 | except (Exception, asyncio.CancelledError): 420 | await self.stop() 421 | raise 422 | 423 | if resume_session and not session_present: 424 | LOGGER.info('No session to resume.') 425 | 426 | raise SessionResumeError('No session to resume.') 427 | 428 | async def stop(self): 429 | """Try to cleanly disconnect from the broker and then close the TCP 430 | connection. Call :meth:`start()` after :meth:`stop()` to 431 | reconnect to the broker. 432 | 433 | >>> await client.stop() 434 | 435 | """ 436 | 437 | try: 438 | self.disconnect() 439 | except (Exception, asyncio.CancelledError): 440 | pass 441 | 442 | if self._reader_task is not None: 443 | self._reader_task.cancel() 444 | 445 | try: 446 | await self._reader_task 447 | except (Exception, asyncio.CancelledError): 448 | pass 449 | 450 | if self._keep_alive_task is not None: 451 | self._keep_alive_task.cancel() 452 | 453 | try: 454 | await self._keep_alive_task 455 | except (Exception, asyncio.CancelledError): 456 | pass 457 | 458 | if self._writer is not None: 459 | self._writer.close() 460 | 461 | async def connect(self, resume_session): 462 | self._connack_event.clear() 463 | self._write_packet(pack_connect(self._client_id, 464 | not resume_session, 465 | self._will_topic, 466 | self._will_message, 467 | self._will_retain, 468 | self._will_qos, 469 | self._username, 470 | self._password, 471 | self._keep_alive_s, 472 | self._connect_properties)) 473 | 474 | try: 475 | await asyncio.wait_for(self._connack_event.wait(), 476 | self.response_timeout) 477 | except asyncio.TimeoutError: 478 | raise TimeoutError('Timeout waiting for CONNACK from the broker.') 479 | 480 | session_present, reason, properties = self._connack 481 | 482 | if reason != ConnectReasonCode.SUCCESS: 483 | raise ConnectError(reason) 484 | 485 | # Topic alias maximum. 486 | if PropertyIds.TOPIC_ALIAS_MAXIMUM in properties: 487 | self._tx_topic_alias_maximum = ( 488 | properties[PropertyIds.TOPIC_ALIAS_MAXIMUM]) 489 | else: 490 | self._tx_topic_alias_maximum = 0 491 | 492 | if len(self._tx_topic_aliases_init) > self._tx_topic_alias_maximum: 493 | LOGGER.warning('The broker topic alias maximum is %d, which is lower ' 494 | 'than the topic aliases length %d.', 495 | self._tx_topic_alias_maximum, 496 | len(self._tx_topic_aliases_init)) 497 | 498 | self._tx_topic_aliases = { 499 | topic: alias 500 | for topic, alias in self._tx_topic_aliases_init.items() 501 | if alias < self._tx_topic_alias_maximum + 1 502 | } 503 | 504 | return session_present 505 | 506 | def disconnect(self): 507 | if self._disconnect_reason is None: 508 | return 509 | 510 | self._write_packet(pack_disconnect(self._disconnect_reason)) 511 | self._disconnect_reason = None 512 | 513 | async def subscribe(self, topic, retain_handling=0): 514 | """Subscribe to given topic with QoS 0. 515 | 516 | `retain_handling` controls the the retain handling. May be 0, 517 | 1 or 2. If 0, all retained messages matching given topic 518 | filter are received. If 1, same as 0, but only if the topic 519 | filter did not already exist. If 2, no retained messages are 520 | received. 521 | 522 | Raises :class:`TimeoutError` if the broker does not 523 | acknowledge the subscribe request. 524 | 525 | Raises :class:`SubscribeError` if the broker does not accept 526 | the subscribe request. 527 | 528 | >>> await client.subscribe('/my/topic') 529 | >>> await client.messages.get() 530 | Message('/my/topic', b'my-message') 531 | 532 | """ 533 | 534 | with Transaction(self) as transaction: 535 | self._write_packet(pack_subscribe(topic, 536 | retain_handling, 537 | transaction.packet_identifier)) 538 | 539 | try: 540 | reasons = await transaction.wait_until_completed() 541 | except asyncio.TimeoutError: 542 | raise TimeoutError('Timeout waiting for SUBACK from the broker.') 543 | 544 | reason = reasons[0] 545 | 546 | if reason != SubackReasonCode.GRANTED_QOS_0: 547 | raise SubscribeError(reason) 548 | 549 | async def unsubscribe(self, topic): 550 | """Unsubscribe from given topic. 551 | 552 | Raises :class:`TimeoutError` if the broker does not 553 | acknowledge the unsubscribe request. 554 | 555 | Raises :class:`UnsubscribeError` if the broker does not accept 556 | the unsubscribe request. 557 | 558 | >>> await client.unsubscribe('/my/topic') 559 | 560 | """ 561 | 562 | with Transaction(self) as transaction: 563 | self._write_packet(pack_unsubscribe(topic, 564 | transaction.packet_identifier)) 565 | 566 | try: 567 | reasons = await transaction.wait_until_completed() 568 | except asyncio.TimeoutError: 569 | raise TimeoutError('Timeout waiting for UNSUBACK from the broker.') 570 | 571 | reason = reasons[0] 572 | 573 | if reason != UnsubackReasonCode.SUCCESS: 574 | raise UnsubscribeError(reason) 575 | 576 | def publish(self, message): 577 | """Publish given message :class:`Message` with QoS 0. 578 | 579 | >>> client.publish(Message('/my/topic', b'my-message')) 580 | 581 | """ 582 | 583 | topic = message.topic 584 | 585 | if topic in self._tx_topic_aliases: 586 | alias = self._tx_topic_aliases[topic] 587 | 588 | if alias in self._registered_broker_topic_aliases: 589 | topic = '' 590 | else: 591 | alias = None 592 | 593 | properties = {} 594 | 595 | if alias is not None: 596 | properties[PropertyIds.TOPIC_ALIAS] = alias 597 | 598 | if message.response_topic is not None: 599 | properties[PropertyIds.RESPONSE_TOPIC] = message.response_topic 600 | 601 | self._write_packet(pack_publish(topic, 602 | message.message, 603 | message.retain, 604 | properties)) 605 | 606 | if (alias is not None) and (topic != ''): 607 | self._registered_broker_topic_aliases.add(alias) 608 | 609 | def on_connack(self, payload): 610 | self._connack = unpack_connack(payload) 611 | self._connack_event.set() 612 | 613 | async def on_publish(self, flags, payload): 614 | topic, message, properties = unpack_publish( 615 | payload, 616 | (flags >> 1) & 0x3) 617 | 618 | if PropertyIds.TOPIC_ALIAS in properties: 619 | alias = properties[PropertyIds.TOPIC_ALIAS] 620 | 621 | if topic == '': 622 | try: 623 | topic = self._rx_topic_aliases[alias] 624 | except KeyError: 625 | LOGGER.debug( 626 | 'Unknown topic alias %d received from the broker.', 627 | alias) 628 | return 629 | elif 0 < alias <= self._rx_topic_alias_maximum: 630 | self._rx_topic_aliases[alias] = topic 631 | else: 632 | LOGGER.debug('Invalid topic alias %d received from the broker.', 633 | alias) 634 | return 635 | 636 | await self.on_message(Message(topic, 637 | message, 638 | bool(flags & 1), 639 | properties.get(PropertyIds.RESPONSE_TOPIC))) 640 | 641 | def on_suback(self, payload): 642 | packet_identifier, properties, reasons = unpack_suback(payload) 643 | 644 | if packet_identifier in self.transactions: 645 | self.transactions[packet_identifier].set_completed(reasons) 646 | else: 647 | LOGGER.debug( 648 | 'Discarding unexpected SUBACK packet with identifier %d.', 649 | packet_identifier) 650 | 651 | def on_unsuback(self, payload): 652 | packet_identifier, properties, reasons = unpack_unsuback(payload) 653 | 654 | if packet_identifier in self.transactions: 655 | self.transactions[packet_identifier].set_completed(reasons) 656 | else: 657 | LOGGER.debug( 658 | 'Discarding unexpected UNSUBACK packet with identifier %d.', 659 | packet_identifier) 660 | 661 | def on_pingresp(self): 662 | self._pingresp_event.set() 663 | 664 | async def on_disconnect(self, payload): 665 | reason, properties = unpack_disconnect(payload) 666 | 667 | if reason != DisconnectReasonCode.NORMAL_DISCONNECTION: 668 | LOGGER.info("Abnormal disconnect reason %s.", reason) 669 | 670 | if PropertyIds.REASON_STRING in properties: 671 | reason_string = properties[PropertyIds.REASON_STRING] 672 | LOGGER.info("Disconnect reason string '%s'.", reason_string) 673 | 674 | await self._close() 675 | 676 | async def reader_loop(self): 677 | while True: 678 | packet_type, flags, payload = await self._read_packet() 679 | 680 | if packet_type == ControlPacketType.CONNACK: 681 | self.on_connack(payload) 682 | elif packet_type == ControlPacketType.PUBLISH: 683 | await self.on_publish(flags, payload) 684 | elif packet_type == ControlPacketType.SUBACK: 685 | self.on_suback(payload) 686 | elif packet_type == ControlPacketType.UNSUBACK: 687 | self.on_unsuback(payload) 688 | elif packet_type == ControlPacketType.PINGRESP: 689 | self.on_pingresp() 690 | elif packet_type == ControlPacketType.DISCONNECT: 691 | await self.on_disconnect(payload) 692 | else: 693 | raise MalformedPacketError( 694 | f'Unsupported or invalid packet type {packet_type}.') 695 | 696 | async def _reader_main(self): 697 | """Read packets from the broker. 698 | 699 | """ 700 | 701 | LOGGER.info('Reader task starting...') 702 | 703 | try: 704 | await self.reader_loop() 705 | except (Exception, asyncio.CancelledError) as e: 706 | LOGGER.info('Reader task stopped by %r.', e) 707 | 708 | if isinstance(e, MalformedPacketError): 709 | self._disconnect_reason = DisconnectReasonCode.MALFORMED_PACKET 710 | 711 | await self._close() 712 | 713 | async def keep_alive_loop(self): 714 | while True: 715 | await asyncio.sleep(self._ping_period_s) 716 | 717 | LOGGER.debug('Pinging the broker.') 718 | 719 | self._pingresp_event.clear() 720 | self._write_packet(pack_pingreq()) 721 | await asyncio.wait_for(self._pingresp_event.wait(), 722 | self.response_timeout) 723 | 724 | async def _keep_alive_main(self): 725 | """Ping the broker periodically to keep the connection alive. 726 | 727 | """ 728 | 729 | LOGGER.info('Keep alive task starting...') 730 | 731 | try: 732 | await self.keep_alive_loop() 733 | except (Exception, asyncio.CancelledError) as e: 734 | LOGGER.info('Keep alive task stopped by %r.', e) 735 | await self._close() 736 | 737 | def _write_packet(self, message): 738 | if LOGGER.isEnabledFor(logging.DEBUG): 739 | for line in format_packet('Sending', message): 740 | LOGGER.debug(line) 741 | elif LOGGER.isEnabledFor(logging.INFO): 742 | LOGGER.info(format_packet_compact('Sending', message)) 743 | 744 | self._writer.write(message) 745 | 746 | async def _read_packet(self): 747 | buf = await self._reader.readexactly(1) 748 | packet_type, flags = CF_FIXED_HEADER.unpack(buf) 749 | size = 0 750 | multiplier = 1 751 | byte = 0x80 752 | 753 | while (byte & 0x80) == 0x80: 754 | buf += await self._reader.readexactly(1) 755 | byte = buf[-1] 756 | size += ((byte & 0x7f) * multiplier) 757 | multiplier <<= 7 758 | 759 | data = await self._reader.readexactly(size) 760 | 761 | if LOGGER.isEnabledFor(logging.DEBUG): 762 | for line in format_packet('Received', buf + data): 763 | LOGGER.debug(line) 764 | elif LOGGER.isEnabledFor(logging.INFO): 765 | LOGGER.info(format_packet_compact('Received', buf + data)) 766 | 767 | return packet_type, flags, PayloadReader(data) 768 | 769 | def alloc_packet_identifier(self): 770 | packet_identifier = self._next_packet_identifier 771 | 772 | if packet_identifier in self.transactions: 773 | raise Error('No packet identifier available.') 774 | 775 | self._next_packet_identifier += 1 776 | 777 | if self._next_packet_identifier == 65536: 778 | self._next_packet_identifier = 1 779 | 780 | return packet_identifier 781 | 782 | async def _close(self): 783 | self.disconnect() 784 | self._writer.close() 785 | await self.on_message(None) 786 | -------------------------------------------------------------------------------- /mqttools/common.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import enum 3 | import logging 4 | import struct 5 | import traceback 6 | from io import BytesIO 7 | 8 | import bitstruct 9 | 10 | # Connection flags. 11 | CLEAN_START = 0x02 12 | WILL_FLAG = 0x04 13 | WILL_QOS_1 = 0x08 14 | WILL_QOS_2 = 0x10 15 | WILL_RETAIN = 0x20 16 | PASSWORD_FLAG = 0x40 17 | USER_NAME_FLAG = 0x80 18 | 19 | 20 | def hexlify(data): 21 | if data is None: 22 | return None 23 | else: 24 | return binascii.hexlify(data).decode('ascii').upper() 25 | 26 | 27 | # Control packet types. 28 | class ControlPacketType(enum.IntEnum): 29 | CONNECT = 1 30 | CONNACK = 2 31 | PUBLISH = 3 32 | PUBACK = 4 33 | PUBREC = 5 34 | PUBREL = 6 35 | PUBCOMP = 7 36 | SUBSCRIBE = 8 37 | SUBACK = 9 38 | UNSUBSCRIBE = 10 39 | UNSUBACK = 11 40 | PINGREQ = 12 41 | PINGRESP = 13 42 | DISCONNECT = 14 43 | AUTH = 15 44 | 45 | 46 | class ConnectReasonCode(enum.IntEnum): 47 | SUCCESS = 0 48 | V3_1_1_UNACCEPTABLE_PROTOCOL_VERSION = 1 49 | V3_1_1_IDENTIFIER_REJECTED = 2 50 | V3_1_1_SERVER_UNAVAILABLE = 3 51 | V3_1_1_BAD_USER_NAME_OR_PASSWORD = 4 52 | V3_1_1_NOT_AUTHORIZED = 5 53 | UNSPECIFIED_ERROR = 128 54 | MALFORMED_PACKET = 129 55 | PROTOCOL_ERROR = 130 56 | IMPLEMENTATION_SPECIFIC_ERROR = 131 57 | UNSUPPORTED_PROTOCOL_VERSION = 132 58 | CLIENT_IDENTIFIER_NOT_VALID = 133 59 | BAD_USER_NAME_OR_PASSWORD = 134 60 | NOT_AUTHORIZED = 135 61 | SERVER_UNAVAILABLE = 136 62 | SERVER_BUSY = 137 63 | BANNED = 138 64 | BAD_AUTHENTICATION_METHOD = 140 65 | TOPIC_NAME_INVALID = 144 66 | PACKET_TOO_LARGE = 149 67 | QUOTA_EXCEEDED = 151 68 | PAYLOAD_FORMAT_INVALID = 153 69 | RETAIN_NOT_SUPPORTED = 154 70 | QOS_NOT_SUPPORTED = 155 71 | USE_ANOTHER_SERVER = 156 72 | SERVER_MOVED = 157 73 | CONNECTION_RATE_EXCEEDED = 159 74 | 75 | 76 | class DisconnectReasonCode(enum.IntEnum): 77 | NORMAL_DISCONNECTION = 0 78 | DISCONNECT_WITH_WILL_MESSAGE = 4 79 | UNSPECIFIED_ERROR = 128 80 | MALFORMED_PACKET = 129 81 | PROTOCOL_ERROR = 130 82 | IMPLEMENTATION_SPECIFIC_ERROR = 131 83 | NOT_AUTHORIZED = 135 84 | SERVER_BUSY = 137 85 | SERVER_SHUTTING_DOWN = 139 86 | KEEP_ALIVE_TIMEOUT = 141 87 | SESSION_TAKEN_OVER = 142 88 | TOPIC_FILTER_INVALID = 143 89 | TOPIC_NAME_INVALID = 144 90 | RECEIVE_MAXIMUM_EXCEEDED = 147 91 | TOPIC_ALIAS_INVALID = 148 92 | PACKET_TOO_LARGE = 149 93 | MESSAGE_RATE_TOO_HIGH = 150 94 | QUOTA_EXCEEDED = 151 95 | ADMINISTRATIVE_ACTION = 152 96 | PAYLOAD_FORMAT_INVALID = 153 97 | RETAIN_NOT_SUPPORTED = 154 98 | QOS_NOT_SUPPORTED = 155 99 | USE_ANOTHER_SERVER = 156 100 | SERVER_MOVED = 157 101 | SHARED_SUBSCRIPTIONS_NOT_SUPPORTED = 158 102 | CONNECTION_RATE_EXCEEDED = 159 103 | MAXIMUM_CONNECT_TIME = 160 104 | SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED = 161 105 | WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED = 162 106 | 107 | 108 | class SubackReasonCode(enum.IntEnum): 109 | GRANTED_QOS_0 = 0 110 | GRANTED_QOS_1 = 1 111 | GRANTED_QOS_2 = 2 112 | UNSPECIFIED_ERROR = 128 113 | IMPLEMENTATION_SPECIFIC_ERROR = 131 114 | NOT_AUTHORIZED = 135 115 | TOPIC_FILTER_INVALID = 143 116 | PACKET_IDENTIFIER_IN_USE = 145 117 | QUOTA_EXCEEDED = 151 118 | SHARED_SUBSCRIPTIONS_NOT_SUPPORTED = 158 119 | SUBSCRIPTION_IDENTIFIERS_NOT_SUPPORTED = 161 120 | WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED = 162 121 | 122 | 123 | class UnsubackReasonCode(enum.IntEnum): 124 | SUCCESS = 0 125 | NO_SUBSCRIPTION_EXISTED = 17 126 | UNSPECIFIED_ERROR = 128 127 | IMPLEMENTATION_SPECIFIC_ERROR = 131 128 | NOT_AUTHORIZED = 135 129 | TOPIC_FILTER_INVALID = 143 130 | PACKET_IDENTIFIER_IN_USE = 145 131 | 132 | 133 | class PropertyIds(enum.IntEnum): 134 | PAYLOAD_FORMAT_INDICATOR = 1 135 | MESSAGE_EXPIRY_INTERVAL = 2 136 | CONTENT_TYPE = 3 137 | RESPONSE_TOPIC = 8 138 | CORRELATION_DATA = 9 139 | SUBSCRIPTION_IDENTIFIER = 11 140 | SESSION_EXPIRY_INTERVAL = 17 141 | ASSIGNED_CLIENT_IDENTIFIER = 18 142 | SERVER_KEEP_ALIVE = 19 143 | AUTHENTICATION_METHOD = 21 144 | AUTHENTICATION_DATA = 22 145 | REQUEST_PROBLEM_INFORMATION = 23 146 | WILL_DELAY_INTERVAL = 24 147 | REQUEST_RESPONSE_INFORMATION = 25 148 | RESPONSE_INFORMATION = 26 149 | SERVER_REFERENCE = 28 150 | REASON_STRING = 31 151 | RECEIVE_MAXIMUM = 33 152 | TOPIC_ALIAS_MAXIMUM = 34 153 | TOPIC_ALIAS = 35 154 | MAXIMUM_QOS = 36 155 | RETAIN_AVAILABLE = 37 156 | USER_PROPERTY = 38 157 | MAXIMUM_PACKET_SIZE = 39 158 | WILDCARD_SUBSCRIPTION_AVAILABLE = 40 159 | SUBSCRIPTION_IDENTIFIER_AVAILABLE = 41 160 | SHARED_SUBSCRIPTION_AVAILABLE = 42 161 | 162 | 163 | # MQTT 5.0 164 | PROTOCOL_VERSION = 5 165 | 166 | CF_FIXED_HEADER = bitstruct.compile('u4u4') 167 | 168 | MAXIMUM_PACKET_SIZE = 268435455 # (128 ^ 4 - 1) 169 | 170 | LOGGER = logging.getLogger(__name__) 171 | 172 | 173 | class Error(Exception): 174 | pass 175 | 176 | 177 | class MalformedPacketError(Error): 178 | pass 179 | 180 | 181 | class TimeoutError(Error): 182 | pass 183 | 184 | 185 | class PayloadReader(BytesIO): 186 | 187 | def __init__(self, data): 188 | super().__init__(data) 189 | self._length = len(data) 190 | 191 | def read(self, size): 192 | data = super().read(size) 193 | 194 | if len(data) != size: 195 | raise MalformedPacketError('Payload too short.') 196 | 197 | return data 198 | 199 | def read_all(self): 200 | return super().read() 201 | 202 | def is_data_available(self): 203 | return self.tell() < self._length 204 | 205 | 206 | def pack_string(data): 207 | data = data.encode('utf-8') 208 | packed = struct.pack('>H', len(data)) 209 | packed += data 210 | 211 | return packed 212 | 213 | 214 | def unpack_string(payload): 215 | size = unpack_u16(payload) 216 | 217 | try: 218 | return payload.read(size).decode('utf-8') 219 | except UnicodeDecodeError: 220 | raise MalformedPacketError('String not UTF-8 encoded.') 221 | 222 | 223 | def pack_u32(value): 224 | return struct.pack('>I', value) 225 | 226 | 227 | def unpack_u32(payload): 228 | return struct.unpack('>I', payload.read(4))[0] 229 | 230 | 231 | def pack_u16(value): 232 | return struct.pack('>H', value) 233 | 234 | 235 | def unpack_u16(payload): 236 | return struct.unpack('>H', payload.read(2))[0] 237 | 238 | 239 | def pack_u8(value): 240 | return struct.pack('B', value) 241 | 242 | 243 | def unpack_u8(payload): 244 | return payload.read(1)[0] 245 | 246 | 247 | def pack_property(property_id, value): 248 | return struct.pack('B', property_id) + { 249 | PropertyIds.PAYLOAD_FORMAT_INDICATOR: pack_u8, 250 | PropertyIds.MESSAGE_EXPIRY_INTERVAL: pack_u32, 251 | PropertyIds.CONTENT_TYPE: pack_string, 252 | PropertyIds.RESPONSE_TOPIC: pack_string, 253 | PropertyIds.CORRELATION_DATA: pack_binary, 254 | PropertyIds.SUBSCRIPTION_IDENTIFIER: pack_variable_integer, 255 | PropertyIds.SESSION_EXPIRY_INTERVAL: pack_u32, 256 | PropertyIds.ASSIGNED_CLIENT_IDENTIFIER: pack_string, 257 | PropertyIds.SERVER_KEEP_ALIVE: pack_u16, 258 | PropertyIds.AUTHENTICATION_METHOD: pack_string, 259 | PropertyIds.AUTHENTICATION_DATA: pack_binary, 260 | PropertyIds.REQUEST_PROBLEM_INFORMATION: pack_u8, 261 | PropertyIds.WILL_DELAY_INTERVAL: pack_u32, 262 | PropertyIds.REQUEST_RESPONSE_INFORMATION: pack_u8, 263 | PropertyIds.RESPONSE_INFORMATION: pack_string, 264 | PropertyIds.SERVER_REFERENCE: pack_string, 265 | PropertyIds.REASON_STRING: pack_string, 266 | PropertyIds.RECEIVE_MAXIMUM: pack_u16, 267 | PropertyIds.TOPIC_ALIAS_MAXIMUM: pack_u16, 268 | PropertyIds.TOPIC_ALIAS: pack_u16, 269 | PropertyIds.MAXIMUM_QOS: pack_u8, 270 | PropertyIds.RETAIN_AVAILABLE: pack_u8, 271 | PropertyIds.USER_PROPERTY: pack_string, 272 | PropertyIds.MAXIMUM_PACKET_SIZE: pack_u32, 273 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE: pack_u8, 274 | PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE: pack_u8, 275 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE: pack_u8 276 | }[property_id](value) 277 | 278 | 279 | def pack_properties(packet_name, properties): 280 | packed = b'' 281 | 282 | for property_id, value in properties.items(): 283 | packed += pack_property(property_id, value) 284 | 285 | return pack_variable_integer(len(packed)) + packed 286 | 287 | 288 | def unpack_property(property_id, payload): 289 | return { 290 | PropertyIds.PAYLOAD_FORMAT_INDICATOR: unpack_u8, 291 | PropertyIds.MESSAGE_EXPIRY_INTERVAL: unpack_u32, 292 | PropertyIds.CONTENT_TYPE: unpack_string, 293 | PropertyIds.RESPONSE_TOPIC: unpack_string, 294 | PropertyIds.CORRELATION_DATA: unpack_binary, 295 | PropertyIds.SUBSCRIPTION_IDENTIFIER: unpack_variable_integer, 296 | PropertyIds.SESSION_EXPIRY_INTERVAL: unpack_u32, 297 | PropertyIds.ASSIGNED_CLIENT_IDENTIFIER: unpack_string, 298 | PropertyIds.SERVER_KEEP_ALIVE: unpack_u16, 299 | PropertyIds.AUTHENTICATION_METHOD: unpack_string, 300 | PropertyIds.AUTHENTICATION_DATA: unpack_binary, 301 | PropertyIds.REQUEST_PROBLEM_INFORMATION: unpack_u8, 302 | PropertyIds.WILL_DELAY_INTERVAL: unpack_u32, 303 | PropertyIds.REQUEST_RESPONSE_INFORMATION: unpack_u8, 304 | PropertyIds.RESPONSE_INFORMATION: unpack_string, 305 | PropertyIds.SERVER_REFERENCE: unpack_string, 306 | PropertyIds.REASON_STRING: unpack_string, 307 | PropertyIds.RECEIVE_MAXIMUM: unpack_u16, 308 | PropertyIds.TOPIC_ALIAS_MAXIMUM: unpack_u16, 309 | PropertyIds.TOPIC_ALIAS: unpack_u16, 310 | PropertyIds.MAXIMUM_QOS: unpack_u8, 311 | PropertyIds.RETAIN_AVAILABLE: unpack_u8, 312 | PropertyIds.USER_PROPERTY: unpack_string, 313 | PropertyIds.MAXIMUM_PACKET_SIZE: unpack_u32, 314 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE: unpack_u8, 315 | PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE: unpack_u8, 316 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE: unpack_u8 317 | }[property_id](payload) 318 | 319 | 320 | def unpack_properties(packet_name, 321 | allowed_property_ids, 322 | payload): 323 | """Return a dictionary of unpacked properties. 324 | 325 | """ 326 | 327 | end_pos = unpack_variable_integer(payload) 328 | end_pos += payload.tell() 329 | properties = {} 330 | 331 | while payload.tell() < end_pos: 332 | property_id = payload.read(1)[0] 333 | 334 | if property_id not in allowed_property_ids: 335 | raise MalformedPacketError( 336 | f'Invalid property identifier {property_id}.') 337 | 338 | property_id = PropertyIds(property_id) 339 | properties[property_id] = unpack_property(property_id, payload) 340 | 341 | return properties 342 | 343 | 344 | def pack_binary(data): 345 | packed = struct.pack('>H', len(data)) 346 | packed += data 347 | 348 | return packed 349 | 350 | 351 | def unpack_binary(payload): 352 | size = unpack_u16(payload) 353 | 354 | return payload.read(size) 355 | 356 | 357 | def pack_variable_integer(value): 358 | if value == 0: 359 | packed = b'\x00' 360 | else: 361 | packed = b'' 362 | 363 | while value > 0: 364 | encoded_byte = (value & 0x7f) 365 | value >>= 7 366 | 367 | if value > 0: 368 | encoded_byte |= 0x80 369 | 370 | packed += struct.pack('B', encoded_byte) 371 | 372 | return packed 373 | 374 | 375 | def unpack_variable_integer(payload): 376 | value = 0 377 | multiplier = 1 378 | byte = 0x80 379 | 380 | while (byte & 0x80) == 0x80: 381 | byte = unpack_u8(payload) 382 | value += ((byte & 0x7f) * multiplier) 383 | multiplier <<= 7 384 | 385 | return value 386 | 387 | 388 | def pack_fixed_header(message_type, flags, size): 389 | packed = CF_FIXED_HEADER.pack(message_type, flags) 390 | packed += pack_variable_integer(size) 391 | 392 | return packed 393 | 394 | 395 | def unpack_fixed_header(payload): 396 | packet_type, flags = CF_FIXED_HEADER.unpack(payload) 397 | 398 | try: 399 | packet_type = ControlPacketType(packet_type) 400 | except ValueError: 401 | raise MalformedPacketError(f'Invalid packet type {packet_type}.') 402 | 403 | return packet_type, flags 404 | 405 | 406 | def pack_connect(client_id, 407 | clean_start, 408 | will_topic, 409 | will_message, 410 | will_retain, 411 | will_qos, 412 | username, 413 | password, 414 | keep_alive_s, 415 | properties): 416 | flags = 0 417 | 418 | if clean_start: 419 | flags |= CLEAN_START 420 | 421 | payload_length = len(client_id) + 2 422 | 423 | if will_topic: 424 | flags |= WILL_FLAG 425 | 426 | if will_retain: 427 | flags |= WILL_RETAIN 428 | 429 | if will_qos == 1: 430 | flags |= WILL_QOS_1 431 | elif will_qos == 2: 432 | flags |= WILL_QOS_2 433 | 434 | payload_length += 1 435 | packed_will_topic = pack_string(will_topic) 436 | payload_length += len(packed_will_topic) 437 | payload_length += len(will_message) + 2 438 | 439 | if password is not None: 440 | flags |= PASSWORD_FLAG 441 | payload_length += len(password) + 2 442 | 443 | if username is not None: 444 | flags |= USER_NAME_FLAG 445 | packed_username = pack_string(username) 446 | payload_length += len(packed_username) 447 | 448 | properties = pack_properties('CONNECT', properties) 449 | packed = pack_fixed_header(ControlPacketType.CONNECT, 450 | 0, 451 | 10 + payload_length + len(properties)) 452 | packed += pack_string('MQTT') 453 | packed += struct.pack('>BBH', 454 | PROTOCOL_VERSION, 455 | flags, 456 | keep_alive_s) 457 | packed += properties 458 | packed += pack_string(client_id) 459 | 460 | if flags & WILL_FLAG: 461 | packed += pack_variable_integer(0) 462 | packed += packed_will_topic 463 | packed += pack_binary(will_message) 464 | 465 | if flags & USER_NAME_FLAG: 466 | packed += packed_username 467 | 468 | if flags & PASSWORD_FLAG: 469 | packed += pack_binary(password) 470 | 471 | return packed 472 | 473 | 474 | def unpack_connect(payload): 475 | if unpack_string(payload) != 'MQTT': 476 | raise MalformedPacketError('Invalid MQTT magic string.') 477 | 478 | if unpack_u8(payload) != PROTOCOL_VERSION: 479 | raise MalformedPacketError('Wrong protocol version.') 480 | 481 | flags = unpack_u8(payload) 482 | clean_start = bool(flags & CLEAN_START) 483 | keep_alive_s = unpack_u16(payload) 484 | properties = unpack_properties( 485 | 'CONNECT', 486 | [ 487 | PropertyIds.SESSION_EXPIRY_INTERVAL, 488 | PropertyIds.AUTHENTICATION_METHOD, 489 | PropertyIds.AUTHENTICATION_DATA, 490 | PropertyIds.REQUEST_PROBLEM_INFORMATION, 491 | PropertyIds.REQUEST_RESPONSE_INFORMATION, 492 | PropertyIds.RECEIVE_MAXIMUM, 493 | PropertyIds.TOPIC_ALIAS_MAXIMUM, 494 | PropertyIds.USER_PROPERTY, 495 | PropertyIds.MAXIMUM_PACKET_SIZE 496 | ], 497 | payload) 498 | client_id = unpack_string(payload) 499 | 500 | if flags & WILL_FLAG: 501 | will_properties = unpack_properties( 502 | 'CONNECT-WILL', 503 | [ 504 | PropertyIds.WILL_DELAY_INTERVAL, 505 | PropertyIds.PAYLOAD_FORMAT_INDICATOR, 506 | PropertyIds.MESSAGE_EXPIRY_INTERVAL, 507 | PropertyIds.CONTENT_TYPE, 508 | PropertyIds.RESPONSE_TOPIC, 509 | PropertyIds.USER_PROPERTY 510 | ], 511 | payload) 512 | will_topic = unpack_string(payload) 513 | will_message = unpack_binary(payload) 514 | will_retain = bool(flags & WILL_RETAIN) 515 | else: 516 | will_topic = None 517 | will_message = None 518 | will_retain = None 519 | 520 | if flags & USER_NAME_FLAG: 521 | username = unpack_string(payload) 522 | else: 523 | username = None 524 | 525 | if flags & PASSWORD_FLAG: 526 | password = unpack_binary(payload) 527 | else: 528 | password = None 529 | 530 | return (client_id, 531 | clean_start, 532 | will_topic, 533 | will_message, 534 | will_retain, 535 | keep_alive_s, 536 | properties, 537 | username, 538 | password) 539 | 540 | 541 | def pack_connack(session_present, 542 | reason, 543 | properties): 544 | properties = pack_properties('CONNACK', properties) 545 | packed = pack_fixed_header(ControlPacketType.CONNACK, 546 | 0, 547 | 2 + len(properties)) 548 | packed += pack_u8(int(session_present)) 549 | packed += pack_u8(reason) 550 | packed += properties 551 | 552 | return packed 553 | 554 | 555 | def unpack_connack(payload): 556 | flags = unpack_u8(payload) 557 | session_present = bool(flags & 1) 558 | 559 | try: 560 | reason = ConnectReasonCode(unpack_u8(payload)) 561 | except ValueError: 562 | raise MalformedPacketError(f'Invalid CONNACK reason {reason}.') 563 | 564 | properties = unpack_properties( 565 | 'CONNACK', 566 | [ 567 | PropertyIds.SESSION_EXPIRY_INTERVAL, 568 | PropertyIds.ASSIGNED_CLIENT_IDENTIFIER, 569 | PropertyIds.SERVER_KEEP_ALIVE, 570 | PropertyIds.AUTHENTICATION_METHOD, 571 | PropertyIds.AUTHENTICATION_DATA, 572 | PropertyIds.RESPONSE_INFORMATION, 573 | PropertyIds.SERVER_REFERENCE, 574 | PropertyIds.REASON_STRING, 575 | PropertyIds.RECEIVE_MAXIMUM, 576 | PropertyIds.TOPIC_ALIAS_MAXIMUM, 577 | PropertyIds.MAXIMUM_QOS, 578 | PropertyIds.RETAIN_AVAILABLE, 579 | PropertyIds.USER_PROPERTY, 580 | PropertyIds.MAXIMUM_PACKET_SIZE, 581 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE, 582 | PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE, 583 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE 584 | ], 585 | payload) 586 | 587 | return session_present, reason, properties 588 | 589 | 590 | def pack_disconnect(reason): 591 | packed = pack_fixed_header(ControlPacketType.DISCONNECT, 0, 2) 592 | packed += struct.pack('B', reason) 593 | packed += pack_variable_integer(0) 594 | 595 | return packed 596 | 597 | 598 | def unpack_disconnect(payload): 599 | if payload.is_data_available(): 600 | reason = payload.read(1)[0] 601 | else: 602 | reason = 0 603 | 604 | try: 605 | reason = DisconnectReasonCode(reason) 606 | except ValueError: 607 | raise MalformedPacketError(f'Invalid DISCONNECT reason {reason}.') 608 | 609 | if payload.is_data_available(): 610 | properties = unpack_properties( 611 | 'DISCONNECT', 612 | [ 613 | PropertyIds.SESSION_EXPIRY_INTERVAL, 614 | PropertyIds.SERVER_REFERENCE, 615 | PropertyIds.REASON_STRING, 616 | PropertyIds.USER_PROPERTY 617 | ], 618 | payload) 619 | else: 620 | properties = {} 621 | 622 | return reason, properties 623 | 624 | 625 | def pack_subscribe(topic, retain_handling, packet_identifier): 626 | packed_topic = pack_string(topic) 627 | packed = pack_fixed_header(ControlPacketType.SUBSCRIBE, 628 | 2, 629 | len(packed_topic) + 4) 630 | packed += struct.pack('>HB', packet_identifier, 0) 631 | packed += packed_topic 632 | packed += struct.pack('B', retain_handling << 4) 633 | 634 | return packed 635 | 636 | 637 | def unpack_subscribe(payload): 638 | packet_identifier = unpack_u16(payload) 639 | properties = unpack_properties('SUBSCRIBE', 640 | [ 641 | PropertyIds.SUBSCRIPTION_IDENTIFIER, 642 | PropertyIds.USER_PROPERTY 643 | ], 644 | payload) 645 | subscriptions = [] 646 | 647 | while payload.is_data_available(): 648 | topic = unpack_string(payload) 649 | options = unpack_u8(payload) 650 | subscriptions.append((topic, options)) 651 | 652 | return packet_identifier, properties, subscriptions 653 | 654 | 655 | def pack_suback(packet_identifier, reasons): 656 | properties = pack_properties('SUBACK', {}) 657 | packed = pack_fixed_header(ControlPacketType.SUBACK, 658 | 0, 659 | 2 + len(properties) + len(reasons)) 660 | packed += pack_u16(packet_identifier) 661 | packed += properties 662 | packed += reasons 663 | 664 | return packed 665 | 666 | 667 | def unpack_suback(payload): 668 | packet_identifier = unpack_u16(payload) 669 | properties = unpack_properties('SUBACK', 670 | [ 671 | PropertyIds.REASON_STRING, 672 | PropertyIds.USER_PROPERTY 673 | ], 674 | payload) 675 | reasons = [] 676 | 677 | while payload.is_data_available(): 678 | try: 679 | reason = SubackReasonCode(unpack_u8(payload)) 680 | except ValueError: 681 | raise MalformedPacketError(f'Invalid SUBACK reason {reason}.') 682 | 683 | reasons.append(reason) 684 | 685 | return packet_identifier, properties, reasons 686 | 687 | 688 | def pack_unsubscribe(topic, packet_identifier): 689 | packed_topic = pack_string(topic) 690 | packed = pack_fixed_header(ControlPacketType.UNSUBSCRIBE, 691 | 2, 692 | len(packed_topic) + 3) 693 | packed += struct.pack('>HB', packet_identifier, 0) 694 | packed += packed_topic 695 | 696 | return packed 697 | 698 | 699 | def unpack_unsubscribe(payload): 700 | packet_identifier = unpack_u16(payload) 701 | unpack_u8(payload) 702 | topics = [] 703 | 704 | while payload.is_data_available(): 705 | topics.append(unpack_string(payload)) 706 | 707 | return packet_identifier, topics 708 | 709 | 710 | def pack_unsuback(packet_identifier, reasons): 711 | packed = pack_fixed_header(ControlPacketType.UNSUBACK, 0, 3 + len(reasons)) 712 | packed += pack_u16(packet_identifier) 713 | packed += pack_properties('UNSUBACK', {}) 714 | packed += reasons 715 | 716 | return packed 717 | 718 | 719 | def unpack_unsuback(payload): 720 | packet_identifier = unpack_u16(payload) 721 | properties = unpack_properties('UNSUBACK', 722 | [ 723 | PropertyIds.REASON_STRING, 724 | PropertyIds.USER_PROPERTY 725 | ], 726 | payload) 727 | reasons = [] 728 | 729 | while payload.is_data_available(): 730 | try: 731 | reason = UnsubackReasonCode(unpack_u8(payload)) 732 | except ValueError: 733 | raise MalformedPacketError(f'Invalid UNSUBACK reason {reason}.') 734 | 735 | reasons.append(reason) 736 | 737 | return packet_identifier, properties, reasons 738 | 739 | 740 | def pack_publish(topic, message, retain, properties): 741 | flags = 0 742 | 743 | if retain: 744 | flags |= 1 745 | 746 | properties = pack_properties('PUBLISH', properties) 747 | packed_topic = pack_string(topic) 748 | size = len(packed_topic) + len(message) + len(properties) 749 | packed = pack_fixed_header(ControlPacketType.PUBLISH, flags, size) 750 | packed += packed_topic 751 | packed += properties 752 | packed += message 753 | 754 | return packed 755 | 756 | 757 | def unpack_publish(payload, qos): 758 | topic = unpack_string(payload) 759 | 760 | if qos > 0: 761 | raise MalformedPacketError('Only QoS 0 is supported.') 762 | 763 | properties = unpack_properties( 764 | 'PUBLISH', 765 | [ 766 | PropertyIds.PAYLOAD_FORMAT_INDICATOR, 767 | PropertyIds.MESSAGE_EXPIRY_INTERVAL, 768 | PropertyIds.CONTENT_TYPE, 769 | PropertyIds.RESPONSE_TOPIC, 770 | PropertyIds.CORRELATION_DATA, 771 | PropertyIds.SUBSCRIPTION_IDENTIFIER, 772 | PropertyIds.TOPIC_ALIAS, 773 | PropertyIds.USER_PROPERTY 774 | ], 775 | payload) 776 | 777 | message = payload.read_all() 778 | 779 | return topic, message, properties 780 | 781 | 782 | def pack_pingreq(): 783 | return pack_fixed_header(ControlPacketType.PINGREQ, 0, 0) 784 | 785 | 786 | def pack_pingresp(): 787 | return pack_fixed_header(ControlPacketType.PINGRESP, 0, 0) 788 | 789 | 790 | def format_properties(properties): 791 | if not properties: 792 | return [] 793 | 794 | lines = [' Properties:'] 795 | 796 | for identifier, value in properties.items(): 797 | lines.append(f' {identifier.name}({identifier.value}): {value}') 798 | 799 | return lines 800 | 801 | 802 | def format_connect(payload): 803 | (client_id, 804 | clean_start, 805 | will_topic, 806 | will_message, 807 | will_retain, 808 | keep_alive_s, 809 | properties, 810 | username, 811 | password) = unpack_connect(payload) 812 | 813 | return [ 814 | f' ClientId: {client_id}', 815 | f' CleanStart: {clean_start}', 816 | f' WillTopic: {will_topic}', 817 | f' WillMessage: {hexlify(will_message)}', 818 | f' WillRetain: {will_retain}', 819 | f' KeepAlive: {keep_alive_s}', 820 | f' UserName: {username}', 821 | f' Password: {password}' 822 | ] + format_properties(properties) 823 | 824 | 825 | def format_connack(payload): 826 | session_present, reason, properties = unpack_connack(payload) 827 | 828 | return [ 829 | f' SessionPresent: {session_present}', 830 | f' Reason: {reason.name}({reason.value})' 831 | ] + format_properties(properties) 832 | 833 | 834 | def format_publish(flags, payload): 835 | dup = bool((flags >> 3) & 0x1) 836 | qos = ((flags >> 1) & 0x3) 837 | retain = bool(flags & 0x1) 838 | topic, message, properties = unpack_publish(payload, qos) 839 | 840 | return [ 841 | f' DupFlag: {dup}', 842 | f' QoSLevel: {qos}', 843 | f' Retain: {retain}', 844 | f' Topic: {topic}', 845 | f' Message: {hexlify(message)}', 846 | ] + format_properties(properties) 847 | 848 | 849 | def format_subscribe(payload): 850 | packet_identifier, properties, subscriptions = unpack_subscribe(payload) 851 | lines = [ 852 | f' PacketIdentifier: {packet_identifier}', 853 | ' Subscriptions:' 854 | ] 855 | 856 | for topic, flags in subscriptions: 857 | lines += [ 858 | f' Topic: {topic}', 859 | f' MaximumQoS: {flags & 0x3}', 860 | f' NoLocal: {bool((flags >> 2) & 0x1)}', 861 | f' RetainAsPublished: {bool((flags >> 3) & 0x1)}', 862 | f' RetainHandling: {(flags >> 4) & 0x3}' 863 | ] 864 | 865 | return lines 866 | 867 | 868 | def format_suback(payload): 869 | packet_identifier, properties, reasons = unpack_suback(payload) 870 | 871 | return [ 872 | f' PacketIdentifier: {packet_identifier}', 873 | ] + format_properties(properties) + [ 874 | ' Reasons:' 875 | ] + [ 876 | f' {reason.name}({reason.value})' for reason in reasons 877 | ] 878 | 879 | 880 | def format_unsubscribe(payload): 881 | packet_identifier, topics = unpack_unsubscribe(payload) 882 | 883 | return [ 884 | f' PacketIdentifier: {packet_identifier}', 885 | ' Topics:' 886 | ] + [f' {topic}' for topic in topics] 887 | 888 | 889 | def format_unsuback(payload): 890 | packet_identifier, properties, reasons = unpack_unsuback(payload) 891 | 892 | return [ 893 | f' PacketIdentifier: {packet_identifier}', 894 | ] + format_properties(properties) + [ 895 | ' Reasons:' 896 | ] + [ 897 | f' {reason.name}({reason.value})' for reason in reasons 898 | ] 899 | 900 | 901 | def format_disconnect(payload): 902 | reason, properties = unpack_disconnect(payload) 903 | 904 | return [ 905 | f' Reason: {reason.name}({reason.value})', 906 | ] + format_properties(properties) 907 | 908 | 909 | def format_packet(prefix, packet): 910 | lines = [] 911 | 912 | try: 913 | packet_type, flags = unpack_fixed_header(packet) 914 | payload = PayloadReader(packet[1:]) 915 | size = unpack_variable_integer(payload) 916 | packet_kind = packet_type.name 917 | lines.append( 918 | f'{prefix} {packet_kind}({packet_type.value}) packet of {len(packet)} ' 919 | f'byte(s)') 920 | 921 | if packet_kind == 'CONNECT': 922 | lines += format_connect(payload) 923 | elif packet_kind == 'CONNACK': 924 | lines += format_connack(payload) 925 | elif packet_kind == 'PUBLISH': 926 | lines += format_publish(flags, payload) 927 | elif packet_kind == 'SUBSCRIBE': 928 | lines += format_subscribe(payload) 929 | elif packet_kind == 'SUBACK': 930 | lines += format_suback(payload) 931 | elif packet_kind == 'UNSUBSCRIBE': 932 | lines += format_unsubscribe(payload) 933 | elif packet_kind == 'UNSUBACK': 934 | lines += format_unsuback(payload) 935 | elif packet_kind == 'DISCONNECT': 936 | lines += format_disconnect(payload) 937 | except Exception as e: 938 | lines.append(f' *** Malformed packet ({e}) ***') 939 | lines.append('') 940 | lines += [f' {line}' for line in traceback.format_exc().splitlines()] 941 | lines.append('') 942 | 943 | return lines 944 | 945 | 946 | def format_connect_compact(payload): 947 | (client_id, 948 | _, 949 | will_topic, 950 | will_message, 951 | _, 952 | keep_alive_s, 953 | _, 954 | _name, 955 | _) = unpack_connect(payload) 956 | 957 | parts = [f'ClientId={client_id}'] 958 | 959 | if will_topic is not None: 960 | parts.append(f'WillTopic={will_topic}') 961 | 962 | if will_message is not None: 963 | parts.append(f'WillMessage={hexlify(will_message)}') 964 | 965 | parts.append(f'KeepAlive={keep_alive_s}') 966 | 967 | return parts 968 | 969 | 970 | def format_connack_compact(payload): 971 | _, reason, _ = unpack_connack(payload) 972 | 973 | return [f'Reason={reason.name}({reason.value})'] 974 | 975 | 976 | def format_publish_compact(flags, payload): 977 | qos = ((flags >> 1) & 0x3) 978 | topic, message, _ = unpack_publish(payload, qos) 979 | 980 | return [ 981 | f'Topic={topic}', 982 | f'Message={hexlify(message)}', 983 | ] 984 | 985 | 986 | def format_subscribe_compact(payload): 987 | _, _, subscriptions = unpack_subscribe(payload) 988 | parts = [] 989 | 990 | for topic, _ in subscriptions: 991 | parts.append(f'Topic={topic}') 992 | 993 | return parts 994 | 995 | 996 | def format_suback_compact(payload): 997 | _, _, reasons = unpack_suback(payload) 998 | parts = [] 999 | 1000 | for reason in reasons: 1001 | parts.append(f'Reason={reason.name}({reason.value})') 1002 | 1003 | return parts 1004 | 1005 | 1006 | def format_unsubscribe_compact(payload): 1007 | _, topics = unpack_unsubscribe(payload) 1008 | parts = [] 1009 | 1010 | for topic in topics: 1011 | parts.append(f'Topic={topic}') 1012 | 1013 | return parts 1014 | 1015 | 1016 | def format_unsuback_compact(payload): 1017 | _, _, reasons = unpack_unsuback(payload) 1018 | parts = [] 1019 | 1020 | for reason in reasons: 1021 | parts.append(f'Reason={reason.name}({reason.value})') 1022 | 1023 | return parts 1024 | 1025 | 1026 | def format_disconnect_compact(payload): 1027 | reason, _ = unpack_disconnect(payload) 1028 | 1029 | return [f'Reason={reason.name}({reason.value})'] 1030 | 1031 | 1032 | def format_packet_compact(prefix, packet): 1033 | try: 1034 | packet_type, flags = unpack_fixed_header(packet) 1035 | payload = PayloadReader(packet[1:]) 1036 | size = unpack_variable_integer(payload) 1037 | packet_kind = packet_type.name 1038 | except Exception as e: 1039 | return f'{prefix} *** Malformed packet ({e}) ***' 1040 | 1041 | try: 1042 | if packet_kind == 'CONNECT': 1043 | extra = format_connect_compact(payload) 1044 | elif packet_kind == 'CONNACK': 1045 | extra = format_connack_compact(payload) 1046 | elif packet_kind == 'PUBLISH': 1047 | extra = format_publish_compact(flags, payload) 1048 | elif packet_kind == 'SUBSCRIBE': 1049 | extra = format_subscribe_compact(payload) 1050 | elif packet_kind == 'SUBACK': 1051 | extra = format_suback_compact(payload) 1052 | elif packet_kind == 'UNSUBSCRIBE': 1053 | extra = format_unsubscribe_compact(payload) 1054 | elif packet_kind == 'UNSUBACK': 1055 | extra = format_unsuback_compact(payload) 1056 | elif packet_kind == 'DISCONNECT': 1057 | extra = format_disconnect_compact(payload) 1058 | else: 1059 | extra = [] 1060 | 1061 | extra = ', '.join(extra) 1062 | 1063 | if extra: 1064 | extra = ': ' + extra 1065 | except Exception as e: 1066 | extra = f': *** Malformed packet ({e}) ***' 1067 | 1068 | return f'{prefix} {packet_kind}({packet_type.value}){extra}' 1069 | -------------------------------------------------------------------------------- /mqttools/subparsers/__init__.py: -------------------------------------------------------------------------------- 1 | from ..common import hexlify 2 | 3 | 4 | def format_message(kind, message): 5 | if kind == 'auto': 6 | try: 7 | message = message.decode() 8 | except UnicodeDecodeError: 9 | message = hexlify(message) 10 | elif kind == 'binary': 11 | message = hexlify(message) 12 | elif kind == 'text': 13 | message = message.decode(errors='replace') 14 | else: 15 | message = '' 16 | 17 | return message.replace('\x00', '\\x00') 18 | -------------------------------------------------------------------------------- /mqttools/subparsers/broker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ssl 3 | 4 | from argparse_addons import Integer 5 | 6 | from ..broker import Broker 7 | 8 | 9 | def _do_broker(args): 10 | addresses = [(args.host, args.port)] 11 | 12 | if all([args.cafile, args.certfile, args.keyfile]): 13 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, 14 | cafile=args.cafile) 15 | context.check_hostname = not args.no_check_hostname 16 | context.load_cert_chain(certfile=args.certfile, keyfile=args.keyfile) 17 | addresses.append((args.host, args.secure_port, context)) 18 | 19 | broker = Broker(addresses) 20 | asyncio.run(broker.serve_forever()) 21 | 22 | 23 | def add_subparser(subparsers): 24 | subparser = subparsers.add_parser('broker', 25 | description='A simple broker.') 26 | subparser.add_argument('--host', 27 | default='localhost', 28 | help="Broker host (default: %(default)s).") 29 | subparser.add_argument('--port', 30 | type=Integer(0), 31 | default=1883, 32 | help='Broker port (default: %(default)s).') 33 | subparser.add_argument('--secure-port', 34 | type=Integer(0), 35 | default=8883, 36 | help='Secure broker port (default: %(default)s).') 37 | subparser.add_argument( 38 | '--cafile', 39 | default='', 40 | help='CA file.') 41 | subparser.add_argument( 42 | '--certfile', 43 | default='', 44 | help='MQTT broker certificate file.') 45 | subparser.add_argument( 46 | '--keyfile', 47 | default='', 48 | help='MQTT broker key file.') 49 | subparser.add_argument( 50 | '--no-check-hostname', 51 | action='store_true', 52 | help='Do not check certificate hostname.') 53 | subparser.set_defaults(func=_do_broker) 54 | -------------------------------------------------------------------------------- /mqttools/subparsers/monitor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import bisect 3 | import curses 4 | import ssl 5 | import sys 6 | import threading 7 | import time 8 | from queue import Empty as QueueEmpty 9 | from queue import Queue 10 | 11 | from argparse_addons import Integer 12 | 13 | from ..client import Client 14 | from . import format_message 15 | 16 | 17 | class QuitError(Exception): 18 | pass 19 | 20 | 21 | class ClientThread(threading.Thread): 22 | 23 | def __init__(self, queue, args): 24 | super().__init__() 25 | self._queue = queue 26 | self._host = args.host 27 | self._port = args.port 28 | self._cafile = args.cafile 29 | self._check_hostname = not args.no_check_hostname 30 | self._client_id = args.client_id 31 | self._keep_alive_s = args.keep_alive 32 | self._session_expiry_interval = args.session_expiry_interval 33 | self._subscriptions = [] 34 | 35 | retain_handling = args.retain_handling[:len(args.subscribe)] 36 | 37 | if not retain_handling: 38 | retain_handling = [0] 39 | 40 | remaining = len(args.subscribe) - len(retain_handling) 41 | retain_handling += remaining * [retain_handling[-1]] 42 | 43 | for topic, retain_handling in zip(args.subscribe, retain_handling): 44 | self._subscriptions.append((topic, retain_handling)) 45 | 46 | async def main(self): 47 | if self._cafile: 48 | print(f"CA File: '{self._cafile}'") 49 | print(f"Check hostname: {self._check_hostname}") 50 | 51 | context = ssl.create_default_context(cafile=self._cafile) 52 | context.check_hostname = self._check_hostname 53 | else: 54 | context = None 55 | 56 | client = Client(self._host, 57 | self._port, 58 | self._client_id, 59 | keep_alive_s=self._keep_alive_s, 60 | session_expiry_interval=self._session_expiry_interval, 61 | subscriptions=self._subscriptions, 62 | topic_alias_maximum=10, 63 | ssl=context) 64 | 65 | while True: 66 | await client.start() 67 | 68 | while True: 69 | message = await client.messages.get() 70 | 71 | if message is None: 72 | break 73 | 74 | self._queue.put((time.strftime('%H:%M:%S'), 75 | message.topic, 76 | message.message)) 77 | 78 | await client.stop() 79 | 80 | def run(self): 81 | asyncio.run(self.main()) 82 | 83 | 84 | class Monitor(object): 85 | 86 | def __init__(self, stdscr, args): 87 | self._stdscr = stdscr 88 | self._sorted_topics = [] 89 | self._messages = {} 90 | self._formatted_messages = {} 91 | self._playing = True 92 | self._format = 'auto' 93 | self._modified = True 94 | self._queue = Queue() 95 | self._nrows, self._ncols = stdscr.getmaxyx() 96 | self._client_thread = ClientThread(self._queue, args) 97 | self._client_thread.daemon = True 98 | self._client_thread.start() 99 | 100 | stdscr.nodelay(True) 101 | curses.use_default_colors() 102 | curses.curs_set(False) 103 | curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_GREEN) 104 | curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) 105 | 106 | def run(self): 107 | while True: 108 | try: 109 | self.tick() 110 | except QuitError: 111 | break 112 | 113 | time.sleep(0.05) 114 | 115 | def tick(self): 116 | modified = self.update() 117 | 118 | if modified: 119 | self.redraw() 120 | 121 | self.process_user_input() 122 | 123 | def redraw(self): 124 | # Clear the screen. 125 | self._stdscr.clear() 126 | 127 | # Draw everything. 128 | self.draw_title(0) 129 | 130 | row = 1 131 | 132 | for topic in self._sorted_topics: 133 | for line in self._formatted_messages[topic]: 134 | self.addstr(row, 0, line) 135 | row += 1 136 | 137 | if row > self._nrows - 2: 138 | break 139 | 140 | self.draw_menu(self._nrows - 1) 141 | 142 | # Refresh the screen. 143 | self._stdscr.refresh() 144 | 145 | def draw_title(self, row): 146 | self.addstr_color(row, 147 | 0, 148 | self.stretch('TIMESTAMP TOPIC & MESSAGE'), 149 | curses.color_pair(1)) 150 | 151 | def draw_menu(self, row): 152 | self.addstr_color(row, 153 | 0, 154 | self.stretch(f'q: Quit, p: Play/Pause, f: Format ({self._format})'), 155 | curses.color_pair(2)) 156 | 157 | def addstr(self, row, col, text): 158 | try: 159 | self._stdscr.addstr(row, col, text) 160 | except curses.error: 161 | pass 162 | 163 | def addstr_color(self, row, col, text, color): 164 | try: 165 | self._stdscr.addstr(row, col, text, color) 166 | except curses.error: 167 | pass 168 | 169 | def stretch(self, text): 170 | return text + ' ' * (self._ncols - len(text)) 171 | 172 | def process_user_input(self): 173 | try: 174 | key = self._stdscr.getkey() 175 | except curses.error: 176 | return 177 | 178 | self.process_user_input_menu(key) 179 | 180 | def process_user_input_menu(self, key): 181 | if key == 'q': 182 | raise QuitError() 183 | elif key == 'p': 184 | self._playing = not self._playing 185 | elif key == 'f': 186 | self._format = { 187 | 'auto': 'binary', 188 | 'binary': 'text', 189 | 'text': 'auto' 190 | }[self._format] 191 | 192 | for topic, (timestamp, message) in self._messages.items(): 193 | self.format_message(timestamp, topic, message) 194 | 195 | self._modified = True 196 | 197 | def format_message(self, timestamp, topic, message): 198 | lines = [] 199 | row_length = max(1, self._ncols - 12) 200 | message = format_message(self._format, message) 201 | message = message.replace('\x00', '\\x00') 202 | 203 | for i in range(0, len(message), row_length): 204 | lines.append(message[i:i + row_length]) 205 | 206 | formatted = [' {} {}'.format(timestamp, topic)] 207 | formatted += [11 * ' ' + line for line in lines] 208 | self._formatted_messages[topic] = formatted 209 | 210 | def try_update_message(self): 211 | timestamp, topic, message = self._queue.get_nowait() 212 | 213 | if topic is None: 214 | sys.exit('Broker connection lost!') 215 | 216 | self.format_message(timestamp, topic, message) 217 | self._messages[topic] = (timestamp, message) 218 | 219 | if topic not in self._sorted_topics: 220 | self.insort(topic) 221 | 222 | def update_messages(self): 223 | modified = False 224 | 225 | try: 226 | while True: 227 | self.try_update_message() 228 | modified = True 229 | except QueueEmpty: 230 | pass 231 | 232 | return modified 233 | 234 | def update(self): 235 | if self._playing: 236 | modified = self.update_messages() 237 | else: 238 | modified = False 239 | 240 | if self._modified: 241 | self._modified = False 242 | modified = True 243 | 244 | if curses.is_term_resized(self._nrows, self._ncols): 245 | self._nrows, self._ncols = self._stdscr.getmaxyx() 246 | modified = True 247 | 248 | return modified 249 | 250 | def insort(self, topic): 251 | bisect.insort(self._sorted_topics, topic) 252 | 253 | 254 | def _do_monitor(args): 255 | def monitor(stdscr): 256 | Monitor(stdscr, args).run() 257 | 258 | try: 259 | curses.wrapper(monitor) 260 | except KeyboardInterrupt: 261 | pass 262 | 263 | 264 | def add_subparser(subparsers): 265 | subparser = subparsers.add_parser('monitor', 266 | description='Monitor given topics.') 267 | subparser.add_argument('--host', 268 | default='localhost', 269 | help='Broker host (default: %(default)s).') 270 | subparser.add_argument('--port', 271 | type=Integer(0), 272 | default=1883, 273 | help='Broker port (default: %(default)s).') 274 | subparser.add_argument('--client-id', 275 | help='Client id (default: mqttools-).') 276 | subparser.add_argument('--keep-alive', 277 | type=Integer(0), 278 | default=0, 279 | help=('Keep alive time in seconds (default: ' 280 | '%(default)s). Give as 0 to disable keep ' 281 | 'alive.')) 282 | subparser.add_argument( 283 | '--session-expiry-interval', 284 | default=0, 285 | type=Integer(0, 0xffffffff), 286 | help='Session expiry interval in the range 0..0xffffffff (default: %(default)s).') 287 | subparser.add_argument( 288 | '--cafile', 289 | default='', 290 | help='CA file.') 291 | subparser.add_argument( 292 | '--no-check-hostname', 293 | action='store_true', 294 | help='Do not check certificate hostname.') 295 | subparser.add_argument( 296 | '--retain-handling', 297 | type=Integer(0, 2), 298 | default=[], 299 | action='append', 300 | help=('Retain handling for the subscriptions. May be given once for each ' 301 | 'subscription. Last known value is used for remaining topics ' 302 | '(default: 0).')) 303 | subparser.add_argument( 304 | 'subscribe', 305 | nargs='+', 306 | help='Subscribe to given topic(s) .') 307 | subparser.set_defaults(func=_do_monitor) 308 | -------------------------------------------------------------------------------- /mqttools/subparsers/publish.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import binascii 3 | import ssl 4 | import time 5 | 6 | from argparse_addons import Integer 7 | from humanfriendly import format_timespan 8 | 9 | from ..client import Client 10 | from ..client import Message 11 | from ..common import Error 12 | 13 | 14 | def encode_binary(message): 15 | try: 16 | return binascii.unhexlify(message) 17 | except ValueError: 18 | raise Error(f"Invalid hex string '{message}'.") 19 | 20 | 21 | def encode_message(message, kind): 22 | if kind == 'auto': 23 | try: 24 | return binascii.unhexlify(message) 25 | except ValueError: 26 | return message.encode() 27 | elif kind == 'binary': 28 | return encode_binary(message) 29 | else: 30 | return message.encode() 31 | 32 | 33 | def create_message(message, message_format, size, number, fmt): 34 | if message is None: 35 | message_bytes = fmt.format(number).encode('ascii') 36 | extra = (size - len(message_bytes)) 37 | 38 | if extra > 0: 39 | message_bytes += extra * b'\xa5' 40 | else: 41 | message_bytes = message_bytes[:size] 42 | else: 43 | message_bytes = encode_message(message, message_format) 44 | 45 | return message_bytes 46 | 47 | 48 | async def publisher(host, 49 | port, 50 | client_id, 51 | count, 52 | size, 53 | retain, 54 | will_topic, 55 | will_message, 56 | will_retain, 57 | session_expiry_interval, 58 | message_format, 59 | cafile, 60 | check_hostname, 61 | topic, 62 | message, 63 | response_topic, 64 | username, 65 | password): 66 | if will_message is not None: 67 | will_message = encode_message(will_message, message_format) 68 | 69 | if cafile: 70 | print(f"CA File: '{cafile}'") 71 | print(f"Check hostname: {check_hostname}") 72 | 73 | context = ssl.create_default_context(cafile=cafile) 74 | context.check_hostname = check_hostname 75 | else: 76 | context = None 77 | 78 | if password is not None: 79 | password = password.encode('utf-8') 80 | 81 | print(f"Connecting to '{host}:{port}'.") 82 | print() 83 | 84 | async with Client(host, 85 | port, 86 | client_id, 87 | will_topic=will_topic, 88 | will_message=will_message, 89 | will_retain=will_retain, 90 | session_expiry_interval=session_expiry_interval, 91 | username=username, 92 | password=password, 93 | ssl=context) as client: 94 | fmt = '{{:0{}}}'.format(len(str(count - 1))) 95 | start_time = time.time() 96 | 97 | for number in range(count): 98 | message_bytes = create_message(message, 99 | message_format, 100 | size, 101 | number, 102 | fmt) 103 | client.publish(Message(topic, 104 | message_bytes, 105 | retain, 106 | response_topic)) 107 | 108 | elapsed_time = format_timespan(time.time() - start_time) 109 | print(f'Published {count} message(s) in {elapsed_time}.') 110 | 111 | 112 | def _do_publish(args): 113 | asyncio.run(publisher(args.host, 114 | args.port, 115 | args.client_id, 116 | args.count, 117 | args.size, 118 | args.retain, 119 | args.will_topic, 120 | args.will_message, 121 | args.will_retain, 122 | args.session_expiry_interval, 123 | args.message_format, 124 | args.cafile, 125 | not args.no_check_hostname, 126 | args.topic, 127 | args.message, 128 | args.response_topic, 129 | args.username, 130 | args.password)) 131 | 132 | 133 | def add_subparser(subparsers): 134 | subparser = subparsers.add_parser('publish', 135 | description='Publish given topic.') 136 | subparser.add_argument('--host', 137 | default='localhost', 138 | help='Broker host (default: %(default)s).') 139 | subparser.add_argument('--port', 140 | type=Integer(0), 141 | default=1883, 142 | help='Broker port (default: %(default)s).') 143 | subparser.add_argument('--client-id', 144 | help='Client id (default: mqttools-).') 145 | subparser.add_argument( 146 | '--count', 147 | type=Integer(0), 148 | default=1, 149 | help='Number of times to publish the message (default: %(default)s).') 150 | subparser.add_argument( 151 | '--size', 152 | type=Integer(0), 153 | default=10, 154 | help='Generated message size (default: %(default)s).') 155 | subparser.add_argument('--retain', 156 | action='store_true', 157 | help='Retain the message.') 158 | subparser.add_argument('--will-topic', help='Will topic.') 159 | subparser.add_argument('--will-message', help='Will message.') 160 | subparser.add_argument('--will-retain', 161 | action='store_true', 162 | help='Retain the will message.') 163 | subparser.add_argument('--response-topic', 164 | help='Response topic.') 165 | subparser.add_argument('--username', 166 | help='Username.') 167 | subparser.add_argument('--password', 168 | help='Password.') 169 | subparser.add_argument( 170 | '--session-expiry-interval', 171 | type=Integer(0, 0xffffffff), 172 | default=0, 173 | help='Session expiry interval in the range 0..0xffffffff (default: %(default)s).') 174 | subparser.add_argument( 175 | '--message-format', 176 | choices=('auto', 'binary', 'text'), 177 | default='auto', 178 | help='Message format (default: %(default)s).') 179 | subparser.add_argument( 180 | '--cafile', 181 | default='', 182 | help='CA file.') 183 | subparser.add_argument( 184 | '--no-check-hostname', 185 | action='store_true', 186 | help='Do not check certificate hostname.') 187 | subparser.add_argument('topic', help='Topic to publish.') 188 | subparser.add_argument( 189 | 'message', 190 | nargs='?', 191 | help='Message to publish (default: \xa5...') 192 | subparser.set_defaults(func=_do_publish) 193 | -------------------------------------------------------------------------------- /mqttools/subparsers/subscribe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ssl 3 | 4 | from argparse_addons import Integer 5 | 6 | from ..client import Client 7 | from . import format_message 8 | 9 | 10 | async def subscriber(host, 11 | port, 12 | cafile, 13 | check_hostname, 14 | client_id, 15 | topic, 16 | keep_alive_s, 17 | session_expiry_interval, 18 | retain_handling, 19 | message_format): 20 | if cafile: 21 | print(f"CA File: '{cafile}'") 22 | print(f"Check hostname: {check_hostname}") 23 | 24 | context = ssl.create_default_context(cafile=cafile) 25 | context.check_hostname = check_hostname 26 | else: 27 | context = None 28 | 29 | client = Client(host, 30 | port, 31 | client_id, 32 | keep_alive_s=keep_alive_s, 33 | session_expiry_interval=session_expiry_interval, 34 | subscriptions=[(topic, retain_handling)], 35 | topic_alias_maximum=10, 36 | ssl=context) 37 | 38 | while True: 39 | print(f"Connecting to '{host}:{port}'.") 40 | await client.start() 41 | print('Connected.') 42 | 43 | while True: 44 | message = await client.messages.get() 45 | 46 | if message is None: 47 | print('Broker connection lost!') 48 | break 49 | 50 | print(f'Topic: {message.topic}') 51 | print(f'Message: ' 52 | f'{format_message(message_format, message.message)}') 53 | print(f'Retain: {message.retain}') 54 | print(f'ResponseTopic: {message.response_topic}') 55 | 56 | await client.stop() 57 | 58 | 59 | def _do_subscribe(args): 60 | asyncio.run(subscriber(args.host, 61 | args.port, 62 | args.cafile, 63 | not args.no_check_hostname, 64 | args.client_id, 65 | args.topic, 66 | args.keep_alive, 67 | args.session_expiry_interval, 68 | args.retain_handling, 69 | args.message_format)) 70 | 71 | 72 | def add_subparser(subparsers): 73 | subparser = subparsers.add_parser('subscribe', 74 | description='Subscribe for given topic.') 75 | subparser.add_argument('--host', 76 | default='localhost', 77 | help='Broker host (default: %(default)s).') 78 | subparser.add_argument('--port', 79 | type=Integer(0), 80 | default=1883, 81 | help='Broker port (default: %(default)s).') 82 | subparser.add_argument('--client-id', 83 | help='Client id (default: mqttools-).') 84 | subparser.add_argument( 85 | '--keep-alive', 86 | type=Integer(0), 87 | default=0, 88 | help=('Keep alive time in seconds (default: %(default)s). Give as 0 to ' 89 | 'disable keep alive.')) 90 | subparser.add_argument( 91 | '--session-expiry-interval', 92 | default=0, 93 | type=Integer(0, 0xffffffff), 94 | help='Session expiry interval in the range 0..0xffffffff (default: %(default)s).') 95 | subparser.add_argument( 96 | '--retain-handling', 97 | type=Integer(0, 2), 98 | default=0, 99 | help='Retain handling for the subscriptions (default: %(default)s).') 100 | subparser.add_argument( 101 | '--message-format', 102 | choices=('auto', 'binary', 'text'), 103 | default='auto', 104 | help='Message format (default: %(default)s).') 105 | subparser.add_argument( 106 | '--cafile', 107 | default='', 108 | help='CA file.') 109 | subparser.add_argument( 110 | '--no-check-hostname', 111 | action='store_true', 112 | help='Do not check certificate hostname.') 113 | subparser.add_argument('topic', help='Topic to subscribe for.') 114 | subparser.set_defaults(func=_do_subscribe) 115 | -------------------------------------------------------------------------------- /mqttools/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.50.0' 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bitstruct 2 | humanfriendly 3 | argparse_addons 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | 5 | import setuptools 6 | from setuptools import find_packages 7 | 8 | 9 | def find_version(): 10 | return re.search(r"^__version__ = '(.*)'$", 11 | open('mqttools/version.py', 'r').read(), 12 | re.MULTILINE).group(1) 13 | 14 | 15 | setuptools.setup( 16 | name='mqttools', 17 | version=find_version(), 18 | description='MQTT tools.', 19 | long_description=open('README.rst', 'r').read(), 20 | author='Erik Moqvist', 21 | author_email='erik.moqvist@gmail.com', 22 | license='MIT', 23 | classifiers=[ 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 3', 26 | ], 27 | keywords=['mqtt'], 28 | url='https://github.com/eerimoq/mqttools', 29 | packages=find_packages(exclude=['tests']), 30 | install_requires=[ 31 | 'bitstruct', 32 | 'humanfriendly', 33 | 'windows-curses;platform_system=="Windows"', 34 | 'argparse_addons' 35 | ], 36 | test_suite="tests", 37 | entry_points={ 38 | 'console_scripts': ['mqttools=mqttools.__init__:main'] 39 | }) 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eerimoq/mqttools/a28c86e89af0852249a5d6f33f9e67036c3eb8fe/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_broker_thread.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import socket 4 | import unittest 5 | 6 | import mqttools 7 | 8 | BROKER_PORT = 10341 9 | 10 | 11 | async def start_stop_clients(): 12 | subscriber = mqttools.Client('localhost', BROKER_PORT) 13 | publisher = mqttools.Client('localhost', BROKER_PORT) 14 | 15 | await subscriber.start() 16 | await subscriber.subscribe('/apa') 17 | 18 | await publisher.start() 19 | publisher.publish(mqttools.Message('/apa', b'halloj')) 20 | await publisher.stop() 21 | 22 | message = await subscriber.messages.get() 23 | 24 | if message.topic != '/apa' or message.message != b'halloj': 25 | raise Exception('Wrong message {}'.format(message)) 26 | 27 | await subscriber.stop() 28 | 29 | 30 | class BrokerThreadTest(unittest.TestCase): 31 | 32 | def connect(self): 33 | sock = socket.socket() 34 | 35 | try: 36 | sock.connect(('localhost', BROKER_PORT)) 37 | finally: 38 | sock.close() 39 | 40 | def test_start_stop(self): 41 | broker = mqttools.BrokerThread(('localhost', BROKER_PORT)) 42 | 43 | with self.assertRaises(ConnectionRefusedError): 44 | self.connect() 45 | 46 | broker.start() 47 | asyncio.run(start_stop_clients()) 48 | self.connect() 49 | broker.stop() 50 | 51 | with self.assertRaises(mqttools.broker.NotRunningError): 52 | broker.stop() 53 | 54 | with self.assertRaises(ConnectionRefusedError): 55 | self.connect() 56 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import unittest 4 | 5 | import mqttools 6 | 7 | 8 | async def broker_main(listener): 9 | async with listener: 10 | try: 11 | await listener.serve_forever() 12 | except asyncio.CancelledError: 13 | pass 14 | 15 | 16 | class ClientTest(unittest.TestCase): 17 | 18 | def test_connack_timeout(self): 19 | asyncio.run(self.connack_timeout()) 20 | 21 | async def connack_timeout(self): 22 | def on_client_connected(reader, writer): 23 | pass 24 | 25 | listener = await asyncio.start_server(on_client_connected, 'localhost', 0) 26 | 27 | async def client_main(): 28 | client = mqttools.Client(*listener.sockets[0].getsockname()[:2], 29 | 'connack', 30 | response_timeout=0.1, 31 | connect_delays=[], 32 | topic_alias_maximum=0) 33 | 34 | with self.assertRaises(mqttools.TimeoutError): 35 | await client.start() 36 | 37 | listener.close() 38 | 39 | await asyncio.wait_for( 40 | asyncio.gather(broker_main(listener), client_main()), 1) 41 | 42 | def test_subscribe_timeout(self): 43 | asyncio.run(self.subscribe_timeout()) 44 | 45 | async def subscribe_timeout(self): 46 | def on_client_connected(reader, writer): 47 | # CONNACK 48 | writer.write(b'\x20\x03\x00\x00\x00') 49 | 50 | listener = await asyncio.start_server(on_client_connected, 'localhost', 0) 51 | 52 | async def client_main(): 53 | client = mqttools.Client(*listener.sockets[0].getsockname()[:2], 54 | 'suback', 55 | response_timeout=0.1, 56 | topic_alias_maximum=0) 57 | await client.start() 58 | 59 | with self.assertRaises(mqttools.TimeoutError): 60 | await client.subscribe('/foo') 61 | 62 | listener.close() 63 | 64 | await asyncio.wait_for( 65 | asyncio.gather(broker_main(listener), client_main()), 1) 66 | 67 | def test_unsubscribe_timeout(self): 68 | asyncio.run(self.unsubscribe_timeout()) 69 | 70 | async def unsubscribe_timeout(self): 71 | def on_client_connected(reader, writer): 72 | # CONNACK 73 | writer.write(b'\x20\x03\x00\x00\x00') 74 | 75 | listener = await asyncio.start_server(on_client_connected, 'localhost', 0) 76 | 77 | async def client_main(): 78 | client = mqttools.Client(*listener.sockets[0].getsockname()[:2], 79 | 'unsuback', 80 | response_timeout=0.1, 81 | topic_alias_maximum=0) 82 | await client.start() 83 | 84 | with self.assertRaises(mqttools.TimeoutError): 85 | await client.unsubscribe('/foo') 86 | 87 | listener.close() 88 | 89 | await asyncio.wait_for( 90 | asyncio.gather(broker_main(listener), client_main()), 1) 91 | 92 | def test_client_id(self): 93 | client = mqttools.Client('localhost', 0) 94 | self.assertEqual(client.client_id[:9], 'mqttools-') 95 | 96 | 97 | 98 | logging.basicConfig(level=logging.DEBUG) 99 | 100 | 101 | if __name__ == '__main__': 102 | unittest.main() 103 | -------------------------------------------------------------------------------- /tests/test_format_packet.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import mqttools 4 | 5 | 6 | class FormatPacketTest(unittest.TestCase): 7 | 8 | maxDiff = None 9 | 10 | def test_format_packet(self): 11 | datas = [ 12 | ( 13 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo', 14 | [ 15 | 'Received CONNECT(1) packet of 18 byte(s)', 16 | ' ClientId: goo', 17 | ' CleanStart: True', 18 | ' WillTopic: None', 19 | ' WillMessage: None', 20 | ' WillRetain: None', 21 | ' KeepAlive: 1', 22 | ' UserName: None', 23 | ' Password: None' 24 | ], 25 | 'Received CONNECT(1): ClientId=goo, KeepAlive=1' 26 | ), 27 | ( 28 | b'\x10\x1a\x00\x04MQTT\x05\x06\x00\x00\x00\x00\x02id\x00\x00' 29 | b'\x03foo\x00\x03bar', 30 | [ 31 | 'Received CONNECT(1) packet of 28 byte(s)', 32 | ' ClientId: id', 33 | ' CleanStart: True', 34 | ' WillTopic: foo', 35 | ' WillMessage: 626172', 36 | ' WillRetain: False', 37 | ' KeepAlive: 0', 38 | ' UserName: None', 39 | ' Password: None' 40 | ], 41 | 'Received CONNECT(1): ClientId=id, WillTopic=foo, ' 42 | 'WillMessage=626172, KeepAlive=0' 43 | ), 44 | ( 45 | b'\x20\x03\x00\x00\x00', 46 | [ 47 | 'Received CONNACK(2) packet of 5 byte(s)', 48 | ' SessionPresent: False', 49 | ' Reason: SUCCESS(0)' 50 | ], 51 | 'Received CONNACK(2): Reason=SUCCESS(0)' 52 | ), 53 | ( 54 | b'\xc0\x00', 55 | [ 56 | 'Received PINGREQ(12) packet of 2 byte(s)' 57 | ], 58 | 'Received PINGREQ(12)' 59 | ), 60 | ( 61 | b'\xd0\x00', 62 | [ 63 | 'Received PINGRESP(13) packet of 2 byte(s)' 64 | ], 65 | 'Received PINGRESP(13)' 66 | ), 67 | ( 68 | b'\xe0\x0f\x80\x0d\x1f\x00\x0aSome error', 69 | [ 70 | 'Received DISCONNECT(14) packet of 17 byte(s)', 71 | ' Reason: UNSPECIFIED_ERROR(128)', 72 | ' Properties:', 73 | ' REASON_STRING(31): Some error' 74 | ], 75 | 'Received DISCONNECT(14): Reason=UNSPECIFIED_ERROR(128)' 76 | ), 77 | ( 78 | b'\x30\x31\x00\x04\x2F\x61\x2F\x62\x00\x31\x32\x33\x34\x35' 79 | b'\x36\x37\x38\x39\x30\x30\x31\x32\x33\x34\x35\x36\x37\x38' 80 | b'\x39\x30\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x30\x30' 81 | b'\x31\x32\x33\x34\x35\x36\x37\x38\x39', 82 | [ 83 | 'Received PUBLISH(3) packet of 51 byte(s)', 84 | ' DupFlag: False', 85 | ' QoSLevel: 0', 86 | ' Retain: False', 87 | ' Topic: /a/b', 88 | ' Message: 313233343536373839303031323334353637383930' 89 | '303132333435363738393030313233343536373839', 90 | ], 91 | 'Received PUBLISH(3): Topic=/a/b, Message=31323334353637383930' 92 | '3031323334353637383930303132333435363738393030313233343536373' 93 | '839' 94 | ), 95 | ( 96 | b'\x82\x0a\x00\x01\x00\x00\x04\x2f\x61\x2f\x62\x00', 97 | [ 98 | 'Received SUBSCRIBE(8) packet of 12 byte(s)', 99 | ' PacketIdentifier: 1', 100 | ' Subscriptions:', 101 | ' Topic: /a/b', 102 | ' MaximumQoS: 0', 103 | ' NoLocal: False', 104 | ' RetainAsPublished: False', 105 | ' RetainHandling: 0' 106 | ], 107 | 'Received SUBSCRIBE(8): Topic=/a/b' 108 | ), 109 | ( 110 | b'\x90\x04\x00\x01\x00\x00', 111 | [ 112 | 'Received SUBACK(9) packet of 6 byte(s)', 113 | ' PacketIdentifier: 1', 114 | ' Reasons:', 115 | ' GRANTED_QOS_0(0)' 116 | ], 117 | 'Received SUBACK(9): Reason=GRANTED_QOS_0(0)' 118 | ), 119 | ( 120 | b'\xa2\x0f\x00\x02\x00\x00\x04\x2f\x61\x2f\x62\x00\x04\x2f\x62' 121 | b'\x2f\x23', 122 | [ 123 | 'Received UNSUBSCRIBE(10) packet of 17 byte(s)', 124 | ' PacketIdentifier: 2', 125 | ' Topics:', 126 | ' /a/b', 127 | ' /b/#' 128 | ], 129 | 'Received UNSUBSCRIBE(10): Topic=/a/b, Topic=/b/#' 130 | ), 131 | ( 132 | b'\xb0\x05\x00\x02\x00\x00\x00', 133 | [ 134 | 'Received UNSUBACK(11) packet of 7 byte(s)', 135 | ' PacketIdentifier: 2', 136 | ' Reasons:', 137 | ' SUCCESS(0)', 138 | ' SUCCESS(0)' 139 | ], 140 | 'Received UNSUBACK(11): Reason=SUCCESS(0), Reason=SUCCESS(0)' 141 | ) 142 | ] 143 | 144 | for message, lines, compact_lines in datas: 145 | self.assertEqual( 146 | mqttools.common.format_packet('Received', message), 147 | lines) 148 | self.assertEqual( 149 | mqttools.common.format_packet_compact('Received', message), 150 | compact_lines) 151 | 152 | def test_format_malformed_publish(self): 153 | message = b'\x30\x07\x00\x01\xff\x00apa' 154 | 155 | lines = mqttools.common.format_packet('Received', message) 156 | self.assertIn(' *** Malformed packet (', lines[1]) 157 | self.assertEqual(lines[2], '') 158 | self.assertEqual(lines[3], ' Traceback (most recent call last):') 159 | 160 | line = mqttools.common.format_packet_compact('Received', message) 161 | self.assertIn('*** Malformed packet', line) 162 | -------------------------------------------------------------------------------- /tests/test_mqttools.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import binascii 3 | import logging 4 | import queue 5 | import socket 6 | import sys 7 | import threading 8 | import unittest 9 | from io import StringIO 10 | from unittest.mock import patch 11 | 12 | import mqttools 13 | 14 | HOST = 'localhost' 15 | PORT = 0 16 | 17 | 18 | class Broker(threading.Thread): 19 | 20 | EXPECTED_DATA_INDEX = 0 21 | EXPECTED_DATA_STREAM = [] 22 | ACTUAL_DATA_STREAM = [] 23 | 24 | def __init__(self): 25 | super().__init__() 26 | self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 27 | self._listener.bind((HOST, PORT)) 28 | self._listener.listen() 29 | self._client_closed = queue.Queue() 30 | 31 | @property 32 | def address(self): 33 | return self._listener.getsockname()[:2] 34 | 35 | def wait_for_client_closed(self): 36 | self._client_closed.get(timeout=1) 37 | 38 | def run(self): 39 | while True: 40 | print('Broker: Listening for client...') 41 | self.serve_client(self._listener.accept()[0]) 42 | self._client_closed.put(True) 43 | 44 | def serve_client(self, client): 45 | print('Broker: Serving client...') 46 | 47 | while self.EXPECTED_DATA_INDEX < len(self.EXPECTED_DATA_STREAM): 48 | _, data = self.EXPECTED_DATA_STREAM[self.EXPECTED_DATA_INDEX] 49 | 50 | size = len(data) 51 | data = client.recv(size) 52 | 53 | if not data: 54 | break 55 | 56 | self.EXPECTED_DATA_INDEX += 1 57 | # print(f'Broker: Received: {data}') 58 | self.ACTUAL_DATA_STREAM.append(('c2s', data)) 59 | 60 | while self.EXPECTED_DATA_INDEX < len(self.EXPECTED_DATA_STREAM): 61 | direction, data = self.EXPECTED_DATA_STREAM[self.EXPECTED_DATA_INDEX] 62 | 63 | if direction != 's2c': 64 | break 65 | 66 | self.EXPECTED_DATA_INDEX += 1 67 | # print(f'Broker: Sending: {data}') 68 | client.send(data) 69 | self.ACTUAL_DATA_STREAM.append(('s2c', data)) 70 | 71 | client.close() 72 | 73 | 74 | class MQTToolsTest(unittest.TestCase): 75 | 76 | def setUp(self): 77 | Broker.EXPECTED_DATA_INDEX = 0 78 | Broker.EXPECTED_DATA_STREAM = [] 79 | Broker.ACTUAL_DATA_STREAM = [] 80 | Broker.CLOSE_AFTER_INDEX = -1 81 | self.broker = Broker() 82 | self.broker.daemon = True 83 | self.broker.start() 84 | self.loop = asyncio.new_event_loop() 85 | asyncio.set_event_loop(self.loop) 86 | 87 | def tearDown(self): 88 | self.broker.wait_for_client_closed() 89 | self.loop.close() 90 | self.assertEqual(Broker.ACTUAL_DATA_STREAM, Broker.EXPECTED_DATA_STREAM) 91 | 92 | def run_until_complete(self, coro): 93 | return self.loop.run_until_complete(coro) 94 | 95 | def test_start_stop(self): 96 | Broker.EXPECTED_DATA_STREAM = [ 97 | # CONNECT 98 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 99 | # CONNACK 100 | ('s2c', b'\x20\x03\x00\x00\x00'), 101 | # DISCONNECT 102 | ('c2s', b'\xe0\x02\x00\x00') 103 | ] 104 | 105 | client = mqttools.Client(*self.broker.address, 106 | 'bar', 107 | keep_alive_s=0, 108 | topic_alias_maximum=0) 109 | self.run_until_complete(client.start()) 110 | self.run_until_complete(client.stop()) 111 | 112 | def test_empty_context_manager(self): 113 | Broker.EXPECTED_DATA_STREAM = [ 114 | # CONNECT 115 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 116 | # CONNACK 117 | ('s2c', b'\x20\x03\x00\x00\x00'), 118 | # DISCONNECT 119 | ('c2s', b'\xe0\x02\x00\x00') 120 | ] 121 | 122 | async def manager(): 123 | async with mqttools.Client(*self.broker.address, 124 | 'bar', 125 | keep_alive_s=0, 126 | topic_alias_maximum=0): 127 | pass 128 | 129 | self.run_until_complete(manager()) 130 | 131 | def test_subscribe(self): 132 | Broker.EXPECTED_DATA_STREAM = [ 133 | # CONNECT 134 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 135 | # CONNACK 136 | ('s2c', b'\x20\x03\x00\x00\x00'), 137 | # SUBSCRIBE 138 | ('c2s', b'\x82\n\x00\x01\x00\x00\x04/a/b\x00'), 139 | # SUBACK 140 | ('s2c', b'\x90\x04\x00\x01\x00\x00'), 141 | # SUBSCRIBE 142 | ('c2s', b'\x82\n\x00\x02\x00\x00\x04/a/c\x00'), 143 | # SUBACK 144 | ('s2c', b'\x90\x04\x00\x02\x00\x00'), 145 | # SUBSCRIBE with invalid topic 146 | ('c2s', b'\x82\x09\x00\x03\x00\x00\x03/a#\x00'), 147 | # SUBACK 148 | ('s2c', b'\x90\x04\x00\x03\x00\xa2'), 149 | # PUBLISH QoS 0 150 | ('s2c', b'\x30\x0a\x00\x04/a/b\x00apa'), 151 | # DISCONNECT 152 | ('c2s', b'\xe0\x02\x00\x00') 153 | ] 154 | 155 | client = mqttools.Client(*self.broker.address, 156 | 'bar', 157 | keep_alive_s=0, 158 | topic_alias_maximum=0) 159 | self.run_until_complete(client.start()) 160 | self.run_until_complete(client.subscribe('/a/b')) 161 | self.run_until_complete(client.subscribe('/a/c')) 162 | 163 | with self.assertRaises(mqttools.SubscribeError) as cm: 164 | self.run_until_complete(client.subscribe('/a#')) 165 | 166 | self.assertEqual(cm.exception.reason, 167 | mqttools.SubackReasonCode.WILDCARD_SUBSCRIPTIONS_NOT_SUPPORTED) 168 | message = self.run_until_complete(client.messages.get()) 169 | self.assertEqual(message.topic, '/a/b') 170 | self.assertEqual(message.message, b'apa') 171 | self.run_until_complete(client.stop()) 172 | 173 | def test_subscribe_retain_handling_values(self): 174 | Broker.EXPECTED_DATA_STREAM = [ 175 | # CONNECT 176 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 177 | # CONNACK 178 | ('s2c', b'\x20\x03\x00\x00\x00'), 179 | # SUBSCRIBE retain handling 0 180 | ('c2s', b'\x82\n\x00\x01\x00\x00\x04/a/b\x00'), 181 | # SUBACK 182 | ('s2c', b'\x90\x04\x00\x01\x00\x00'), 183 | # SUBSCRIBE retain handling 1 184 | ('c2s', b'\x82\n\x00\x02\x00\x00\x04/a/c\x10'), 185 | # SUBACK 186 | ('s2c', b'\x90\x04\x00\x02\x00\x00'), 187 | # SUBSCRIBE retain handling 2 188 | ('c2s', b'\x82\n\x00\x03\x00\x00\x04/a/d\x20'), 189 | # SUBACK 190 | ('s2c', b'\x90\x04\x00\x03\x00\x00'), 191 | # DISCONNECT 192 | ('c2s', b'\xe0\x02\x00\x00') 193 | ] 194 | 195 | client = mqttools.Client(*self.broker.address, 196 | 'bar', 197 | keep_alive_s=0, 198 | topic_alias_maximum=0) 199 | self.run_until_complete(client.start()) 200 | self.run_until_complete(client.subscribe('/a/b', 0)) 201 | self.run_until_complete(client.subscribe('/a/c', 1)) 202 | self.run_until_complete(client.subscribe('/a/d', 2)) 203 | self.run_until_complete(client.stop()) 204 | 205 | def test_unsubscribe(self): 206 | Broker.EXPECTED_DATA_STREAM = [ 207 | # CONNECT 208 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 209 | # CONNACK 210 | ('s2c', b'\x20\x03\x00\x00\x00'), 211 | # SUBSCRIBE 212 | ('c2s', b'\x82\n\x00\x01\x00\x00\x04/a/b\x00'), 213 | # SUBACK 214 | ('s2c', b'\x90\x04\x00\x01\x00\x00'), 215 | # UNSUBSCRIBE 216 | ('c2s', b'\xa2\x09\x00\x02\x00\x00\x04/a/b'), 217 | # UNSUBACK 218 | ('s2c', b'\xb0\x04\x00\x02\x00\x00'), 219 | # UNSUBSCRIBE from non-subscribed topic 220 | ('c2s', b'\xa2\x09\x00\x03\x00\x00\x04/a/d'), 221 | # UNSUBACK 222 | ('s2c', b'\xb0\x04\x00\x03\x00\x11'), 223 | # DISCONNECT 224 | ('c2s', b'\xe0\x02\x00\x00') 225 | ] 226 | 227 | client = mqttools.Client(*self.broker.address, 228 | 'bar', 229 | keep_alive_s=0, 230 | topic_alias_maximum=0) 231 | self.run_until_complete(client.start()) 232 | self.run_until_complete(client.subscribe('/a/b')) 233 | self.run_until_complete(client.unsubscribe('/a/b')) 234 | 235 | with self.assertRaises(mqttools.UnsubscribeError) as cm: 236 | self.run_until_complete(client.unsubscribe('/a/d')) 237 | 238 | self.assertEqual(cm.exception.reason, 239 | mqttools.UnsubackReasonCode.NO_SUBSCRIPTION_EXISTED) 240 | self.run_until_complete(client.stop()) 241 | 242 | def test_publish_qos_0(self): 243 | Broker.EXPECTED_DATA_STREAM = [ 244 | # CONNECT 245 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 246 | # CONNACK 247 | ('s2c', b'\x20\x03\x00\x00\x00'), 248 | # PUBLISH 249 | ('c2s', b'\x30\x0a\x00\x04/a/b\x00apa'), 250 | # DISCONNECT 251 | ('c2s', b'\xe0\x02\x00\x00') 252 | ] 253 | 254 | client = mqttools.Client(*self.broker.address, 255 | 'bar', 256 | keep_alive_s=0, 257 | topic_alias_maximum=0) 258 | self.run_until_complete(client.start()) 259 | client.publish(mqttools.Message('/a/b', b'apa')) 260 | self.run_until_complete(client.stop()) 261 | 262 | def test_command_line_publish_qos_0(self): 263 | Broker.EXPECTED_DATA_STREAM = [ 264 | # CONNECT 265 | ( 266 | 'c2s', 267 | b'\x10\x20\x00\x04MQTT\x05\x02\x00<\x03"\x00\n\x00\x10' 268 | b'mqttools_publish' 269 | ), 270 | # CONNACK 271 | ('s2c', b'\x20\x03\x00\x00\x00'), 272 | # PUBLISH 273 | ('c2s', b'\x30\x0a\x00\x04/a/b\x00apa'), 274 | # DISCONNECT 275 | ('c2s', b'\xe0\x02\x00\x00') 276 | ] 277 | 278 | argv = [ 279 | 'mqttools', 280 | 'publish', 281 | '--host', self.broker.address[0], 282 | '--port', str(self.broker.address[1]), 283 | '--client-id', 'mqttools_publish', 284 | '/a/b', 285 | '617061' 286 | ] 287 | 288 | stdout = StringIO() 289 | 290 | with patch('sys.stdout', stdout): 291 | with patch('sys.argv', argv): 292 | mqttools.main() 293 | 294 | self.assertIn('Published 1 message(s) in', stdout.getvalue()) 295 | 296 | def test_command_line_publish_qos_0_generate_message(self): 297 | Broker.EXPECTED_DATA_STREAM = [ 298 | # CONNECT 299 | ( 300 | 'c2s', 301 | b'\x10\x20\x00\x04MQTT\x05\x02\x00<\x03"\x00\n\x00\x10' 302 | b'mqttools_publish' 303 | ), 304 | # CONNACK 305 | ('s2c', b'\x20\x03\x00\x00\x00'), 306 | # PUBLISH 307 | ('c2s', b'\x30\x11\x00\x04/a/b\x000\xa5\xa5\xa5\xa5\xa5\xa5\xa5\xa5\xa5'), 308 | # DISCONNECT 309 | ('c2s', b'\xe0\x02\x00\x00') 310 | ] 311 | 312 | argv = [ 313 | 'mqttools', 314 | 'publish', 315 | '--host', self.broker.address[0], 316 | '--port', str(self.broker.address[1]), 317 | '--client-id', 'mqttools_publish', 318 | '--size', '10', 319 | '/a/b' 320 | ] 321 | 322 | stdout = StringIO() 323 | 324 | with patch('sys.stdout', stdout): 325 | with patch('sys.argv', argv): 326 | mqttools.main() 327 | 328 | self.assertIn('Published 1 message(s) in', stdout.getvalue()) 329 | 330 | def test_command_line_publish_qos_0_generate_short_message(self): 331 | Broker.EXPECTED_DATA_STREAM = [ 332 | # CONNECT 333 | ( 334 | 'c2s', 335 | b'\x10\x20\x00\x04MQTT\x05\x02\x00<\x03"\x00\n\x00\x10' 336 | b'mqttools_publish' 337 | ), 338 | # CONNACK 339 | ('s2c', b'\x20\x03\x00\x00\x00'), 340 | # PUBLISH 341 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 342 | # PUBLISH 343 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 344 | # PUBLISH 345 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 346 | # PUBLISH 347 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 348 | # PUBLISH 349 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 350 | # PUBLISH 351 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 352 | # PUBLISH 353 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 354 | # PUBLISH 355 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 356 | # PUBLISH 357 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 358 | # PUBLISH 359 | ('c2s', b'\x30\x08\x00\x04/a/b\x000'), 360 | # PUBLISH 361 | ('c2s', b'\x30\x08\x00\x04/a/b\x001'), 362 | # DISCONNECT 363 | ('c2s', b'\xe0\x02\x00\x00') 364 | ] 365 | 366 | argv = [ 367 | 'mqttools', 368 | 'publish', 369 | '--host', self.broker.address[0], 370 | '--port', str(self.broker.address[1]), 371 | '--client-id', 'mqttools_publish', 372 | '--count', '11', 373 | '--size', '1', 374 | '/a/b' 375 | ] 376 | 377 | stdout = StringIO() 378 | 379 | with patch('sys.stdout', stdout): 380 | with patch('sys.argv', argv): 381 | mqttools.main() 382 | 383 | self.assertIn('Published 11 message(s) in', stdout.getvalue()) 384 | 385 | def test_publish_topic_alias(self): 386 | Broker.EXPECTED_DATA_STREAM = [ 387 | # CONNECT 388 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 389 | # CONNACK with topic alias 5 390 | ('s2c', b'\x20\x06\x00\x00\x03\x22\x00\x05'), 391 | # PUBLISH to set alias 392 | ( 393 | 'c2s', 394 | b'\x30\x2c\x00\x12/test/mqttools/foo\x03\x23\x00\x01' 395 | b'sets-alias-in-broker' 396 | ), 397 | # PUBLISH using alias 398 | ('c2s', b'\x30\x1a\x00\x00\x03\x23\x00\x01published-with-alias'), 399 | # PUBLISH without alias 400 | ('c2s', b'\x30\x24\x00\x12/test/mqttools/fie\x00not-using-alias'), 401 | # DISCONNECT 402 | ('c2s', b'\xe0\x02\x00\x00') 403 | ] 404 | 405 | client = mqttools.Client(*self.broker.address, 406 | 'bar', 407 | topic_aliases=[ 408 | '/test/mqttools/foo' 409 | ], 410 | topic_alias_maximum=0, 411 | keep_alive_s=0) 412 | self.run_until_complete(client.start()) 413 | client.publish(mqttools.Message('/test/mqttools/foo', b'sets-alias-in-broker')) 414 | client.publish(mqttools.Message('/test/mqttools/foo', b'published-with-alias')) 415 | client.publish(mqttools.Message('/test/mqttools/fie', b'not-using-alias')) 416 | self.run_until_complete(client.stop()) 417 | 418 | def test_use_all_topic_aliases(self): 419 | Broker.EXPECTED_DATA_STREAM = [ 420 | # CONNECT 421 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 422 | # CONNACK with topic alias 1 423 | ('s2c', b'\x20\x06\x00\x00\x03\x22\x00\x01'), 424 | # PUBLISH to set alias 425 | ('c2s', b'\x30\x0d\x00\x04/foo\x03\x23\x00\x01apa'), 426 | # PUBLISH, no alias available 427 | ('c2s', b'\x30\x0a\x00\x04/bar\x00cat'), 428 | # DISCONNECT 429 | ('c2s', b'\xe0\x02\x00\x00') 430 | ] 431 | 432 | client = mqttools.Client(*self.broker.address, 433 | 'bar', 434 | topic_aliases=[ 435 | '/foo' 436 | ], 437 | topic_alias_maximum=0, 438 | keep_alive_s=0) 439 | self.run_until_complete(client.start()) 440 | client.publish(mqttools.Message('/foo', b'apa')) 441 | client.publish(mqttools.Message('/bar', b'cat')) 442 | self.run_until_complete(client.stop()) 443 | 444 | def test_connack_unspecified_error(self): 445 | Broker.EXPECTED_DATA_STREAM = [ 446 | # CONNECT 447 | ('c2s', b'\x10\x10\x00\x04MQTT\x05\x02\x00\x00\x00\x00\x03bar'), 448 | # CONNACK with unspecified error 449 | ('s2c', b'\x20\x03\x00\x80\x00') 450 | ] 451 | 452 | client = mqttools.Client(*self.broker.address, 453 | 'bar', 454 | topic_alias_maximum=0, 455 | connect_delays=[], 456 | keep_alive_s=0) 457 | 458 | with self.assertRaises(mqttools.ConnectError) as cm: 459 | self.run_until_complete(client.start()) 460 | 461 | self.assertEqual(str(cm.exception), 'UNSPECIFIED_ERROR(128)') 462 | 463 | def test_receive_topic_alias(self): 464 | Broker.EXPECTED_DATA_STREAM = [ 465 | # CONNECT with topic alias 5 466 | ( 467 | 'c2s', 468 | b'\x10\x13\x00\x04MQTT\x05\x02\x00\x00\x03\x22\x00\x05\x00\x03bar' 469 | ), 470 | # CONNACK 471 | ('s2c', b'\x20\x03\x00\x00\x00'), 472 | # SUBSCRIBE 473 | ('c2s', b'\x82\x18\x00\x01\x00\x00\x12/test/mqttools/foo\x00'), 474 | # SUBACK 475 | ('s2c', b'\x90\x04\x00\x01\x00\x00'), 476 | # PUBLISH using an unknown alias 1 477 | ( 478 | 's2c', 479 | b'\x30\x22\x00\x00\x03\x23\x00\x01published-with-unknown-alias' 480 | ), 481 | # PUBLISH using alias an invalid alias 6 482 | ( 483 | 's2c', 484 | b'\x30\x34\x00\x12/test/mqttools/foo\x03\x23\x00\x06' 485 | b'sets-invalid-alias-in-client' 486 | ), 487 | 488 | # PUBLISH to set alias 489 | ( 490 | 's2c', 491 | b'\x30\x2c\x00\x12/test/mqttools/foo\x03\x23\x00\x01' 492 | b'sets-alias-in-client' 493 | ), 494 | # PUBLISH using alias 495 | ('s2c', b'\x30\x1a\x00\x00\x03\x23\x00\x01published-with-alias'), 496 | # DISCONNECT 497 | ('c2s', b'\xe0\x02\x00\x00') 498 | ] 499 | 500 | client = mqttools.Client(*self.broker.address, 501 | 'bar', 502 | topic_alias_maximum=5, 503 | keep_alive_s=0) 504 | self.run_until_complete(client.start()) 505 | self.run_until_complete(client.subscribe('/test/mqttools/foo')) 506 | message = self.run_until_complete(client.messages.get()) 507 | self.assertEqual(message.topic, '/test/mqttools/foo') 508 | self.assertEqual(message.message, b'sets-alias-in-client') 509 | message = self.run_until_complete(client.messages.get()) 510 | self.assertEqual(message.topic, '/test/mqttools/foo') 511 | self.assertEqual(message.message, b'published-with-alias') 512 | self.run_until_complete(client.stop()) 513 | 514 | def test_resume_session(self): 515 | Broker.EXPECTED_DATA_STREAM = [ 516 | # CONNECT with clean session 0 (to resume) and session 517 | # expiry interval 120. 518 | ( 519 | 'c2s', 520 | b'\x10\x15\x00\x04MQTT\x05\x00\x00\x00\x05\x11\x00\x00\x00\x78' 521 | b'\x00\x03bar' 522 | ), 523 | # CONNACK with no session present 524 | ('s2c', b'\x20\x03\x00\x00\x00'), 525 | # DISCONNECT 526 | ('c2s', b'\xe0\x02\x00\x00'), 527 | # CONNECT with clean session 0 (to resume) and session 528 | # expiry interval 120. 529 | ( 530 | 'c2s', 531 | b'\x10\x15\x00\x04MQTT\x05\x00\x00\x00\x05\x11\x00\x00\x00\x78' 532 | b'\x00\x03bar' 533 | ), 534 | # CONNACK with session present 535 | ('s2c', b'\x20\x03\x01\x00\x00'), 536 | # DISCONNECT 537 | ('c2s', b'\xe0\x02\x00\x00') 538 | ] 539 | 540 | client = mqttools.Client(*self.broker.address, 541 | 'bar', 542 | session_expiry_interval=120, 543 | topic_alias_maximum=0, 544 | connect_delays=[], 545 | keep_alive_s=0) 546 | 547 | with self.assertRaises(mqttools.SessionResumeError): 548 | self.run_until_complete(client.start(resume_session=True)) 549 | 550 | self.run_until_complete(client.stop()) 551 | self.broker.wait_for_client_closed() 552 | self.run_until_complete(client.start(resume_session=True)) 553 | self.run_until_complete(client.stop()) 554 | 555 | 556 | logging.basicConfig(level=logging.DEBUG) 557 | 558 | 559 | if __name__ == '__main__': 560 | unittest.main() 561 | -------------------------------------------------------------------------------- /tests/test_pack_unpack.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mqttools.common import MalformedPacketError 4 | from mqttools.common import PayloadReader 5 | from mqttools.common import PropertyIds 6 | from mqttools.common import pack_binary 7 | from mqttools.common import pack_connect 8 | from mqttools.common import pack_string 9 | from mqttools.common import pack_u8 10 | from mqttools.common import pack_u16 11 | from mqttools.common import pack_u32 12 | from mqttools.common import pack_variable_integer 13 | from mqttools.common import unpack_connect 14 | from mqttools.common import unpack_properties 15 | 16 | 17 | class PackUnpackTest(unittest.TestCase): 18 | 19 | def test_pack_unpack_connect(self): 20 | datas = [ 21 | ( 22 | ('client-id', True, '', b'', None, 0, None, None, 0, {}), 23 | b'\x10\x16\x00\x04MQTT\x05\x02\x00\x00\x00\x00\tclient-id', 24 | ('client-id', True, None, None, None, 0, {}, None, None) 25 | ), 26 | ( 27 | ('abc', True, 'foo', b'bar', False, 0, None, None, 600, {}), 28 | b'\x10\x1b\x00\x04MQTT\x05\x06\x02X\x00\x00\x03abc\x00\x00\x03' 29 | b'foo\x00\x03bar', 30 | ('abc', True, 'foo', b'bar', False, 600, {}, None, None) 31 | ), 32 | ( 33 | ('abc', True, 'foo2', b'bar', True, 1, None, None, 3600, {}), 34 | b'\x10\x1c\x00\x04MQTT\x05\x2e\x0e\x10\x00\x00\x03abc\x00\x00' 35 | b'\x04foo2\x00\x03bar', 36 | ('abc', True, 'foo2', b'bar', True, 3600, {}, None, None) 37 | ), 38 | ( 39 | ('abc', True, 'foo', b'bar', True, 1, "user", b"pass", 0, {}), 40 | b'\x10\'\x00\x04MQTT\x05\xee\x00\x00\x00\x00\x03abc\x00\x00' 41 | b'\x03foo\x00\x03bar\x00\x04user\x00\x04pass', 42 | ('abc', True, 'foo', b'bar', True, 0, {}, 'user', b'pass') 43 | ) 44 | ] 45 | 46 | for args, expected_packed, expected_unpacked in datas: 47 | packed = pack_connect(*args) 48 | self.assertEqual(packed, expected_packed) 49 | unpacked = unpack_connect(PayloadReader(packed[2:])) 50 | self.assertEqual(unpacked, expected_unpacked) 51 | 52 | def test_unpack_properties(self): 53 | buf = pack_u8(PropertyIds.PAYLOAD_FORMAT_INDICATOR) 54 | buf += pack_u8(5) 55 | buf += pack_u8(PropertyIds.MESSAGE_EXPIRY_INTERVAL) 56 | buf += pack_u32(6) 57 | buf += pack_u8(PropertyIds.CONTENT_TYPE) 58 | buf += pack_string('a') 59 | buf += pack_u8(PropertyIds.RESPONSE_TOPIC) 60 | buf += pack_string('b') 61 | buf += pack_u8(PropertyIds.CORRELATION_DATA) 62 | buf += pack_binary(b'c') 63 | buf += pack_u8(PropertyIds.SUBSCRIPTION_IDENTIFIER) 64 | buf += pack_variable_integer(7) 65 | buf += pack_u8(PropertyIds.SESSION_EXPIRY_INTERVAL) 66 | buf += pack_u32(8) 67 | buf += pack_u8(PropertyIds.ASSIGNED_CLIENT_IDENTIFIER) 68 | buf += pack_string('d') 69 | buf += pack_u8(PropertyIds.SERVER_KEEP_ALIVE) 70 | buf += pack_u16(9) 71 | buf += pack_u8(PropertyIds.AUTHENTICATION_METHOD) 72 | buf += pack_string('e') 73 | buf += pack_u8(PropertyIds.AUTHENTICATION_DATA) 74 | buf += pack_binary(b'f') 75 | buf += pack_u8(PropertyIds.REQUEST_PROBLEM_INFORMATION) 76 | buf += pack_u8(10) 77 | buf += pack_u8(PropertyIds.WILL_DELAY_INTERVAL) 78 | buf += pack_u32(11) 79 | buf += pack_u8(PropertyIds.REQUEST_RESPONSE_INFORMATION) 80 | buf += pack_u8(12) 81 | buf += pack_u8(PropertyIds.RESPONSE_INFORMATION) 82 | buf += pack_string('g') 83 | buf += pack_u8(PropertyIds.SERVER_REFERENCE) 84 | buf += pack_string('h') 85 | buf += pack_u8(PropertyIds.REASON_STRING) 86 | buf += pack_string('i') 87 | buf += pack_u8(PropertyIds.RECEIVE_MAXIMUM) 88 | buf += pack_u16(13) 89 | buf += pack_u8(PropertyIds.TOPIC_ALIAS_MAXIMUM) 90 | buf += pack_u16(14) 91 | buf += pack_u8(PropertyIds.TOPIC_ALIAS) 92 | buf += pack_u16(15) 93 | buf += pack_u8(PropertyIds.MAXIMUM_QOS) 94 | buf += pack_u8(16) 95 | buf += pack_u8(PropertyIds.RETAIN_AVAILABLE) 96 | buf += pack_u8(17) 97 | buf += pack_u8(PropertyIds.USER_PROPERTY) 98 | buf += pack_string('j') 99 | buf += pack_u8(PropertyIds.MAXIMUM_PACKET_SIZE) 100 | buf += pack_u32(18) 101 | buf += pack_u8(PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE) 102 | buf += pack_u8(19) 103 | buf += pack_u8(PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE) 104 | buf += pack_u8(20) 105 | buf += pack_u8(PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE) 106 | buf += pack_u8(21) 107 | buf = pack_variable_integer(len(buf)) + buf 108 | 109 | properties = unpack_properties( 110 | 'FOO', 111 | [ 112 | PropertyIds.PAYLOAD_FORMAT_INDICATOR, 113 | PropertyIds.MESSAGE_EXPIRY_INTERVAL, 114 | PropertyIds.CONTENT_TYPE, 115 | PropertyIds.RESPONSE_TOPIC, 116 | PropertyIds.CORRELATION_DATA, 117 | PropertyIds.SUBSCRIPTION_IDENTIFIER, 118 | PropertyIds.SESSION_EXPIRY_INTERVAL, 119 | PropertyIds.ASSIGNED_CLIENT_IDENTIFIER, 120 | PropertyIds.SERVER_KEEP_ALIVE, 121 | PropertyIds.AUTHENTICATION_METHOD, 122 | PropertyIds.AUTHENTICATION_DATA, 123 | PropertyIds.REQUEST_PROBLEM_INFORMATION, 124 | PropertyIds.WILL_DELAY_INTERVAL, 125 | PropertyIds.REQUEST_RESPONSE_INFORMATION, 126 | PropertyIds.RESPONSE_INFORMATION, 127 | PropertyIds.SERVER_REFERENCE, 128 | PropertyIds.REASON_STRING, 129 | PropertyIds.RECEIVE_MAXIMUM, 130 | PropertyIds.TOPIC_ALIAS_MAXIMUM, 131 | PropertyIds.TOPIC_ALIAS, 132 | PropertyIds.MAXIMUM_QOS, 133 | PropertyIds.RETAIN_AVAILABLE, 134 | PropertyIds.USER_PROPERTY, 135 | PropertyIds.MAXIMUM_PACKET_SIZE, 136 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE, 137 | PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE, 138 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE 139 | ], 140 | PayloadReader(buf)) 141 | 142 | self.assertEqual( 143 | properties, 144 | { 145 | PropertyIds.PAYLOAD_FORMAT_INDICATOR: 5, 146 | PropertyIds.MESSAGE_EXPIRY_INTERVAL: 6, 147 | PropertyIds.CONTENT_TYPE: 'a', 148 | PropertyIds.RESPONSE_TOPIC: 'b', 149 | PropertyIds.CORRELATION_DATA: b'c', 150 | PropertyIds.SUBSCRIPTION_IDENTIFIER: 7, 151 | PropertyIds.SESSION_EXPIRY_INTERVAL: 8, 152 | PropertyIds.ASSIGNED_CLIENT_IDENTIFIER: 'd', 153 | PropertyIds.SERVER_KEEP_ALIVE: 9, 154 | PropertyIds.AUTHENTICATION_METHOD: 'e', 155 | PropertyIds.AUTHENTICATION_DATA: b'f', 156 | PropertyIds.REQUEST_PROBLEM_INFORMATION: 10, 157 | PropertyIds.WILL_DELAY_INTERVAL: 11, 158 | PropertyIds.REQUEST_RESPONSE_INFORMATION: 12, 159 | PropertyIds.RESPONSE_INFORMATION: 'g', 160 | PropertyIds.SERVER_REFERENCE: 'h', 161 | PropertyIds.REASON_STRING: 'i', 162 | PropertyIds.RECEIVE_MAXIMUM: 13, 163 | PropertyIds.TOPIC_ALIAS_MAXIMUM: 14, 164 | PropertyIds.TOPIC_ALIAS: 15, 165 | PropertyIds.MAXIMUM_QOS: 16, 166 | PropertyIds.RETAIN_AVAILABLE: 17, 167 | PropertyIds.USER_PROPERTY: 'j', 168 | PropertyIds.MAXIMUM_PACKET_SIZE: 18, 169 | PropertyIds.WILDCARD_SUBSCRIPTION_AVAILABLE: 19, 170 | PropertyIds.SUBSCRIPTION_IDENTIFIER_AVAILABLE: 20, 171 | PropertyIds.SHARED_SUBSCRIPTION_AVAILABLE: 21 172 | }) 173 | 174 | def test_unpack_empty_properties(self): 175 | with self.assertRaises(MalformedPacketError): 176 | unpack_properties('FOO', 177 | [ 178 | PropertyIds.PAYLOAD_FORMAT_INDICATOR 179 | ], 180 | PayloadReader(b'')) 181 | 182 | def test_unpack_invalid_property(self): 183 | with self.assertRaises(MalformedPacketError): 184 | unpack_properties('FOO', 185 | [ 186 | PropertyIds.PAYLOAD_FORMAT_INDICATOR 187 | ], 188 | PayloadReader(b'\x01\xff')) 189 | 190 | def test_unpack_invalid_property_for_packet(self): 191 | with self.assertRaises(MalformedPacketError): 192 | unpack_properties('FOO', 193 | [ 194 | PropertyIds.PAYLOAD_FORMAT_INDICATOR 195 | ], 196 | PayloadReader(b'\x01\x02\x00\x00\x00\x00')) 197 | 198 | 199 | if __name__ == '__main__': 200 | unittest.main() 201 | -------------------------------------------------------------------------------- /tests/test_reconnect.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import queue 4 | import socket 5 | import threading 6 | import unittest 7 | 8 | import mqttools 9 | 10 | HOST = 'localhost' 11 | PORT = 0 12 | 13 | 14 | class Client(threading.Thread): 15 | 16 | def __init__(self, host, port): 17 | super().__init__() 18 | self._host = host 19 | self._port = port 20 | self.messages = [] 21 | self.done = threading.Event() 22 | 23 | async def client(self): 24 | client = mqttools.Client(self._host, 25 | self._port, 26 | 'goo', 27 | response_timeout=1, 28 | topic_alias_maximum=0, 29 | keep_alive_s=1) 30 | 31 | for _ in range(6): 32 | await client.start() 33 | self.messages.append(await client.messages.get()) 34 | await client.stop() 35 | 36 | def run(self): 37 | asyncio.run(self.client()) 38 | self.done.set() 39 | 40 | 41 | class ReconnectTest(unittest.TestCase): 42 | 43 | def test_reconnect(self): 44 | listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 45 | listener.bind((HOST, PORT)) 46 | listener.listen() 47 | 48 | client_thread = Client(*listener.getsockname()[:2]) 49 | client_thread.daemon = True 50 | client_thread.start() 51 | 52 | client, _ = listener.accept() 53 | 54 | # CONNECT 55 | self.assertEqual( 56 | client.recv(18), 57 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 58 | # CONNACK 59 | self.assertEqual(client.send(b'\x20\x03\x00\x00\x00'), 5) 60 | # PINGREQ 61 | self.assertEqual(client.recv(2), b'\xc0\x00') 62 | # PINGRESP 63 | self.assertEqual(client.send(b'\xd0\x00'), 2) 64 | 65 | # Connection closed by the broker. 66 | client.close() 67 | 68 | # Wait for another connection. 69 | client, _ = listener.accept() 70 | 71 | # CONNECT 72 | self.assertEqual( 73 | client.recv(18), 74 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 75 | # CONNACK 76 | self.assertEqual(client.send(b'\x20\x03\x00\x00\x00'), 5) 77 | # PINGREQ 78 | self.assertIn(client.recv(2), [b'', b'\xc0\x00']) 79 | 80 | # Don't respond to the ping request and wait for another 81 | # connection. 82 | client2, _ = listener.accept() 83 | client.close() 84 | 85 | # CONNECT 86 | self.assertEqual( 87 | client2.recv(18), 88 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 89 | # CONNACK 90 | self.assertEqual(client2.send(b'\x20\x03\x00\x00\x00'), 5) 91 | 92 | client2.close() 93 | 94 | # Wait for another connection. 95 | client, _ = listener.accept() 96 | 97 | # CONNECT 98 | self.assertEqual( 99 | client.recv(18), 100 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 101 | # CONNACK 102 | self.assertEqual(client.send(b'\x20\x03\x00\x00\x00'), 5) 103 | # DISCONNECT 104 | self.assertEqual( 105 | client.send(b'\xe0\x0f\x80\x0d\x1f\x00\x0aSome error'), 106 | 17) 107 | 108 | # Wait for another connection. 109 | client2, _ = listener.accept() 110 | client.close() 111 | 112 | # CONNECT 113 | self.assertEqual( 114 | client2.recv(18), 115 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 116 | # CONNACK 117 | self.assertEqual(client2.send(b'\x20\x03\x00\x00\x00'), 5) 118 | # PUBLISH malformed packet with non-UTF-8 topic. 119 | self.assertEqual(client2.send(b'\x30\x07\x00\x01\xff\x00apa'), 9) 120 | # DISCONNECT with reason malformed packet 121 | self.assertEqual(client2.recv(4), b'\xe0\x02\x81\x00') 122 | 123 | # Wait for another connection. 124 | client, _ = listener.accept() 125 | client2.close() 126 | 127 | # CONNECT 128 | self.assertEqual( 129 | client.recv(18), 130 | b'\x10\x10\x00\x04MQTT\x05\x02\x00\x01\x00\x00\x03goo') 131 | # CONNACK 132 | self.assertEqual(client.send(b'\x20\x03\x00\x00\x00'), 5) 133 | 134 | client.close() 135 | listener.close() 136 | client_thread.done.wait() 137 | self.assertEqual(client_thread.messages, 6 * [None]) 138 | 139 | 140 | logging.basicConfig(level=logging.DEBUG) 141 | 142 | 143 | if __name__ == '__main__': 144 | unittest.main() 145 | -------------------------------------------------------------------------------- /tests/test_request_respose.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | 4 | import mqttools 5 | 6 | from .utils import get_broker_address 7 | 8 | 9 | class RequestResponseTest(unittest.TestCase): 10 | 11 | def create_broker(self): 12 | broker = mqttools.Broker(("localhost", 0)) 13 | 14 | async def broker_wrapper(): 15 | with self.assertRaises(asyncio.CancelledError): 16 | await broker.serve_forever() 17 | 18 | return broker, asyncio.create_task(broker_wrapper()) 19 | 20 | def test_request_response(self): 21 | asyncio.run(self.request_response()) 22 | 23 | async def request_response(self): 24 | broker, broker_task = self.create_broker() 25 | 26 | host, port = await get_broker_address(broker) 27 | 28 | request_topic = "/a" 29 | response_topic = "/b" 30 | request = b"ping" 31 | response = b"pong" 32 | 33 | requester = mqttools.Client(host, port) 34 | await requester.start() 35 | await requester.subscribe(response_topic) 36 | 37 | responder = mqttools.Client(host, port) 38 | await responder.start() 39 | await responder.subscribe(request_topic) 40 | 41 | requester.publish(mqttools.Message(request_topic, 42 | request, 43 | True, 44 | response_topic)) 45 | 46 | message = await responder.messages.get() 47 | self.assertEqual(message.topic, request_topic) 48 | responder.publish(mqttools.Message(message.response_topic, response)) 49 | 50 | message = await requester.messages.get() 51 | self.assertEqual(message.topic, response_topic) 52 | self.assertEqual(message.message, response) 53 | 54 | broker_task.cancel() 55 | await asyncio.wait_for(broker_task, 1) 56 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | async def get_broker_address(broker): 2 | # IPv6 gives 4-tuple, need only host and port. 3 | address_tuple = await broker.getsockname() 4 | 5 | return address_tuple[:2] 6 | --------------------------------------------------------------------------------