diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..21c125c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +.py text eol=lf +.rst text eol=lf +.txt text eol=lf +.yaml text eol=lf +.toml text eol=lf +.license text eol=lf +.md text eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md index 71ef8f89..8de294e6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md +++ b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md @@ -4,7 +4,7 @@ Thank you for contributing! Before you submit a pull request, please read the following. -Make sure any changes you're submitting are in line with the CircuitPython Design Guide, available here: https://circuitpython.readthedocs.io/en/latest/docs/design_guide.html +Make sure any changes you're submitting are in line with the CircuitPython Design Guide, available here: https://docs.circuitpython.org/en/latest/docs/design_guide.html If your changes are to documentation, please verify that the documentation builds locally by following the steps found here: https://adafru.it/build-docs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 860cc368..041a337c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,71 +10,5 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Translate Repo Name For Build Tools filename_prefix - id: repo-name - run: | - echo ::set-output name=repo-name::$( - echo ${{ github.repository }} | - awk -F '\/' '{ print tolower($2) }' | - tr '_' '-' - ) - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - name: Versions - run: | - python3 --version - - name: Checkout Current Repo - uses: actions/checkout@v1 - with: - submodules: true - - name: Checkout tools repo - uses: actions/checkout@v2 - with: - repository: adafruit/actions-ci-circuitpython-libs - path: actions-ci - - name: Install dependencies - # (e.g. - apt-get: gettext, etc; pip: circuitpython-build-tools, requirements.txt; etc.) - run: | - source actions-ci/install.sh - - name: Pip install Sphinx, pre-commit - run: | - pip install --force-reinstall Sphinx sphinx-rtd-theme pre-commit - - name: Library version - run: git describe --dirty --always --tags - - name: Pre-commit hooks - run: | - pre-commit run --all-files - - name: Build assets - run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location . - - name: Archive bundles - uses: actions/upload-artifact@v2 - with: - name: bundles - path: ${{ github.workspace }}/bundles/ - - name: Check For docs folder - id: need-docs - run: | - echo ::set-output name=docs::$( find . -wholename './docs' ) - - name: Build docs - if: contains(steps.need-docs.outputs.docs, 'docs') - working-directory: docs - run: sphinx-build -E -W -b html . _build/html - - name: Check For setup.py - id: need-pypi - run: | - echo ::set-output name=setup-py::$( find . -wholename './setup.py' ) - - name: Build Python package - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - run: | - pip install --upgrade setuptools wheel twine readme_renderer testresources - python setup.py sdist - python setup.py bdist_wheel --universal - twine check dist/* - - name: Setup problem matchers - uses: adafruit/circuitpython-action-library-ci-problem-matchers@v1 + - name: Run Build CI workflow + uses: adafruit/workflows-circuitpython-libs/build@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 6d0015a6..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,85 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# -# SPDX-License-Identifier: MIT - -name: Release Actions - -on: - release: - types: [published] - -jobs: - upload-release-assets: - runs-on: ubuntu-latest - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Translate Repo Name For Build Tools filename_prefix - id: repo-name - run: | - echo ::set-output name=repo-name::$( - echo ${{ github.repository }} | - awk -F '\/' '{ print tolower($2) }' | - tr '_' '-' - ) - - name: Set up Python 3.6 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Versions - run: | - python3 --version - - name: Checkout Current Repo - uses: actions/checkout@v1 - with: - submodules: true - - name: Checkout tools repo - uses: actions/checkout@v2 - with: - repository: adafruit/actions-ci-circuitpython-libs - path: actions-ci - - name: Install deps - run: | - source actions-ci/install.sh - - name: Build assets - run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location . - - name: Upload Release Assets - # the 'official' actions version does not yet support dynamically - # supplying asset names to upload. @csexton's version chosen based on - # discussion in the issue below, as its the simplest to implement and - # allows for selecting files with a pattern. - # https://github.com/actions/upload-release-asset/issues/4 - #uses: actions/upload-release-asset@v1.0.1 - uses: csexton/release-asset-action@master - with: - pattern: "bundles/*" - github-token: ${{ secrets.GITHUB_TOKEN }} - - upload-pypi: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: Check For setup.py - id: need-pypi - run: | - echo ::set-output name=setup-py::$( find . -wholename './setup.py' ) - - name: Set up Python - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - name: Install dependencies - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - env: - TWINE_USERNAME: ${{ secrets.pypi_username }} - TWINE_PASSWORD: ${{ secrets.pypi_password }} - run: | - python setup.py sdist - twine upload dist/* diff --git a/.github/workflows/release_gh.yml b/.github/workflows/release_gh.yml new file mode 100644 index 00000000..9acec601 --- /dev/null +++ b/.github/workflows/release_gh.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +name: GitHub Release Actions + +on: + release: + types: [published] + +jobs: + upload-release-assets: + runs-on: ubuntu-latest + steps: + - name: Run GitHub Release CI workflow + uses: adafruit/workflows-circuitpython-libs/release-gh@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + upload-url: ${{ github.event.release.upload_url }} diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml new file mode 100644 index 00000000..65775b7b --- /dev/null +++ b/.github/workflows/release_pypi.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +name: PyPI Release Actions + +on: + release: + types: [published] + +jobs: + upload-release-assets: + runs-on: ubuntu-latest + steps: + - name: Run PyPI Release CI workflow + uses: adafruit/workflows-circuitpython-libs/release-pypi@main + with: + pypi-username: ${{ secrets.pypi_username }} + pypi-password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index 9647e712..a06dc67a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,55 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Kattni Rembor, written for Adafruit Industries # -# SPDX-License-Identifier: Unlicense +# SPDX-License-Identifier: MIT +# Do not include files and directories created by your personal work environment, such as the IDE +# you use, except for those already listed here. Pull requests including changes to this file will +# not be accepted. + +# This .gitignore file contains rules for files generated by working with CircuitPython libraries, +# including building Sphinx, testing with pip, and creating a virual environment, as well as the +# MacOS and IDE-specific files generated by using MacOS in general, or the PyCharm or VSCode IDEs. + +# If you find that there are files being generated on your machine that should not be included in +# your git commit, you should create a .gitignore_global file on your computer to include the +# files created by your personal setup. To do so, follow the two steps below. + +# First, create a file called .gitignore_global somewhere convenient for you, and add rules for +# the files you want to exclude from git commits. + +# Second, configure Git to use the exclude file for all Git repositories by running the +# following via commandline, replacing "path/to/your/" with the actual path to your newly created +# .gitignore_global file: +# git config --global core.excludesfile path/to/your/.gitignore_global + +# CircuitPython-specific files *.mpy -.idea + +# Python-specific files __pycache__ -_build *.pyc + +# Sphinx build-specific files +_build + +# This file results from running `pip -e install .` in a local repository +*.egg-info + +# Virtual environment-specific files .env -bundles +.venv + +# MacOS-specific files *.DS_Store -.eggs -dist -**/*.egg-info + +# IDE-specific files +.idea +.vscode +*~ + +# tox-specific files +.tox +build + +# coverage-specific files +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1b9fadc5..029d1e95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,22 @@ # SPDX-FileCopyrightText: 2020 Diego Elio Pettenò +# SPDX-FileCopyrightText: 2024 Justin Myers # # SPDX-License-Identifier: Unlicense repos: -- repo: https://github.com/python/black - rev: 20.8b1 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 hooks: - - id: black -- repo: https://github.com/fsfe/reuse-tool - rev: v0.12.1 + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.9 hooks: - - id: reuse -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + - id: ruff + args: ["--fix"] + - id: ruff-format + - repo: https://github.com/fsfe/reuse-tool + rev: v3.0.1 hooks: - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/pycqa/pylint - rev: v2.11.1 - hooks: - - id: pylint - name: pylint (library code) - types: [python] - args: - - --disable=consider-using-f-string - exclude: "^(docs/|examples/|tests/|setup.py$)" - - id: pylint - name: pylint (example code) - description: Run pylint rules on "examples/*.py" files - types: [python] - files: "^examples/" - args: - - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code - - id: pylint - name: pylint (test code) - description: Run pylint rules on "tests/*.py" files - types: [python] - files: "^tests/" - args: - - --disable=missing-docstring,consider-using-f-string,duplicate-code + - id: reuse diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index e78bad2f..00000000 --- a/.pylintrc +++ /dev/null @@ -1,436 +0,0 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# disable=import-error,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call -disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,import-error,bad-continuation - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -# notes=FIXME,XXX,TODO -notes=FIXME,XXX - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=board - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -# expected-line-ending-format= -expected-line-ending-format=LF - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming hint for argument names -argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for attribute names -attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class names -# class-name-hint=[A-Z_][a-zA-Z0-9]+$ -class-name-hint=[A-Z_][a-zA-Z0-9_]+$ - -# Regular expression matching correct class names -# class-rgx=[A-Z_][a-zA-Z0-9]+$ -class-rgx=[A-Z_][a-zA-Z0-9_]+$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming hint for function names -function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -# good-names=i,j,k,ex,Run,_ -good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_ - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for method names -method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Naming hint for variable names -variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Maximum number of attributes for a class (see R0902). -# max-attributes=7 -max-attributes=11 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..255dafd2 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +sphinx: + configuration: docs/conf.py + +build: + os: ubuntu-lts-latest + tools: + python: "3" + +python: + install: + - requirements: docs/requirements.txt + - requirements: requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 49dcab30..00000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -python: - version: 3 -requirements_file: docs/requirements.txt diff --git a/README.rst b/README.rst index c3db2aa3..a17dcfd7 100644 --- a/README.rst +++ b/README.rst @@ -2,10 +2,10 @@ Introduction ============ .. image:: https://readthedocs.org/projects/adafruit-circuitpython-minimqtt/badge/?version=latest - :target: https://circuitpython.readthedocs.io/projects/minimqtt/en/latest/ + :target: https://docs.circuitpython.org/projects/minimqtt/en/latest/ :alt: Documentation Status -.. image:: https://img.shields.io/discord/327254708534116352.svg +.. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg :target: https://adafru.it/discord :alt: Discord @@ -13,6 +13,10 @@ Introduction :target: https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT/actions/ :alt: Build Status +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Code Style: Ruff + MQTT Client library for CircuitPython. Dependencies @@ -20,6 +24,7 @@ Dependencies This driver depends on: * `Adafruit CircuitPython `_ +* `Adafruit CircuitPython ConnectionManager `_ Please ensure all dependencies are available on the CircuitPython filesystem. This is easily achieved by downloading @@ -45,8 +50,8 @@ To install in a virtual environment in your current project: .. code-block:: shell mkdir project-name && cd project-name - python3 -m venv .env - source .env/bin/activate + python3 -m venv .venv + source .venv/bin/activate pip3 install adafruit-circuitpython-minimqtt Usage Example @@ -58,7 +63,9 @@ for usage examples for this library. Documentation ============= -API documentation for this library can be found on `Read the Docs `_. +API documentation for this library can be found on `Read the Docs `_. + +For information on building library documentation, please check out `this guide `_. Contributing ============ @@ -66,8 +73,3 @@ Contributing Contributions are welcome! Please read our `Code of Conduct `_ before contributing to help this project stay welcoming. - -Documentation -============= - -For information on building library documentation, please check out `this guide `_. diff --git a/adafruit_minimqtt/__init__.py b/adafruit_minimqtt/__init__.py old mode 100755 new mode 100644 diff --git a/adafruit_minimqtt/adafruit_minimqtt.py b/adafruit_minimqtt/adafruit_minimqtt.py old mode 100755 new mode 100644 index fd52a22b..2a1d6000 --- a/adafruit_minimqtt/adafruit_minimqtt.py +++ b/adafruit_minimqtt/adafruit_minimqtt.py @@ -24,15 +24,36 @@ * Adafruit CircuitPython firmware for the supported boards: https://github.com/adafruit/circuitpython/releases +* Adafruit's Connection Manager library: + https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager + """ + +# ruff: noqa: PLR6104,PLR6201,PLR6301 non-augmented-assignment,literal-membership,no-self-use + import errno import struct import time from random import randint + +from adafruit_connection_manager import get_connection_manager +from adafruit_ticks import ticks_diff, ticks_ms + +try: + from typing import List, Optional, Tuple, Type, Union +except ImportError: + pass + +try: + from types import TracebackType +except ImportError: + pass + from micropython import const + from .matcher import MQTTMatcher -__version__ = "0.0.0-auto.0" +__version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT.git" # Client-specific variables @@ -45,116 +66,138 @@ # MQTT Commands MQTT_PINGREQ = b"\xc0\0" MQTT_PINGRESP = const(0xD0) -MQTT_SUB = b"\x82" -MQTT_UNSUB = b"\xA2" +MQTT_PUBLISH = const(0x30) +MQTT_SUB = const(0x82) +MQTT_SUBACK = const(0x90) +MQTT_UNSUB = const(0xA2) +MQTT_UNSUBACK = const(0xB0) MQTT_DISCONNECT = b"\xe0\0" -# Variable CONNECT header [MQTT 3.1.2] -MQTT_HDR_CONNECT = bytearray(b"\x04MQTT\x04\x02\0\0") +MQTT_PKT_TYPE_MASK = const(0xF0) +CONNACK_ERROR_INCORRECT_PROTOCOL = const(0x01) +CONNACK_ERROR_ID_REJECTED = const(0x02) +CONNACK_ERROR_SERVER_UNAVAILABLE = const(0x03) +CONNACK_ERROR_INCORECT_USERNAME_PASSWORD = const(0x04) +CONNACK_ERROR_UNAUTHORIZED = const(0x05) + CONNACK_ERRORS = { - const(0x01): "Connection Refused - Incorrect Protocol Version", - const(0x02): "Connection Refused - ID Rejected", - const(0x03): "Connection Refused - Server unavailable", - const(0x04): "Connection Refused - Incorrect username/password", - const(0x05): "Connection Refused - Unauthorized", + CONNACK_ERROR_INCORRECT_PROTOCOL: "Connection Refused - Incorrect Protocol Version", + CONNACK_ERROR_ID_REJECTED: "Connection Refused - ID Rejected", + CONNACK_ERROR_SERVER_UNAVAILABLE: "Connection Refused - Server unavailable", + CONNACK_ERROR_INCORECT_USERNAME_PASSWORD: "Connection Refused - Incorrect username/password", + CONNACK_ERROR_UNAUTHORIZED: "Connection Refused - Unauthorized", } -_default_sock = None # pylint: disable=invalid-name -_fake_context = None # pylint: disable=invalid-name +_default_sock = None +_fake_context = None class MMQTTException(Exception): - """MiniMQTT Exception class.""" + """ + MiniMQTT Exception class. - # pylint: disable=unnecessary-pass - # pass + Raised for various mostly protocol or network/system level errors. + In general, the robust way to recover is to call reconnect(). + """ + def __init__(self, error, code=None): + super().__init__(error, code) + self.code = code -# Legacy ESP32SPI Socket API -def set_socket(sock, iface=None): - """Legacy API for setting the socket and network interface. - :param sock: socket object. - :param iface: internet interface object +class MMQTTStateError(MMQTTException): + """ + MiniMQTT invalid state error. + + Raised e.g. if a function is called in unexpected state. """ - global _default_sock # pylint: disable=invalid-name, global-statement - global _fake_context # pylint: disable=invalid-name, global-statement - _default_sock = sock - if iface: - _default_sock.set_interface(iface) - _fake_context = _FakeSSLContext(iface) - - -class _FakeSSLSocket: - def __init__(self, socket, tls_mode): - self._socket = socket - self._mode = tls_mode - self.settimeout = socket.settimeout - self.send = socket.send - self.recv = socket.recv - self.close = socket.close - - def connect(self, address): - """connect wrapper to add non-standard mode parameter""" - try: - return self._socket.connect(address, self._mode) - except RuntimeError as error: - raise OSError(errno.ENOMEM) from error -class _FakeSSLContext: - def __init__(self, iface): - self._iface = iface +class NullLogger: + """Fake logger class that does not do anything""" - def wrap_socket(self, socket, server_hostname=None): - """Return the same socket""" - # pylint: disable=unused-argument - return _FakeSSLSocket(socket, self._iface.TLS_MODE) + def nothing(self, msg: str, *args) -> None: + """no action""" + def __init__(self) -> None: + for log_level in ["debug", "info", "warning", "error", "critical"]: + setattr(NullLogger, log_level, self.nothing) -class MQTT: + +class MQTT: # noqa: PLR0904 # too-many-public-methods """MQTT Client for CircuitPython. + :param str broker: MQTT Broker URL or IP Address. - :param int port: Optional port definition, defaults to 8883. + :param int port: Optional port definition, defaults to MQTT_TLS_PORT if is_ssl is True, + MQTT_TCP_PORT otherwise. :param str username: Username for broker authentication. :param str password: Password for broker authentication. - :param network_manager: NetworkManager object, such as WiFiManager from ESPSPI_WiFiManager. :param str client_id: Optional client identifier, defaults to a unique, generated string. :param bool is_ssl: Sets a secure or insecure connection with the broker. :param int keep_alive: KeepAlive interval between the broker and the MiniMQTT client. + :param int recv_timeout: receive timeout, in seconds. :param socket socket_pool: A pool of socket resources available for the given radio. :param ssl_context: SSL context for long-lived SSL connections. + :param bool use_binary_mode: Messages are passed as bytearray instead of string to callbacks. + :param int socket_timeout: How often to check socket state for read/write/connect operations, + in seconds. + :param int connect_retries: How many times to try to connect to the broker before giving up + on connect or reconnect. Exponential backoff will be used for the retries. + :param class user_data: arbitrary data to pass as a second argument to most of the callbacks. + This works with all callbacks but the "on_message" and those added via add_topic_callback(); + for those, to get access to the user_data use the 'user_data' member of the MQTT object + passed as 1st argument. """ - # pylint: disable=too-many-arguments,too-many-instance-attributes, not-callable, invalid-name, no-member - def __init__( + def __init__( # noqa: PLR0915, PLR0913, Too many statements, Too many arguments self, - broker, - port=None, - username=None, - password=None, - client_id=None, - is_ssl=True, - keep_alive=60, + *, + broker: str, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + is_ssl: Optional[bool] = None, + keep_alive: int = 60, + recv_timeout: int = 10, socket_pool=None, ssl_context=None, - ): - + use_binary_mode: bool = False, + socket_timeout: int = 1, + connect_retries: int = 5, + user_data=None, + ) -> None: + self._connection_manager = get_connection_manager(socket_pool) self._socket_pool = socket_pool self._ssl_context = ssl_context self._sock = None self._backwards_compatible_sock = False + self._use_binary_mode = use_binary_mode + + if recv_timeout <= socket_timeout: + raise ValueError("recv_timeout must be strictly greater than socket_timeout") + self._socket_timeout = socket_timeout + self._recv_timeout = recv_timeout self.keep_alive = keep_alive - self._user_data = None + self.user_data = user_data self._is_connected = False self._msg_size_lim = MQTT_MSG_SZ_LIM self._pid = 0 - self._timestamp = 0 - self.logger = None + self._last_msg_sent_timestamp: int = 0 + self.logger = NullLogger() + """An optional logging attribute that can be set with with a Logger + to enable debug logging.""" + + self._reconnect_attempt = 0 + self._reconnect_timeout = float(0) + self._reconnect_maximum_backoff = 32 + if connect_retries <= 0: + raise ValueError("connect_retries must be positive") + self._reconnect_attempts_max = connect_retries self.broker = broker self._username = username @@ -162,37 +205,43 @@ def __init__( if ( self._password and len(password.encode("utf-8")) > MQTT_TOPIC_LENGTH_LIMIT ): # [MQTT-3.1.3.5] - raise MMQTTException("Password length is too large.") + raise ValueError("Password length is too large.") + # The connection will be insecure unless is_ssl is set to True. + # If the port is not specified, the security will be set based on the is_ssl parameter. + # If the port is specified, the is_ssl parameter will be honored. self.port = MQTT_TCP_PORT - if is_ssl: + if is_ssl is None: + is_ssl = False + self._is_ssl = is_ssl + if self._is_ssl: self.port = MQTT_TLS_PORT if port: self.port = port - # define client identifer + self.session_id = None + + # define client identifier if client_id: # user-defined client_id MAY allow client_id's > 23 bytes or # non-alpha-numeric characters self.client_id = client_id else: # assign a unique client_id - self.client_id = "cpy{0}{1}".format( - randint(0, int(time.monotonic() * 100) % 1000), randint(0, 99) - ) + time_int = int(ticks_ms() / 10) % 1000 + self.client_id = f"cpy{randint(0, time_int)}{randint(0, 99)}" # generated client_id's enforce spec.'s length rules - if len(self.client_id) > 23 or not self.client_id: + if len(self.client_id.encode("utf-8")) > 23 or not self.client_id: raise ValueError("MQTT Client ID must be between 1 and 23 bytes") # LWT self._lw_topic = None self._lw_qos = 0 - self._lw_topic = None self._lw_msg = None self._lw_retain = False # List of subscribed topics, used for tracking - self._subscribed_topics = [] + self._subscribed_topics: List[str] = [] self._on_message_filtered = MQTTMatcher() # Default topic callback methods @@ -203,124 +252,28 @@ def __init__( self.on_subscribe = None self.on_unsubscribe = None - # pylint: disable=too-many-branches - def _get_connect_socket(self, host, port, *, timeout=1): - """Obtains a new socket and connects to a broker. - :param str host: Desired broker hostname - :param int port: Desired broker port - :param int timeout: Desired socket timeout - """ - # For reconnections - check if we're using a socket already and close it - if self._sock: - self._sock.close() - self._sock = None - - # Legacy API - use the interface's socket instead of a passed socket pool - if self._socket_pool is None: - self._socket_pool = _default_sock - - # Legacy API - fake the ssl context - if self._ssl_context is None: - self._ssl_context = _fake_context - - if not isinstance(port, int): - raise RuntimeError("Port must be an integer") - - if port == 8883 and not self._ssl_context: - raise RuntimeError( - "ssl_context must be set before using adafruit_mqtt for secure MQTT." - ) - - if self.logger and port == MQTT_TLS_PORT: - self.logger.info( - "Establishing a SECURE SSL connection to {0}:{1}".format(host, port) - ) - elif self.logger: - self.logger.info( - "Establishing an INSECURE connection to {0}:{1}".format(host, port) - ) - - addr_info = self._socket_pool.getaddrinfo( - host, port, 0, self._socket_pool.SOCK_STREAM - )[0] - - sock = None - retry_count = 0 - while retry_count < 5 and sock is None: - retry_count += 1 - - try: - sock = self._socket_pool.socket(addr_info[0], addr_info[1]) - except OSError: - continue - - connect_host = addr_info[-1][0] - if port == 8883: - sock = self._ssl_context.wrap_socket(sock, server_hostname=host) - connect_host = host - sock.settimeout(timeout) - - try: - sock.connect((connect_host, port)) - except MemoryError: - sock.close() - sock = None - except OSError: - sock.close() - sock = None - - if sock is None: - raise RuntimeError("Repeated socket failures") - - self._backwards_compatible_sock = not hasattr(sock, "recv_into") - return sock - def __enter__(self): return self - def __exit__(self, exception_type, exception_value, traceback): + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: self.deinit() - def _sock_exact_recv(self, bufsize): - """Reads _exact_ number of bytes from the connected socket. Will only return - string with the exact number of bytes requested. - - The semantics of native socket receive is that it returns no more than the - specified number of bytes (i.e. max size). However, it makes no guarantees in - terms of the minimum size of the buffer, which could be 1 byte. This is a - wrapper for socket recv() to ensure that no less than the expected number of - bytes is returned or trigger a timeout exception. - - :param int bufsize: number of bytes to receive - """ - stamp = time.monotonic() - rc = self._sock.recv(bufsize) - to_read = bufsize - len(rc) - assert to_read >= 0 - read_timeout = self.keep_alive - while to_read > 0: - recv = self._sock.recv(to_read) - to_read -= len(recv) - rc += recv - if time.monotonic() - stamp > read_timeout: - raise MMQTTException( - "Unable to receive {} bytes within {} seconds.".format( - to_read, read_timeout - ) - ) - return rc - - def deinit(self): + def deinit(self) -> None: """De-initializes the MQTT client and disconnects from the mqtt broker.""" self.disconnect() @property - def mqtt_msg(self): + def mqtt_msg(self) -> Tuple[int, int]: """Returns maximum MQTT payload and topic size.""" return self._msg_size_lim, MQTT_TOPIC_LENGTH_LIMIT @mqtt_msg.setter - def mqtt_msg(self, msg_size): + def mqtt_msg(self, msg_size: int) -> None: """Sets the maximum MQTT message payload size. :param int msg_size: Maximum MQTT payload size. @@ -328,47 +281,89 @@ def mqtt_msg(self, msg_size): if msg_size < MQTT_MSG_MAX_SZ: self._msg_size_lim = msg_size - def will_set(self, topic=None, payload=None, qos=0, retain=False): + def will_set( + self, + topic: str, + msg: Union[str, int, float, bytes], + retain: bool = False, + qos: int = 0, + ) -> None: """Sets the last will and testament properties. MUST be called before `connect()`. :param str topic: MQTT Broker topic. - :param int,float,str payload: Last will disconnection payload. - payloads of type int & float are converted to a string. + :param str|int|float|bytes msg: Last will disconnection msg. + msgs of type int & float are converted to a string. + msgs of type byetes are left unchanged, as it is in the publish function. :param int qos: Quality of Service level, defaults to zero. Conventional options are ``0`` (send at most once), ``1`` (send at least once), or ``2`` (send exactly once). - .. note:: Only options ``1`` or ``0`` are QoS levels supported by this library. - :param bool retain: Specifies if the payload is to be retained when + :param bool retain: Specifies if the msg is to be retained when it is published. """ - if self.logger: - self.logger.debug("Setting last will properties") - self._valid_qos(qos) + self.logger.debug("Setting last will properties") if self._is_connected: - raise MMQTTException("Last Will should only be called before connect().") - if payload is None: - payload = "" - if isinstance(payload, (int, float, str)): - payload = str(payload).encode() + raise MMQTTStateError("Last Will should only be called before connect().") + + # check topic/msg/qos kwargs + self._valid_topic(topic) + if "+" in topic or "#" in topic: + raise ValueError("Publish topic can not contain wildcards.") + + if msg is None: + raise ValueError("Message can not be None.") + if isinstance(msg, (int, float)): + msg = str(msg).encode("ascii") + elif isinstance(msg, str): + msg = str(msg).encode("utf-8") + elif isinstance(msg, bytes): + pass else: - raise MMQTTException("Invalid message data type.") + raise ValueError("Invalid message data type.") + if len(msg) > MQTT_MSG_MAX_SZ: + raise ValueError(f"Message size larger than {MQTT_MSG_MAX_SZ} bytes.") + + self._valid_qos(qos) + + # fixed header. [3.3.1.2], [3.3.1.3] + pub_hdr_fixed = bytearray([MQTT_PUBLISH | retain | qos << 1]) + + # variable header = 2-byte Topic length (big endian) + pub_hdr_var = bytearray(struct.pack(">H", len(topic.encode("utf-8")))) + pub_hdr_var.extend(topic.encode("utf-8")) # Topic name + + remaining_length = 2 + len(msg) + len(topic.encode("utf-8")) + if qos > 0: + # packet identifier where QoS level is 1 or 2. [3.3.2.2] + remaining_length += 2 + self._pid = self._pid + 1 if self._pid < 0xFFFF else 1 + pub_hdr_var.append(self._pid >> 8) + pub_hdr_var.append(self._pid & 0xFF) + + self._encode_remaining_length(pub_hdr_fixed, remaining_length) + self._lw_qos = qos self._lw_topic = topic - self._lw_msg = payload + self._lw_msg = msg self._lw_retain = retain + self.logger.debug("Last will properties successfully set") - def add_topic_callback(self, mqtt_topic, callback_method): + def add_topic_callback(self, mqtt_topic: str, callback_method) -> None: """Registers a callback_method for a specific MQTT topic. :param str mqtt_topic: MQTT topic identifier. - :param function callback_method: Name of callback method. + :param function callback_method: The callback method. + + Expected method signature is ``on_message(client, topic, message)`` + To get access to the user_data, use the client argument. + + If a callback is called for the topic, then any "on_message" callback will not be called. """ if mqtt_topic is None or callback_method is None: raise ValueError("MQTT topic and callback method must both be defined.") self._on_message_filtered[mqtt_topic] = callback_method - def remove_topic_callback(self, mqtt_topic): + def remove_topic_callback(self, mqtt_topic: str) -> None: """Removes a registered callback method. :param str mqtt_topic: MQTT topic identifier string. @@ -378,51 +373,150 @@ def remove_topic_callback(self, mqtt_topic): try: del self._on_message_filtered[mqtt_topic] except KeyError: - raise KeyError( - "MQTT topic callback not added with add_topic_callback." - ) from None + raise KeyError("MQTT topic callback not added with add_topic_callback.") from None @property def on_message(self): """Called when a new message has been received on a subscribed topic. Expected method signature is ``on_message(client, topic, message)`` + To get access to the user_data, use the client argument. """ return self._on_message @on_message.setter - def on_message(self, method): + def on_message(self, method) -> None: self._on_message = method - def _handle_on_message(self, client, topic, message): + def _handle_on_message(self, topic: str, message: str): matched = False if topic is not None: for callback in self._on_message_filtered.iter_match(topic): - callback(client, topic, message) # on_msg with callback + callback(self, topic, message) # on_msg with callback matched = True if not matched and self.on_message: # regular on_message - self.on_message(client, topic, message) + self.on_message(self, topic, message) - def username_pw_set(self, username, password=None): + def username_pw_set(self, username: str, password: Optional[str] = None) -> None: """Set client's username and an optional password. + :param str username: Username to use with your MQTT broker. :param str password: Password to use with your MQTT broker. """ if self._is_connected: - raise MMQTTException("This method must be called before connect().") + raise MMQTTStateError("This method must be called before connect().") self._username = username if password is not None: self._password = password - # pylint: disable=too-many-branches, too-many-statements, too-many-locals - def connect(self, clean_session=True, host=None, port=None, keep_alive=None): + def connect( # noqa: PLR0913, too many arguments in function definition + self, + clean_session: bool = True, + host: Optional[str] = None, + port: Optional[int] = None, + keep_alive: Optional[int] = None, + session_id: Optional[str] = None, + ) -> int: + """Initiates connection with the MQTT Broker. Will perform exponential back-off + on connect failures. + + :param bool clean_session: Establishes a persistent session. + :param str host: Hostname or IP address of the remote broker. + :param int port: Network port of the remote broker. + :param int keep_alive: Maximum period allowed for communication + within single connection attempt, in seconds. + :param str session_id: unique session ID, + used for multiple simultaneous connections to the same host + """ + + last_exception = None + backoff = False + for i in range(0, self._reconnect_attempts_max): + if i > 0: + if backoff: + self._recompute_reconnect_backoff() + else: + self._reset_reconnect_backoff() + + self.logger.debug( + f"Attempting to connect to MQTT broker (attempt #{self._reconnect_attempt})" + ) + + try: + ret = self._connect( + clean_session=clean_session, + host=host, + port=port, + keep_alive=keep_alive, + session_id=session_id, + ) + self._reset_reconnect_backoff() + return ret + except (MemoryError, OSError, RuntimeError) as e: + if isinstance(e, RuntimeError) and e.args == ("pystack exhausted",): + raise + self._close_socket() + self.logger.warning(f"Socket error when connecting: {e}") + last_exception = e + backoff = False + except MMQTTException as e: + self._close_socket() + self.logger.info(f"MMQT error: {e}") + if e.code in [ + CONNACK_ERROR_INCORECT_USERNAME_PASSWORD, + CONNACK_ERROR_UNAUTHORIZED, + ]: + # No sense trying these again, re-raise + raise + last_exception = e + backoff = True + + if self._reconnect_attempts_max > 1: + exc_msg = "Repeated connect failures" + else: + exc_msg = "Connect failure" + + if last_exception: + raise MMQTTException(exc_msg) from last_exception + raise MMQTTException(exc_msg) + + def _send_bytes( + self, + buffer: Union[bytes, bytearray, memoryview], + ): + bytes_sent: int = 0 + bytes_to_send = len(buffer) + view = memoryview(buffer) + while bytes_sent < bytes_to_send: + try: + sent_now = self._sock.send(view[bytes_sent:]) + # Some versions of `Socket.send()` do not return the number of bytes sent. + if not isinstance(sent_now, int): + return + bytes_sent += sent_now + except OSError as exc: + if exc.errno == errno.EAGAIN: + continue + raise + + def _connect( # noqa: PLR0912, PLR0913, PLR0915, Too many branches, Too many arguments, Too many statements + self, + clean_session: bool = True, + host: Optional[str] = None, + port: Optional[int] = None, + keep_alive: Optional[int] = None, + session_id: Optional[str] = None, + ) -> int: """Initiates connection with the MQTT Broker. + :param bool clean_session: Establishes a persistent session. :param str host: Hostname or IP address of the remote broker. :param int port: Network port of the remote broker. :param int keep_alive: Maximum period allowed for communication, in seconds. + :param str session_id: unique session ID, + used for multiple simultaneous connections to the same host """ if host: @@ -432,140 +526,171 @@ def connect(self, clean_session=True, host=None, port=None, keep_alive=None): if keep_alive: self.keep_alive = keep_alive - if self.logger: - self.logger.debug("Attempting to establish MQTT connection...") + self.logger.debug("Attempting to establish MQTT connection...") + + if self._reconnect_attempt > 0: + self.logger.debug( + f"Sleeping for {self._reconnect_timeout:.3} seconds due to connect back-off" + ) + time.sleep(self._reconnect_timeout) # Get a new socket - self._sock = self._get_connect_socket(self.broker, self.port) + self._sock = self._connection_manager.get_socket( + self.broker, + self.port, + proto="mqtt:", + session_id=session_id, + timeout=self._socket_timeout, + is_ssl=self._is_ssl, + ssl_context=self._ssl_context, + ) + self.session_id = session_id + self._backwards_compatible_sock = not hasattr(self._sock, "recv_into") - # Fixed Header fixed_header = bytearray([0x10]) - # NOTE: Variable header is - # MQTT_HDR_CONNECT = bytearray(b"\x04MQTT\x04\x02\0\0") - # because final 4 bytes are 4, 2, 0, 0 - var_header = MQTT_HDR_CONNECT - var_header[6] = clean_session << 1 + # Variable CONNECT header [MQTT 3.1.2] + # The byte array is used as a template. + var_header = bytearray(b"\x00\x04MQTT\x04\x02\0\0") + var_header[7] = clean_session << 1 # Set up variable header and remaining_length - remaining_length = 12 + len(self.client_id) - if self._username: - remaining_length += 2 + len(self._username) + 2 + len(self._password) - var_header[6] |= 0xC0 + remaining_length = 12 + len(self.client_id.encode("utf-8")) + if self._username is not None: + remaining_length += ( + 2 + len(self._username.encode("utf-8")) + 2 + len(self._password.encode("utf-8")) + ) + var_header[7] |= 0xC0 if self.keep_alive: assert self.keep_alive < MQTT_TOPIC_LENGTH_LIMIT - var_header[7] |= self.keep_alive >> 8 - var_header[8] |= self.keep_alive & 0x00FF + var_header[8] |= self.keep_alive >> 8 + var_header[9] |= self.keep_alive & 0x00FF if self._lw_topic: - remaining_length += 2 + len(self._lw_topic) + 2 + len(self._lw_msg) - var_header[6] |= 0x4 | (self._lw_qos & 0x1) << 3 | (self._lw_qos & 0x2) << 3 - var_header[6] |= self._lw_retain << 5 - - # Remaining length calculation - large_rel_length = False - if remaining_length > 0x7F: - large_rel_length = True - # Calculate Remaining Length [2.2.3] - while remaining_length > 0: - encoded_byte = remaining_length % 0x80 - remaining_length = remaining_length // 0x80 - # if there is more data to encode, set the top bit of the byte - if remaining_length > 0: - encoded_byte |= 0x80 - fixed_header.append(encoded_byte) - if large_rel_length: - fixed_header.append(0x00) - else: - fixed_header.append(remaining_length) - fixed_header.append(0x00) - - if self.logger: - self.logger.debug("Sending CONNECT to broker...") - self.logger.debug( - "Fixed Header: %s\nVariable Header: %s", fixed_header, var_header - ) - self._sock.send(fixed_header) - self._sock.send(var_header) + remaining_length += 2 + len(self._lw_topic.encode("utf-8")) + 2 + len(self._lw_msg) + var_header[7] |= 0x4 | (self._lw_qos & 0x1) << 3 | (self._lw_qos & 0x2) << 3 + var_header[7] |= self._lw_retain << 5 + + self._encode_remaining_length(fixed_header, remaining_length) + self.logger.debug("Sending CONNECT to broker...") + self.logger.debug(f"Fixed Header: {fixed_header}") + self.logger.debug(f"Variable Header: {var_header}") + self._send_bytes(fixed_header) + self._send_bytes(var_header) # [MQTT-3.1.3-4] self._send_str(self.client_id) if self._lw_topic: # [MQTT-3.1.3-11] self._send_str(self._lw_topic) self._send_str(self._lw_msg) - if self._username is None: - self._username = None - else: + if self._username is not None: self._send_str(self._username) self._send_str(self._password) - if self.logger: - self.logger.debug("Receiving CONNACK packet from broker") + self._last_msg_sent_timestamp = ticks_ms() + self.logger.debug("Receiving CONNACK packet from broker") + stamp = ticks_ms() while True: op = self._wait_for_msg() if op == 32: rc = self._sock_exact_recv(3) assert rc[0] == 0x02 if rc[2] != 0x00: - raise MMQTTException(CONNACK_ERRORS[rc[2]]) + raise MMQTTException(CONNACK_ERRORS[rc[2]], code=rc[2]) self._is_connected = True result = rc[0] & 1 if self.on_connect is not None: - self.on_connect(self, self._user_data, result, rc[2]) + self.on_connect(self, self.user_data, result, rc[2]) + return result - def disconnect(self): + if op is None: + if ticks_diff(ticks_ms(), stamp) / 1000 > self._recv_timeout: + raise MMQTTException( + f"No data received from broker for {self._recv_timeout} seconds." + ) + + def _close_socket(self): + if self._sock: + self.logger.debug("Closing socket") + self._connection_manager.close_socket(self._sock) + self._sock = None + + def _encode_remaining_length(self, fixed_header: bytearray, remaining_length: int) -> None: + """Encode Remaining Length [2.2.3]""" + if remaining_length > 268_435_455: + raise MMQTTException("invalid remaining length") + + # Remaining length calculation + if remaining_length > 0x7F: + while remaining_length > 0: + encoded_byte = remaining_length % 0x80 + remaining_length = remaining_length // 0x80 + # if there is more data to encode, set the top bit of the byte + if remaining_length > 0: + encoded_byte |= 0x80 + fixed_header.append(encoded_byte) + else: + fixed_header.append(remaining_length) + + def disconnect(self) -> None: """Disconnects the MiniMQTT client from the MQTT broker.""" - self.is_connected() - if self.logger is not None: - self.logger.debug("Sending DISCONNECT packet to broker") + self._connected() + self.logger.debug("Sending DISCONNECT packet to broker") try: - self._sock.send(MQTT_DISCONNECT) - except RuntimeError as e: - if self.logger: - self.logger.warning("Unable to send DISCONNECT packet: {}".format(e)) - if self.logger is not None: - self.logger.debug("Closing socket") - self._sock.close() + self._send_bytes(MQTT_DISCONNECT) + except (MemoryError, OSError, RuntimeError) as e: + self.logger.warning(f"Unable to send DISCONNECT packet: {e}") + self._close_socket() self._is_connected = False self._subscribed_topics = [] + self._last_msg_sent_timestamp = 0 if self.on_disconnect is not None: - self.on_disconnect(self, self._user_data, 0) + self.on_disconnect(self, self.user_data, 0) - def ping(self): + def ping(self) -> list[int]: """Pings the MQTT Broker to confirm if the broker is alive or if there is an active network connection. - Returns response codes of any messages received while waiting for PINGRESP. + Returns packet types of any messages received while waiting for PINGRESP. """ - self.is_connected() - if self.logger: - self.logger.debug("Sending PINGREQ") - self._sock.send(MQTT_PINGREQ) + self._connected() + self.logger.debug("Sending PINGREQ") + self._send_bytes(MQTT_PINGREQ) ping_timeout = self.keep_alive - stamp = time.monotonic() + stamp = ticks_ms() + + self._last_msg_sent_timestamp = stamp rc, rcs = None, [] while rc != MQTT_PINGRESP: rc = self._wait_for_msg() if rc: rcs.append(rc) - if time.monotonic() - stamp > ping_timeout: - raise MMQTTException("PINGRESP not returned from broker.") + if ticks_diff(ticks_ms(), stamp) / 1000 > ping_timeout: + raise MMQTTException( + f"PINGRESP not returned from broker within {ping_timeout} seconds." + ) return rcs - # pylint: disable=too-many-branches, too-many-statements - def publish(self, topic, msg, retain=False, qos=0): + def publish( # noqa: PLR0912, Too many branches + self, + topic: str, + msg: Union[str, int, float, bytes], + retain: bool = False, + qos: int = 0, + ) -> None: """Publishes a message to a topic provided. + :param str topic: Unique topic identifier. - :param str,int,float,bytes msg: Data to send to the broker. + :param str|int|float|bytes msg: Data to send to the broker. :param bool retain: Whether the message is saved by the broker. :param int qos: Quality of Service level for the message, defaults to zero. """ - self.is_connected() + self._connected() self._valid_topic(topic) if "+" in topic or "#" in topic: - raise MMQTTException("Publish topic can not contain wildcards.") + raise ValueError("Publish topic can not contain wildcards.") # check msg/qos kwargs if msg is None: - raise MMQTTException("Message can not be None.") + raise ValueError("Message can not be None.") if isinstance(msg, (int, float)): msg = str(msg).encode("ascii") elif isinstance(msg, str): @@ -573,21 +698,20 @@ def publish(self, topic, msg, retain=False, qos=0): elif isinstance(msg, bytes): pass else: - raise MMQTTException("Invalid message data type.") + raise ValueError("Invalid message data type.") if len(msg) > MQTT_MSG_MAX_SZ: - raise MMQTTException("Message size larger than %d bytes." % MQTT_MSG_MAX_SZ) - assert ( - 0 <= qos <= 1 - ), "Quality of Service Level 2 is unsupported by this library." + raise ValueError(f"Message size larger than {MQTT_MSG_MAX_SZ} bytes.") + + self._valid_qos(qos) # fixed header. [3.3.1.2], [3.3.1.3] - pub_hdr_fixed = bytearray([0x30 | retain | qos << 1]) + pub_hdr_fixed = bytearray([MQTT_PUBLISH | retain | qos << 1]) # variable header = 2-byte Topic length (big endian) - pub_hdr_var = bytearray(struct.pack(">H", len(topic))) + pub_hdr_var = bytearray(struct.pack(">H", len(topic.encode("utf-8")))) pub_hdr_var.extend(topic.encode("utf-8")) # Topic name - remaining_length = 2 + len(msg) + len(topic) + remaining_length = 2 + len(msg) + len(topic.encode("utf-8")) if qos > 0: # packet identifier where QoS level is 1 or 2. [3.3.2.2] remaining_length += 2 @@ -595,49 +719,49 @@ def publish(self, topic, msg, retain=False, qos=0): pub_hdr_var.append(self._pid >> 8) pub_hdr_var.append(self._pid & 0xFF) - # Calculate remaining length [2.2.3] - if remaining_length > 0x7F: - while remaining_length > 0: - encoded_byte = remaining_length % 0x80 - remaining_length = remaining_length // 0x80 - if remaining_length > 0: - encoded_byte |= 0x80 - pub_hdr_fixed.append(encoded_byte) - else: - pub_hdr_fixed.append(remaining_length) - - if self.logger: - self.logger.debug( - "Sending PUBLISH\nTopic: %s\nMsg: %s\ - \nQoS: %d\nRetain? %r", - topic, - msg, - qos, - retain, - ) - self._sock.send(pub_hdr_fixed) - self._sock.send(pub_hdr_var) - self._sock.send(msg) + self._encode_remaining_length(pub_hdr_fixed, remaining_length) + + self.logger.debug( + "Sending PUBLISH\nTopic: %s\nMsg: %s\ + \nQoS: %d\nRetain? %r", + topic, + msg, + qos, + retain, + ) + self._send_bytes(pub_hdr_fixed) + self._send_bytes(pub_hdr_var) + self._send_bytes(msg) + self._last_msg_sent_timestamp = ticks_ms() if qos == 0 and self.on_publish is not None: - self.on_publish(self, self._user_data, topic, self._pid) + self.on_publish(self, self.user_data, topic, self._pid) if qos == 1: + stamp = ticks_ms() while True: op = self._wait_for_msg() if op == 0x40: sz = self._sock_exact_recv(1) assert sz == b"\x02" - rcv_pid = self._sock_exact_recv(2) - rcv_pid = rcv_pid[0] << 0x08 | rcv_pid[1] + rcv_pid_buf = self._sock_exact_recv(2) + rcv_pid = rcv_pid_buf[0] << 0x08 | rcv_pid_buf[1] if self._pid == rcv_pid: if self.on_publish is not None: - self.on_publish(self, self._user_data, topic, rcv_pid) + self.on_publish(self, self.user_data, topic, rcv_pid) return - def subscribe(self, topic, qos=0): + if op is None: + if ticks_diff(ticks_ms(), stamp) / 1000 > self._recv_timeout: + raise MMQTTException( + f"No data received from broker for {self._recv_timeout} seconds." + ) + + def subscribe( # noqa: PLR0912, PLR0915, Too many branches, Too many statements + self, topic: Optional[Union[tuple, str, list]], qos: int = 0 + ) -> None: """Subscribes to a topic on the MQTT Broker. - This method can subscribe to one topics or multiple topics. + This method can subscribe to one topic or multiple topics. - :param str,tuple,list topic: Unique MQTT topic identifier string. If + :param str|tuple|list topic: Unique MQTT topic identifier string. If this is a `tuple`, then the tuple should contain topic identifier string and qos level integer. If this is a `list`, then @@ -648,7 +772,7 @@ def subscribe(self, topic, qos=0): (send at least once), or ``2`` (send exactly once). """ - self.is_connected() + self._connected() topics = None if isinstance(topic, tuple): topic, qos = topic @@ -665,38 +789,71 @@ def subscribe(self, topic, qos=0): self._valid_topic(t) topics.append((t, q)) # Assemble packet + self.logger.debug("Sending SUBSCRIBE to broker...") + fixed_header = bytearray([MQTT_SUB]) packet_length = 2 + (2 * len(topics)) + (1 * len(topics)) - packet_length += sum(len(topic) for topic, qos in topics) - packet_length_byte = packet_length.to_bytes(1, "big") + packet_length += sum(len(topic.encode("utf-8")) for topic, qos in topics) + self._encode_remaining_length(fixed_header, remaining_length=packet_length) + self.logger.debug(f"Fixed Header: {fixed_header}") + self._send_bytes(fixed_header) self._pid = self._pid + 1 if self._pid < 0xFFFF else 1 packet_id_bytes = self._pid.to_bytes(2, "big") - # Packet with variable and fixed headers - packet = MQTT_SUB + packet_length_byte + packet_id_bytes + var_header = packet_id_bytes + self.logger.debug(f"Variable Header: {var_header}") + self._send_bytes(var_header) # attaching topic and QOS level to the packet + payload = b"" for t, q in topics: - topic_size = len(t).to_bytes(2, "big") + topic_size = len(t.encode("utf-8")).to_bytes(2, "big") qos_byte = q.to_bytes(1, "big") - packet += topic_size + t.encode() + qos_byte - if self.logger: - for t, q in topics: - self.logger.debug("SUBSCRIBING to topic %s with QoS %d", t, q) - self._sock.send(packet) + payload += topic_size + t.encode() + qos_byte + for t, q in topics: + self.logger.debug(f"SUBSCRIBING to topic {t} with QoS {q}") + self.logger.debug(f"payload: {payload}") + self._send_bytes(payload) + stamp = ticks_ms() + self._last_msg_sent_timestamp = stamp while True: op = self._wait_for_msg() - if op == 0x90: - rc = self._sock_exact_recv(4) - assert rc[1] == packet[2] and rc[2] == packet[3] - if rc[3] == 0x80: - raise MMQTTException("SUBACK Failure!") - for t, q in topics: - if self.on_subscribe is not None: - self.on_subscribe(self, self._user_data, t, q) - self._subscribed_topics.append(t) - return - - def unsubscribe(self, topic): + if op is None: + if ticks_diff(ticks_ms(), stamp) / 1000 > self._recv_timeout: + raise MMQTTException( + f"No data received from broker for {self._recv_timeout} seconds." + ) + else: + if op == MQTT_SUBACK: + remaining_len = self._decode_remaining_length() + assert remaining_len > 0 + rc = self._sock_exact_recv(2) + # Check packet identifier. + assert rc[0] == var_header[0] and rc[1] == var_header[1] + rc = self._sock_exact_recv(remaining_len - 2) + for i in range(0, remaining_len - 2): + if rc[i] not in [0, 1, 2]: + raise MMQTTException( + f"SUBACK Failure for topic {topics[i][0]}: {hex(rc[i])}" + ) + + for t, q in topics: + if self.on_subscribe is not None: + self.on_subscribe(self, self.user_data, t, q) + self._subscribed_topics.append(t) + + return + + if op != MQTT_PUBLISH: + # [3.8.4] The Server is permitted to start sending PUBLISH packets + # matching the Subscription before the Server sends the SUBACK Packet. + raise MMQTTException( + f"invalid message received as response to SUBSCRIBE: {hex(op)}" + ) + + def unsubscribe( # noqa: PLR0912, Too many branches + self, topic: Optional[Union[str, list]] + ) -> None: """Unsubscribes from a MQTT topic. - :param str,list topic: Unique MQTT topic identifier string or list. + + :param str|list topic: Unique MQTT topic identifier string or list. """ topics = None @@ -707,264 +864,371 @@ def unsubscribe(self, topic): topics = [] for t in topic: self._valid_topic(t) - topics.append((t)) + topics.append(t) for t in topics: if t not in self._subscribed_topics: - raise MMQTTException( - "Topic must be subscribed to before attempting unsubscribe." - ) + raise MMQTTStateError("Topic must be subscribed to before attempting unsubscribe.") # Assemble packet + self.logger.debug("Sending UNSUBSCRIBE to broker...") + fixed_header = bytearray([MQTT_UNSUB]) packet_length = 2 + (2 * len(topics)) - packet_length += sum(len(topic) for topic in topics) - packet_length_byte = packet_length.to_bytes(1, "big") + packet_length += sum(len(topic.encode("utf-8")) for topic in topics) + self._encode_remaining_length(fixed_header, remaining_length=packet_length) + self.logger.debug(f"Fixed Header: {fixed_header}") + self._send_bytes(fixed_header) self._pid = self._pid + 1 if self._pid < 0xFFFF else 1 packet_id_bytes = self._pid.to_bytes(2, "big") - packet = MQTT_UNSUB + packet_length_byte + packet_id_bytes + var_header = packet_id_bytes + self.logger.debug(f"Variable Header: {var_header}") + self._send_bytes(var_header) + payload = b"" + for t in topics: + topic_size = len(t.encode("utf-8")).to_bytes(2, "big") + payload += topic_size + t.encode() for t in topics: - topic_size = len(t).to_bytes(2, "big") - packet += topic_size + t.encode() - if self.logger: - for t in topics: - self.logger.debug("UNSUBSCRIBING from topic %s", t) - self._sock.send(packet) - if self.logger: - self.logger.debug("Waiting for UNSUBACK...") + self.logger.debug(f"UNSUBSCRIBING from topic {t}") + self._send_bytes(payload) + self._last_msg_sent_timestamp = ticks_ms() + self.logger.debug("Waiting for UNSUBACK...") while True: + stamp = ticks_ms() op = self._wait_for_msg() - if op == 176: - rc = self._sock_exact_recv(3) - assert rc[0] == 0x02 - # [MQTT-3.32] - assert rc[1] == packet_id_bytes[0] and rc[2] == packet_id_bytes[1] - for t in topics: - if self.on_unsubscribe is not None: - self.on_unsubscribe(self, self._user_data, t, self._pid) - self._subscribed_topics.remove(t) - return - - def reconnect(self, resub_topics=True): + if op is None: + if ticks_diff(ticks_ms(), stamp) / 1000 > self._recv_timeout: + raise MMQTTException( + f"No data received from broker for {self._recv_timeout} seconds." + ) + else: + if op == MQTT_UNSUBACK: + rc = self._sock_exact_recv(3) + assert rc[0] == 0x02 + # [MQTT-3.32] + assert rc[1] == packet_id_bytes[0] and rc[2] == packet_id_bytes[1] + for t in topics: + if self.on_unsubscribe is not None: + self.on_unsubscribe(self, self.user_data, t, self._pid) + self._subscribed_topics.remove(t) + return + if op != MQTT_PUBLISH: + # [3.10.4] The Server may continue to deliver existing messages buffered + # for delivery to the client prior to sending the UNSUBACK Packet. + raise MMQTTException( + f"invalid message received as response to UNSUBSCRIBE: {hex(op)}" + ) + + def _recompute_reconnect_backoff(self) -> None: + """ + Recompute the reconnection timeout. The self._reconnect_timeout will be used + in self._connect() to perform the actual sleep. + + """ + self._reconnect_attempt = self._reconnect_attempt + 1 + self._reconnect_timeout = 2**self._reconnect_attempt + self.logger.debug(f"Reconnect timeout computed to {self._reconnect_timeout:.2f}") + + if self._reconnect_timeout > self._reconnect_maximum_backoff: + self.logger.debug( + f"Truncating reconnect timeout to {self._reconnect_maximum_backoff} seconds" + ) + self._reconnect_timeout = float(self._reconnect_maximum_backoff) + + # Add a sub-second jitter. + # Even truncated timeout should have jitter added to it. This is why it is added here. + jitter = randint(0, 1000) / 1000 + self.logger.debug(f"adding jitter {jitter:.2f} to {self._reconnect_timeout:.2f} seconds") + self._reconnect_timeout += jitter + + def _reset_reconnect_backoff(self) -> None: + """ + Reset reconnect back-off to the initial state. + + """ + self.logger.debug("Resetting reconnect backoff") + self._reconnect_attempt = 0 + self._reconnect_timeout = float(0) + + def reconnect(self, resub_topics: bool = True) -> int: """Attempts to reconnect to the MQTT broker. - :param bool resub_topics: Resubscribe to previously subscribed topics. + Return the value from connect() if successful. Will disconnect first if already connected. + Will perform exponential back-off on connect failures. + + :param bool resub_topics: Whether to resubscribe to previously subscribed topics. """ - if self.logger: - self.logger.debug("Attempting to reconnect with MQTT broker") - self.connect() - if self.logger: - self.logger.debug("Reconnected with broker") - if resub_topics: - if self.logger: - self.logger.debug( - "Attempting to resubscribe to previously subscribed topics." - ) - subscribed_topics = self._subscribed_topics.copy() + + self.logger.debug("Attempting to reconnect with MQTT broker") + subscribed_topics = [] + if self.is_connected(): + # disconnect() will reset subscribed topics so stash them now. + if resub_topics: + subscribed_topics = self._subscribed_topics.copy() + self.disconnect() + + ret = self.connect(session_id=self.session_id) + self.logger.debug("Reconnected with broker") + + if resub_topics and subscribed_topics: + self.logger.debug("Attempting to resubscribe to previously subscribed topics.") self._subscribed_topics = [] while subscribed_topics: feed = subscribed_topics.pop() self.subscribe(feed) - def loop(self, timeout=1): - """Non-blocking message loop. Use this method to - check incoming subscription messages. - Returns response codes of any messages received. - :param int timeout: Socket timeout, in seconds. + return ret + + def loop(self, timeout: float = 1.0) -> Optional[list[int]]: + """Non-blocking message loop. Use this method to check for incoming messages. + Returns list of packet types of any messages received or None. + + :param float timeout: return after this timeout, in seconds. """ - if self._timestamp == 0: - self._timestamp = time.monotonic() - current_time = time.monotonic() - if current_time - self._timestamp >= self.keep_alive: - self._timestamp = 0 - # Handle KeepAlive by expecting a PINGREQ/PINGRESP from the server - if self.logger is not None: + if timeout < self._socket_timeout: + raise ValueError( + f"loop timeout ({timeout}) must be >= " + + f"socket timeout ({self._socket_timeout}))" + ) + + self._connected() + self.logger.debug(f"waiting for messages for {timeout} seconds") + + stamp = ticks_ms() + rcs = [] + + while True: + if ticks_diff(ticks_ms(), self._last_msg_sent_timestamp) / 1000 >= self.keep_alive: + # Handle KeepAlive by expecting a PINGREQ/PINGRESP from the server self.logger.debug( "KeepAlive period elapsed - requesting a PINGRESP from the server..." ) - rcs = self.ping() - return rcs - self._sock.settimeout(timeout) - rc = self._wait_for_msg() - return [rc] if rc else None - - def _wait_for_msg(self, timeout=0.1): - """Reads and processes network events.""" + rcs.extend(self.ping()) + # ping() itself contains a _wait_for_msg() loop which might have taken a while, + # so check here as well. + if ticks_diff(ticks_ms(), stamp) / 1000 > timeout: + self.logger.debug(f"Loop timed out after {timeout} seconds") + break + + rc = self._wait_for_msg() + if rc is not None: + rcs.append(rc) + if ticks_diff(ticks_ms(), stamp) / 1000 > timeout: + self.logger.debug(f"Loop timed out after {timeout} seconds") + break + + return rcs if rcs else None + + def _wait_for_msg( # noqa: PLR0912, Too many branches + self, timeout: Optional[float] = None + ) -> Optional[int]: + """Reads and processes network events. + Return the packet type or None if there is nothing to be received. + + :param float timeout: return after this timeout, in seconds. + """ # CPython socket module contains a timeout attribute if hasattr(self._socket_pool, "timeout"): try: res = self._sock_exact_recv(1) except self._socket_pool.timeout: return None - else: # socketpool, esp32spi + else: # socketpool, esp32spi, wiznet5k try: - res = self._sock_exact_recv(1) + res = self._sock_exact_recv(1, timeout=timeout) except OSError as error: - if error.errno == errno.ETIMEDOUT: + if error.errno in (errno.ETIMEDOUT, errno.EAGAIN): # raised by a socket timeout if 0 bytes were present return None - raise MMQTTException from error + raise MMQTTException("Unexpected error while waiting for messages") from error - # Block while we parse the rest of the response - self._sock.settimeout(timeout) if res in [None, b""]: # If we get here, it means that there is nothing to be received return None - if res[0] == MQTT_PINGRESP: - if self.logger: - self.logger.debug("Got PINGRESP") + pkt_type = res[0] & MQTT_PKT_TYPE_MASK + self.logger.debug(f"Got message type: {hex(pkt_type)} pkt: {hex(res[0])}") + if pkt_type == MQTT_PINGRESP: + self.logger.debug("Got PINGRESP") sz = self._sock_exact_recv(1)[0] if sz != 0x00: - raise MMQTTException( - "Unexpected PINGRESP returned from broker: {}.".format(sz) - ) - return MQTT_PINGRESP - if res[0] & 0xF0 != 0x30: - return res[0] - sz = self._recv_len() + raise MMQTTException(f"Unexpected PINGRESP returned from broker: {sz}.") + return pkt_type + + if pkt_type != MQTT_PUBLISH: + return pkt_type + + # Handle only the PUBLISH packet type from now on. + sz = self._decode_remaining_length() # topic length MSB & LSB - topic_len = self._sock_exact_recv(2) - topic_len = (topic_len[0] << 8) | topic_len[1] - topic = self._sock_exact_recv(topic_len) - topic = str(topic, "utf-8") + topic_len_buf = self._sock_exact_recv(2) + topic_len = int((topic_len_buf[0] << 8) | topic_len_buf[1]) + + if topic_len > sz - 2: + raise MMQTTException( + f"Topic length {topic_len} in PUBLISH packet exceeds remaining length {sz} - 2" + ) + + topic_buf = self._sock_exact_recv(topic_len) + topic = str(topic_buf, "utf-8") sz -= topic_len + 2 pid = 0 if res[0] & 0x06: - pid = self._sock_exact_recv(2) - pid = pid[0] << 0x08 | pid[1] + pid_buf = self._sock_exact_recv(2) + pid = pid_buf[0] << 0x08 | pid_buf[1] sz -= 0x02 + # read message contents - msg = self._sock_exact_recv(sz) - self._handle_on_message(self, topic, str(msg, "utf-8")) + raw_msg = self._sock_exact_recv(sz) + msg = raw_msg if self._use_binary_mode else str(raw_msg, "utf-8") + self.logger.debug("Receiving PUBLISH \nTopic: %s\nMsg: %s\n", topic, raw_msg) + self._handle_on_message(topic, msg) if res[0] & 0x06 == 0x02: pkt = bytearray(b"\x40\x02\0\0") struct.pack_into("!H", pkt, 2, pid) - self._sock.send(pkt) + self._send_bytes(pkt) elif res[0] & 6 == 4: assert 0 - return res[0] - def _recv_len(self): - """Unpack MQTT message length.""" + return pkt_type + + def _decode_remaining_length(self) -> int: + """Decode Remaining Length [2.2.3]""" n = 0 sh = 0 - b = bytearray(1) while True: + if sh > 28: + raise MMQTTException("invalid remaining length encoding") b = self._sock_exact_recv(1)[0] n |= (b & 0x7F) << sh if not b & 0x80: return n sh += 7 - def _recv_into(self, buf, size=0): - """Backwards-compatible _recv_into implementation.""" - if self._backwards_compatible_sock: - size = len(buf) if size == 0 else size - b = self._sock.recv(size) - read_size = len(b) - buf[:read_size] = b - return read_size - return self._sock.recv_into(buf, size) - - def _sock_exact_recv(self, bufsize): + def _sock_exact_recv(self, bufsize: int, timeout: Optional[float] = None) -> bytearray: """Reads _exact_ number of bytes from the connected socket. Will only return - string with the exact number of bytes requested. + bytearray with the exact number of bytes requested. The semantics of native socket receive is that it returns no more than the specified number of bytes (i.e. max size). However, it makes no guarantees in terms of the minimum size of the buffer, which could be 1 byte. This is a wrapper for socket recv() to ensure that no less than the expected number of bytes is returned or trigger a timeout exception. - :param int bufsize: number of bytes to receive + :param int bufsize: number of bytes to receive + :param float timeout: timeout, in seconds. Defaults to keep_alive + :return: byte array """ + stamp = ticks_ms() if not self._backwards_compatible_sock: - # CPython/Socketpool Impl. + # CPython, socketpool, esp32spi, wiznet5k rc = bytearray(bufsize) - self._sock.recv_into(rc, bufsize) - else: # ESP32SPI Impl. - stamp = time.monotonic() - read_timeout = self.keep_alive - # This will timeout with socket timeout (not keepalive timeout) + mv = memoryview(rc) + recv_len = self._sock.recv_into(rc, bufsize) + to_read = bufsize - recv_len + if to_read < 0: + raise MMQTTException(f"negative number of bytes to read: {to_read}") + read_timeout = timeout if timeout is not None else self._recv_timeout + mv = mv[recv_len:] + while to_read > 0: + recv_len = self._sock.recv_into(mv, to_read) + to_read -= recv_len + mv = mv[recv_len:] + if ticks_diff(ticks_ms(), stamp) / 1000 > read_timeout: + raise MMQTTException( + f"Unable to receive {to_read} bytes within {read_timeout} seconds." + ) + else: # Legacy: fona, esp_atcontrol + # This will time out with socket timeout (not receive timeout). rc = self._sock.recv(bufsize) if not rc: - if self.logger: - self.logger.debug("_sock_exact_recv timeout") + self.logger.debug("_sock_exact_recv timeout") # If no bytes waiting, raise same exception as socketpool raise OSError(errno.ETIMEDOUT) # If any bytes waiting, try to read them all, # or raise exception if wait longer than read_timeout to_read = bufsize - len(rc) assert to_read >= 0 - read_timeout = self.keep_alive + read_timeout = self._recv_timeout while to_read > 0: recv = self._sock.recv(to_read) to_read -= len(recv) rc += recv - if time.monotonic() - stamp > read_timeout: + if ticks_diff(ticks_ms(), stamp) / 1000 > read_timeout: raise MMQTTException( - "Unable to receive {} bytes within {} seconds.".format( - to_read, read_timeout - ) + f"Unable to receive {to_read} bytes within {read_timeout} seconds." ) return rc - def _send_str(self, string): + def _send_str(self, string: str) -> None: """Encodes a string and sends it to a socket. + :param str string: String to write to the socket. """ - self._sock.send(struct.pack("!H", len(string))) if isinstance(string, str): - self._sock.send(str.encode(string, "utf-8")) + self._send_bytes(struct.pack("!H", len(string.encode("utf-8")))) + self._send_bytes(str.encode(string, "utf-8")) else: - self._sock.send(string) + self._send_bytes(struct.pack("!H", len(string))) + self._send_bytes(string) @staticmethod - def _valid_topic(topic): + def _valid_topic(topic: str) -> None: """Validates if topic provided is proper MQTT topic format. + :param str topic: Topic identifier """ if topic is None: - raise MMQTTException("Topic may not be NoneType") + raise ValueError("Topic may not be NoneType") # [MQTT-4.7.3-1] if not topic: - raise MMQTTException("Topic may not be empty.") + raise ValueError("Topic may not be empty.") # [MQTT-4.7.3-3] if len(topic.encode("utf-8")) > MQTT_TOPIC_LENGTH_LIMIT: - raise MMQTTException("Topic length is too large.") + raise ValueError(f"Encoded topic length is larger than {MQTT_TOPIC_LENGTH_LIMIT}") @staticmethod - def _valid_qos(qos_level): + def _valid_qos(qos_level: int) -> None: """Validates if the QoS level is supported by this library + :param int qos_level: Desired QoS level. """ if isinstance(qos_level, int): if qos_level < 0 or qos_level > 2: - raise MMQTTException("QoS must be between 1 and 2.") + raise NotImplementedError("QoS must be between 1 and 2.") else: - raise MMQTTException("QoS must be an integer.") + raise ValueError("QoS must be an integer.") - def is_connected(self): + def _connected(self) -> None: """Returns MQTT client session status as True if connected, raises - a `MMQTTException` if `False`. + a `MMQTTStateError exception` if `False`. + """ + if not self.is_connected(): + raise MMQTTStateError("MiniMQTT is not connected") + + def is_connected(self) -> bool: + """Returns MQTT client session status as True if connected, False + if not. """ - if self._sock is None or self._is_connected is False: - raise MMQTTException("MiniMQTT is not connected.") - return self._is_connected + return self._is_connected and self._sock is not None # Logging - def enable_logger(self, logger, log_level=20): - """Enables library logging provided a logger object. - :param logger: A python logger pacakge. + def enable_logger(self, log_pkg, log_level: int = 20, logger_name: str = "log"): + """Enables library logging by getting logger from the specified logging package + and setting its log level. + + :param log_pkg: A Python logging package. :param log_level: Numeric value of a logging level, defaults to INFO. + :param logger_name: name of the logger, defaults to "log". + + :return logger object """ - self.logger = logger.getLogger("log") + self.logger = log_pkg.getLogger(logger_name) self.logger.setLevel(log_level) - def disable_logger(self): + return self.logger + + def disable_logger(self) -> None: """Disables logging.""" - if not self.logger: - raise MMQTTException("Can not disable logger, no logger found.") - self.logger = None + self.logger = NullLogger() diff --git a/adafruit_minimqtt/matcher.py b/adafruit_minimqtt/matcher.py old mode 100755 new mode 100644 index 5d641ccb..6531f482 --- a/adafruit_minimqtt/matcher.py +++ b/adafruit_minimqtt/matcher.py @@ -11,6 +11,11 @@ * Author(s): Yoch (https://github.com/yoch) """ +try: + from typing import Dict +except ImportError: + pass + class MQTTMatcher: """Intended to manage topic filters including wildcards. @@ -21,20 +26,19 @@ class MQTTMatcher: some topic name. """ - # pylint: disable=too-few-public-methods class Node: """Individual node on the MQTT prefix tree.""" __slots__ = "children", "content" - def __init__(self): - self.children = {} + def __init__(self) -> None: + self.children: Dict[str, MQTTMatcher.Node] = {} self.content = None - def __init__(self): + def __init__(self) -> None: self._root = self.Node() - def __setitem__(self, key, value): + def __setitem__(self, key: str, value) -> None: """Add a topic filter :key to the prefix tree and associate it to :value""" node = self._root @@ -42,7 +46,7 @@ def __setitem__(self, key, value): node = node.children.setdefault(sym, self.Node()) node.content = value - def __getitem__(self, key): + def __getitem__(self, key: str): """Retrieve the value associated with some topic filter :key""" try: node = self._root @@ -54,7 +58,7 @@ def __getitem__(self, key): except KeyError: raise KeyError(key) from None - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: """Delete the value associated with some topic filter :key""" lst = [] try: @@ -65,30 +69,27 @@ def __delitem__(self, key): node.content = None except KeyError: raise KeyError(key) from None - else: # cleanup - for parent, k, node in reversed(lst): - if node.children or node.content is not None: - break - del parent.children[k] + for parent, k, node in reversed(lst): + if node.children or node.content is not None: + break + del parent.children[k] - def iter_match(self, topic): + def iter_match(self, topic: str): """Return an iterator on all values associated with filters that match the :topic""" lst = topic.split("/") normal = not topic.startswith("$") - def rec(node, i=0): + def rec(node: MQTTMatcher.Node, i: int = 0): if i == len(lst): if node.content is not None: yield node.content else: part = lst[i] if part in node.children: - for content in rec(node.children[part], i + 1): - yield content + yield from rec(node.children[part], i + 1) if "+" in node.children and (normal or i > 0): - for content in rec(node.children["+"], i + 1): - yield content + yield from rec(node.children["+"], i + 1) if "#" in node.children and (normal or i > 0): content = node.children["#"].content if content is not None: diff --git a/docs/conf.py b/docs/conf.py index 7dfe9601..064f3486 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,9 +1,8 @@ -# -*- coding: utf-8 -*- - # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # # SPDX-License-Identifier: MIT +import datetime import os import sys @@ -16,6 +15,7 @@ # ones. extensions = [ "sphinx.ext.autodoc", + "sphinxcontrib.jquery", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.todo", @@ -24,12 +24,12 @@ # Uncomment the below if you use native CircuitPython modules such as # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. -autodoc_mock_imports = ["micropython", "microcontroller", "random"] +autodoc_mock_imports = ["microcontroller", "random"] intersphinx_mapping = { - "python": ("https://docs.python.org/3.4", None), - "CircuitPython": ("https://circuitpython.readthedocs.io/en/latest/", None), + "python": ("https://docs.python.org/3", None), + "CircuitPython": ("https://docs.circuitpython.org/en/latest/", None), } # Add any paths that contain templates here, relative to this directory. @@ -42,7 +42,12 @@ # General information about the project. project = "Adafruit MiniMQTT Library" -copyright = "2019 Brent Rubell" +creation_year = "2019" +current_year = str(datetime.datetime.now().year) +year_duration = ( + current_year if current_year == creation_year else creation_year + " - " + current_year +) +copyright = year_duration + " Brent Rubell" author = "Brent Rubell" # The version info for the project you're documenting, acts as replacement for @@ -59,7 +64,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -91,19 +96,9 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - try: - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."] - except: - html_theme = "default" - html_theme_path = ["."] -else: - html_theme_path = ["."] +import sphinx_rtd_theme + +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/index.rst b/docs/index.rst index 9208b989..eb6a361a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,8 +29,9 @@ Table of Contents .. toctree:: :caption: Other Links - Download - CircuitPython Reference Documentation + Download from GitHub + Download Library Bundle + CircuitPython Reference Documentation CircuitPython Support Forum Discord Chat Adafruit Learning System diff --git a/docs/requirements.txt b/docs/requirements.txt index 88e67331..979f5681 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,6 @@ # # SPDX-License-Identifier: Unlicense -sphinx>=4.0.0 +sphinx +sphinxcontrib-jquery +sphinx-rtd-theme diff --git a/examples/cellular/minimqtt_adafruitio_cellular.py b/examples/cellular/minimqtt_adafruitio_cellular.py index d83b410b..f2f3061b 100755 --- a/examples/cellular/minimqtt_adafruitio_cellular.py +++ b/examples/cellular/minimqtt_adafruitio_cellular.py @@ -2,21 +2,25 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager +import adafruit_fona.adafruit_fona_network as network +import adafruit_fona.adafruit_fona_socket as pool import board import busio import digitalio from adafruit_fona.adafruit_fona import FONA -import adafruit_fona.adafruit_fona_network as network -import adafruit_fona.adafruit_fona_socket as socket import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Get Adafruit IO details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("GPRS secrets are kept in secrets.py, please add them there!") - raise +# Get FONA details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +apn = getenv("apn") +apn_username = getenv("apn_username") +apn_password = getenv("apn_password") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") ### Cellular ### @@ -29,19 +33,19 @@ ### Feeds ### # Setup a feed named 'photocell' for publishing to a feed -photocell_feed = secrets["aio_username"] + "/feeds/photocell" +photocell_feed = f"{aio_username}/feeds/photocell" # Setup a feed named 'onoff' for subscribing to changes -onoff_feed = secrets["aio_username"] + "/feeds/onoff" +onoff_feed = f"{aio_username}/feeds/onoff" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to Adafruit IO! Listening for topic changes on %s" % onoff_feed) + print(f"Connected to Adafruit IO! Listening for topic changes on {onoff_feed}") # Subscribe to all changes on the onoff_feed. client.subscribe(onoff_feed) @@ -54,13 +58,11 @@ def disconnected(client, userdata, rc): def message(client, topic, message): # This method is called when a topic the client is subscribed to # has a new message. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Initialize cellular data network -network = network.CELLULAR( - fona, (secrets["apn"], secrets["apn_username"], secrets["apn_password"]) -) +network = network.CELLULAR(fona, (apn, apn_username, apn_password)) while not network.is_attached: print("Attaching to network...") @@ -73,16 +75,17 @@ def message(client, topic, message): time.sleep(0.5) print("Network Connected!") -# Initialize MQTT interface with the cellular interface -MQTT.set_socket(socket, fona) +ssl_context = adafruit_connection_manager.create_fake_ssl_context(pool, fona) # Set up a MiniMQTT Client # NOTE: We'll need to connect insecurely for ethernet configurations. mqtt_client = MQTT.MQTT( broker="io.adafruit.com", - username=secrets["aio_username"], - password=secrets["aio_key"], + username=aio_username, + password=aio_key, is_ssl=False, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -101,7 +104,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d..." % photocell_val) + print(f"Sending photocell value: {photocell_val}...") mqtt_client.publish(photocell_feed, photocell_val) print("Sent!") photocell_val += 1 diff --git a/examples/cellular/minimqtt_simpletest_cellular.py b/examples/cellular/minimqtt_simpletest_cellular.py index 24bd33ac..fb83e292 100644 --- a/examples/cellular/minimqtt_simpletest_cellular.py +++ b/examples/cellular/minimqtt_simpletest_cellular.py @@ -2,23 +2,26 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager +import adafruit_fona.adafruit_fona_network as network +import adafruit_fona.adafruit_fona_socket as pool import board import busio import digitalio from adafruit_fona.adafruit_fona import FONA -import adafruit_fona.adafruit_fona_network as network -import adafruit_fona.adafruit_fona_socket as socket import adafruit_minimqtt.adafruit_minimqtt as MQTT -### Cellular ### - -# Get cellular details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("Cellular secrets are kept in secrets.py, please add them there!") - raise +# Get FONA details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +apn = getenv("apn") +apn_username = getenv("apn_username") +apn_password = getenv("apn_password") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") # Create a serial connection for the FONA connection uart = busio.UART(board.TX, board.RX) @@ -38,13 +41,13 @@ ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(client, userdata, rc): @@ -55,23 +58,21 @@ def disconnect(client, userdata, rc): def subscribe(client, userdata, topic, granted_qos): # This method is called when the client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(client, userdata, topic, pid): # This method is called when the client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(client, userdata, topic, pid): # This method is called when the client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") # Initialize cellular data network -network = network.CELLULAR( - fona, (secrets["apn"], secrets["apn_username"], secrets["apn_password"]) -) +network = network.CELLULAR(fona, (apn, apn_username, apn_password)) while not network.is_attached: print("Attaching to network...") @@ -84,15 +85,16 @@ def publish(client, userdata, topic, pid): time.sleep(0.5) print("Network Connected!") -# Initialize MQTT interface with the cellular interface -MQTT.set_socket(socket, fona) +ssl_context = adafruit_connection_manager.create_fake_ssl_context(pool, fona) # Set up a MiniMQTT Client client = MQTT.MQTT( - broker=secrets["broker"], - username=secrets["user"], - password=secrets["pass"], + broker=broker, + username=aio_username, + password=aio_key, is_ssl=False, + socket_pool=pool, + ssl_context=ssl_context, ) # Connect callback handlers to client @@ -102,17 +104,17 @@ def publish(client, userdata, topic, pid): client.on_unsubscribe = unsubscribe client.on_publish = publish -print("Attempting to connect to %s" % client.broker) +print(f"Attempting to connect to {client.broker}") client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % client.broker) +print(f"Disconnecting from {client.broker}") client.disconnect() diff --git a/examples/cpython/minimqtt_adafruitio_cpython.py b/examples/cpython/minimqtt_adafruitio_cpython.py index 7eb4f5fb..5fa03556 100644 --- a/examples/cpython/minimqtt_adafruitio_cpython.py +++ b/examples/cpython/minimqtt_adafruitio_cpython.py @@ -1,34 +1,39 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT -import time import socket +import ssl +import time +from os import getenv + import adafruit_minimqtt.adafruit_minimqtt as MQTT -### Secrets File Setup ### +### Key Setup ### -try: - from secrets import secrets -except ImportError: - print("Connection secrets are kept in secrets.py, please add them there!") - raise +# Add your Adafruit IO username and key to your env. +# example: +# export ADAFRUIT_AIO_USERNAME=your-aio-username +# export ADAFRUIT_AIO_KEY=your-aio-key + +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") ### Feeds ### # Setup a feed named 'photocell' for publishing to a feed -photocell_feed = secrets["aio_username"] + "/feeds/photocell" +photocell_feed = f"{aio_username}/feeds/photocell" # Setup a feed named 'onoff' for subscribing to changes -onoff_feed = secrets["aio_username"] + "/feeds/onoff" +onoff_feed = f"{aio_username}/feeds/onoff" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to Adafruit IO! Listening for topic changes on %s" % onoff_feed) + print(f"Connected to Adafruit IO! Listening for topic changes on {onoff_feed}") # Subscribe to all changes on the onoff_feed. client.subscribe(onoff_feed) @@ -41,16 +46,17 @@ def disconnected(client, userdata, rc): def message(client, topic, message): # This method is called when a topic the client is subscribed to # has a new message. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - port=1883, - username=secrets["aio_username"], - password=secrets["aio_key"], + broker="io.adafruit.com", + username=aio_username, + password=aio_key, socket_pool=socket, + is_ssl=True, + ssl_context=ssl.create_default_context(), ) # Setup the callback methods above @@ -68,7 +74,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d..." % photocell_val) + print(f"Sending photocell value: {photocell_val}...") mqtt_client.publish(photocell_feed, photocell_val) print("Sent!") photocell_val += 1 diff --git a/examples/cpython/minimqtt_simpletest_cpython.py b/examples/cpython/minimqtt_simpletest_cpython.py index f0d71a09..3254e2d2 100644 --- a/examples/cpython/minimqtt_simpletest_cpython.py +++ b/examples/cpython/minimqtt_simpletest_cpython.py @@ -1,19 +1,22 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT -import ssl import socket +import ssl +from os import getenv + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +# Add your Adafruit IO username and key to your env. +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +# example: +# export ADAFRUIT_AIO_USERNAME=your-aio-username +# export ADAFRUIT_AIO_KEY=your-aio-key +# export broker=io.adafruit.com + +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") ### Topic Setup ### @@ -23,16 +26,16 @@ # Adafruit IO-style Topic # Use this topic if you'd like to connect to io.adafruit.com -# mqtt_topic = secrets["aio_username"] + "/feeds/temperature" +# mqtt_topic = f"{aio_username}/feeds/temperature" + ### Code ### # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(mqtt_client, userdata, flags, rc): # This function will be called when the mqtt_client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(mqtt_client, userdata, rc): @@ -43,29 +46,29 @@ def disconnect(mqtt_client, userdata, rc): def subscribe(mqtt_client, userdata, topic, granted_qos): # This method is called when the mqtt_client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") def message(client, topic, message): # Method callled when a client's subscribed feed has a new value. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - username=secrets["aio_username"], - password=secrets["aio_key"], + broker=broker, + username=aio_username, + password=aio_key, socket_pool=socket, ssl_context=ssl.create_default_context(), ) @@ -78,17 +81,17 @@ def message(client, topic, message): mqtt_client.on_publish = publish mqtt_client.on_message = message -print("Attempting to connect to %s" % mqtt_client.broker) +print(f"Attempting to connect to {mqtt_client.broker}") mqtt_client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") mqtt_client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") mqtt_client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") mqtt_client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % mqtt_client.broker) +print(f"Disconnecting from {mqtt_client.broker}") mqtt_client.disconnect() diff --git a/examples/cpython/user_data.py b/examples/cpython/user_data.py new file mode 100644 index 00000000..14f13709 --- /dev/null +++ b/examples/cpython/user_data.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# SPDX-License-Identifier: Unlicense + + +""" +Demonstrate on how to use user_data for various callbacks. +""" + +import logging +import socket +import ssl +import sys + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +def on_connect(mqtt_client, user_data, flags, ret_code): + """ + connect callback + """ + logger = logging.getLogger(__name__) + logger.debug("Connected to MQTT Broker!") + logger.debug(f"Flags: {flags}\n RC: {ret_code}") + + +def on_subscribe(mqtt_client, user_data, topic, granted_qos): + """ + subscribe callback + """ + logger = logging.getLogger(__name__) + logger.debug(f"Subscribed to {topic} with QOS level {granted_qos}") + + +def on_message(client, topic, message): + """ + received message callback + """ + logger = logging.getLogger(__name__) + logger.debug(f"New message on topic {topic}: {message}") + + messages = client.user_data + if not messages.get(topic): + messages[topic] = [] + messages[topic].append(message) + + +def main(): + """ + Main loop. + """ + + logging.basicConfig() + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + # dictionary/map of topic to list of messages + messages = {} + + # connect to MQTT broker + mqtt = MQTT.MQTT( + broker="172.40.0.3", + port=1883, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + user_data=messages, + ) + + mqtt.on_connect = on_connect + mqtt.on_subscribe = on_subscribe + mqtt.on_message = on_message + + logger.info("Connecting to MQTT broker") + mqtt.connect() + logger.info("Subscribing") + mqtt.subscribe("foo/#", qos=0) + mqtt.add_topic_callback("foo/bar", on_message) + + i = 0 + while True: + i += 1 + logger.debug(f"Loop {i}") + # Make sure to stay connected to the broker e.g. in case of keep alive. + mqtt.loop(1) + + for topic, msg_list in messages.items(): + logger.info(f"Got {len(msg_list)} messages from topic {topic}") + for msg_cnt, msg in enumerate(msg_list): + logger.debug(f"#{msg_cnt}: {msg}") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/examples/esp32spi/minimqtt_adafruitio_esp32spi.py b/examples/esp32spi/minimqtt_adafruitio_esp32spi.py index 7141e8bc..7a52f5ee 100644 --- a/examples/esp32spi/minimqtt_adafruitio_esp32spi.py +++ b/examples/esp32spi/minimqtt_adafruitio_esp32spi.py @@ -2,24 +2,23 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut import neopixel from adafruit_esp32spi import adafruit_esp32spi -from adafruit_esp32spi import adafruit_esp32spi_wifimanager -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -### WiFi ### - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -34,36 +33,33 @@ spi = busio.SPI(board.SCK, board.MOSI, board.MISO) esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) """Use below for Most Boards""" -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards +status_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards """Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# status_pixel = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) # Uncomment below for an externally defined RGB LED # import adafruit_rgbled # from adafruit_esp32spi import PWMOut # RED_LED = PWMOut.PWMOut(esp, 26) # GREEN_LED = PWMOut.PWMOut(esp, 27) # BLUE_LED = PWMOut.PWMOut(esp, 25) -# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +# status_pixel = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) ### Feeds ### # Setup a feed named 'photocell' for publishing to a feed -photocell_feed = secrets["aio_username"] + "/feeds/photocell" +photocell_feed = f"{aio_username}/feeds/photocell" # Setup a feed named 'onoff' for subscribing to changes -onoff_feed = secrets["aio_username"] + "/feeds/onoff" +onoff_feed = f"{aio_username}/feeds/onoff" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to Adafruit IO! Listening for topic changes on %s" % onoff_feed) + print(f"Connected to Adafruit IO! Listening for topic changes on {onoff_feed}") # Subscribe to all changes on the onoff_feed. client.subscribe(onoff_feed) @@ -76,22 +72,24 @@ def disconnected(client, userdata, rc): def message(client, topic, message): # This method is called when a topic the client is subscribed to # has a new message. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Connect to WiFi print("Connecting to WiFi...") -wifi.connect() +esp.connect_AP(ssid, password) print("Connected!") -# Initialize MQTT interface with the esp interface -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( broker="io.adafruit.com", - username=secrets["aio_username"], - password=secrets["aio_key"], + username=aio_username, + password=aio_key, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -109,7 +107,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d..." % photocell_val) + print(f"Sending photocell value: {photocell_val}...") mqtt_client.publish(photocell_feed, photocell_val) print("Sent!") photocell_val += 1 diff --git a/examples/esp32spi/minimqtt_certificate_esp32spi.py b/examples/esp32spi/minimqtt_certificate_esp32spi.py index 50f002eb..5dcd7cd7 100644 --- a/examples/esp32spi/minimqtt_certificate_esp32spi.py +++ b/examples/esp32spi/minimqtt_certificate_esp32spi.py @@ -1,24 +1,25 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut import neopixel -from adafruit_esp32spi import adafruit_esp32spi -from adafruit_esp32spi import adafruit_esp32spi_wifimanager -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -### WiFi ### +# Get WiFi details and MQTT keys, ensure these are setup in settings.toml +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +broker = getenv("broker") +username = getenv("username") +paswword = getenv("paswword") -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +### WiFi ### # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -33,19 +34,17 @@ spi = busio.SPI(board.SCK, board.MOSI, board.MISO) esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) """Use below for Most Boards""" -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards +status_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards """Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# status_pixel = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) # Uncomment below for an externally defined RGB LED # import adafruit_rgbled # from adafruit_esp32spi import PWMOut # RED_LED = PWMOut.PWMOut(esp, 26) # GREEN_LED = PWMOut.PWMOut(esp, 27) # BLUE_LED = PWMOut.PWMOut(esp, 25) -# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +# status_pixel = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.WiFiManager(esp, ssid, password, status_pixel=status_pixel) ### Topic Setup ### @@ -59,13 +58,13 @@ ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(client, userdata, rc): @@ -76,26 +75,24 @@ def disconnect(client, userdata, rc): def subscribe(client, userdata, topic, granted_qos): # This method is called when the client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(client, userdata, topic, pid): # This method is called when the client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(client, userdata, topic, pid): # This method is called when the client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") # Get certificate and private key from a certificates.py file try: from certificates import DEVICE_CERT, DEVICE_KEY except ImportError: - print( - "Certificate and private key data is kept in certificates.py, please add them there!" - ) + print("Certificate and private key data is kept in certificates.py, please add them there!") raise # Set Device Certificate @@ -109,12 +106,16 @@ def publish(client, userdata, topic, pid): wifi.connect() print("Connected!") -# Initialize MQTT interface with the esp interface -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client client = MQTT.MQTT( - broker=secrets["broker"], username=secrets["user"], password=secrets["pass"] + broker=broker, + username=username, + password=password, + socket_pool=pool, + ssl_context=ssl_context, ) # Connect callback handlers to client @@ -124,17 +125,17 @@ def publish(client, userdata, topic, pid): client.on_unsubscribe = unsubscribe client.on_publish = publish -print("Attempting to connect to %s" % client.broker) +print(f"Attempting to connect to {client.broker}") client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % client.broker) +print(f"Disconnecting from {client.broker}") client.disconnect() diff --git a/examples/esp32spi/minimqtt_pub_sub_blocking_esp32spi.py b/examples/esp32spi/minimqtt_pub_sub_blocking_esp32spi.py index 734816ea..98e9cffa 100644 --- a/examples/esp32spi/minimqtt_pub_sub_blocking_esp32spi.py +++ b/examples/esp32spi/minimqtt_pub_sub_blocking_esp32spi.py @@ -2,24 +2,23 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut import neopixel from adafruit_esp32spi import adafruit_esp32spi -from adafruit_esp32spi import adafruit_esp32spi_wifimanager -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -### WiFi ### - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -34,33 +33,30 @@ spi = busio.SPI(board.SCK, board.MOSI, board.MISO) esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) """Use below for Most Boards""" -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards +status_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards """Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# status_pixel = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) # Uncomment below for an externally defined RGB LED # import adafruit_rgbled # from adafruit_esp32spi import PWMOut # RED_LED = PWMOut.PWMOut(esp, 26) # GREEN_LED = PWMOut.PWMOut(esp, 27) # BLUE_LED = PWMOut.PWMOut(esp, 25) -# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +# status_pixel = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) ### Adafruit IO Setup ### # Setup a feed named `testfeed` for publishing. -default_topic = secrets["user"] + "/feeds/testfeed" +default_topic = f"{aio_username}/feeds/testfeed" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to MQTT broker! Listening for topic changes on %s" % default_topic) + print(f"Connected to MQTT broker! Listening for topic changes on {default_topic}") # Subscribe to all changes on the default_topic feed. client.subscribe(default_topic) @@ -76,20 +72,24 @@ def message(client, topic, message): :param str topic: The topic of the feed with a new value. :param str message: The new value """ - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Connect to WiFi print("Connecting to WiFi...") -wifi.connect() +esp.connect_AP(ssid, password) print("Connected!") -# Initialize MQTT interface with the esp interface -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], username=secrets["user"], password=secrets["pass"] + broker="io.adafruit.com", + username=aio_username, + password=aio_key, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -109,7 +109,9 @@ def message(client, topic, message): mqtt_client.loop() except (ValueError, RuntimeError) as e: print("Failed to get data, retrying\n", e) - wifi.reset() + esp.reset() + time.sleep(1) + esp.connect_AP(ssid, password) mqtt_client.reconnect() continue time.sleep(1) diff --git a/examples/esp32spi/minimqtt_pub_sub_blocking_topic_callbacks_esp32spi.py b/examples/esp32spi/minimqtt_pub_sub_blocking_topic_callbacks_esp32spi.py index 60b4504f..3301891d 100644 --- a/examples/esp32spi/minimqtt_pub_sub_blocking_topic_callbacks_esp32spi.py +++ b/examples/esp32spi/minimqtt_pub_sub_blocking_topic_callbacks_esp32spi.py @@ -2,24 +2,27 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut import neopixel -from adafruit_esp32spi import adafruit_esp32spi -from adafruit_esp32spi import adafruit_esp32spi_wifimanager -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -### WiFi ### +# Get WiFi details and broker keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") +broker_port = int(getenv("broker_port", "8883")) # Port 1883 insecure, 8883 secure -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +### WiFi ### # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -34,24 +37,22 @@ spi = busio.SPI(board.SCK, board.MOSI, board.MISO) esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) """Use below for Most Boards""" -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards +status_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards """Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# status_pixel = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) # Uncomment below for an externally defined RGB LED # import adafruit_rgbled # from adafruit_esp32spi import PWMOut # RED_LED = PWMOut.PWMOut(esp, 26) # GREEN_LED = PWMOut.PWMOut(esp, 27) # BLUE_LED = PWMOut.PWMOut(esp, 25) -# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +# status_pixel = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.WiFiManager(esp, ssid, password, status_pixel=status_pixel) ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. @@ -65,24 +66,24 @@ def disconnected(client, userdata, rc): def subscribe(client, userdata, topic, granted_qos): # This method is called when the client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(client, userdata, topic, pid): # This method is called when the client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def on_battery_msg(client, topic, message): # Method called when device/batteryLife has a new value - print("Battery level: {}v".format(message)) + print(f"Battery level: {message}v") - # client.remove_topic_callback(secrets["aio_username"] + "/feeds/device.batterylevel") + # client.remove_topic_callback(f"{aio_username}/feeds/device.batterylevel") def on_message(client, topic, message): # Method callled when a client's subscribed feed has a new value. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Connect to WiFi @@ -90,10 +91,18 @@ def on_message(client, topic, message): wifi.connect() print("Connected!") -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client -client = MQTT.MQTT(broker=secrets["broker"], port=secrets["broker_port"]) +client = MQTT.MQTT( + broker=broker, + port=broker_port, + username=aio_username, + password=aio_key, + socket_pool=pool, + ssl_context=ssl_context, +) # Setup the callback methods above client.on_connect = connected @@ -101,16 +110,14 @@ def on_message(client, topic, message): client.on_subscribe = subscribe client.on_unsubscribe = unsubscribe client.on_message = on_message -client.add_topic_callback( - secrets["aio_username"] + "/feeds/device.batterylevel", on_battery_msg -) +client.add_topic_callback(f"{aio_username}/feeds/device.batterylevel", on_battery_msg) # Connect the client to the MQTT broker. print("Connecting to MQTT broker...") client.connect() # Subscribe to all notifications on the device group -client.subscribe(secrets["aio_username"] + "/groups/device", 1) +client.subscribe(f"{aio_username}/groups/device", 1) # Start a blocking message loop... # NOTE: NO code below this loop will execute diff --git a/examples/esp32spi/minimqtt_pub_sub_nonblocking_esp32spi.py b/examples/esp32spi/minimqtt_pub_sub_nonblocking_esp32spi.py index 6ff98d27..e396c53e 100644 --- a/examples/esp32spi/minimqtt_pub_sub_nonblocking_esp32spi.py +++ b/examples/esp32spi/minimqtt_pub_sub_nonblocking_esp32spi.py @@ -2,24 +2,23 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut import neopixel from adafruit_esp32spi import adafruit_esp32spi -from adafruit_esp32spi import adafruit_esp32spi_wifimanager -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -### WiFi ### - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -34,32 +33,29 @@ spi = busio.SPI(board.SCK, board.MOSI, board.MISO) esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) """Use below for Most Boards""" -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards +status_pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards """Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# status_pixel = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) # Uncomment below for an externally defined RGB LED # import adafruit_rgbled # from adafruit_esp32spi import PWMOut # RED_LED = PWMOut.PWMOut(esp, 26) # GREEN_LED = PWMOut.PWMOut(esp, 27) # BLUE_LED = PWMOut.PWMOut(esp, 25) -# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +# status_pixel = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) ### Adafruit IO Setup ### # Setup a feed named `testfeed` for publishing. -default_topic = secrets["user"] + "/feeds/testfeed" +default_topic = f"{aio_username}/feeds/testfeed" + ### Code ### # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to MQTT broker! Listening for topic changes on %s" % default_topic) + print(f"Connected to MQTT broker! Listening for topic changes on {default_topic}") # Subscribe to all changes on the default_topic feed. client.subscribe(default_topic) @@ -75,20 +71,24 @@ def message(client, topic, message): :param str topic: The topic of the feed with a new value. :param str message: The new value """ - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Connect to WiFi print("Connecting to WiFi...") -wifi.connect() +esp.connect_AP(ssid, password) print("Connected!") -# Initialize MQTT interface with the esp interface -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], username=secrets["user"], password=secrets["pass"] + broker="io.adafruit.com", + username=aio_username, + password=aio_key, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -105,7 +105,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d" % photocell_val) + print(f"Sending photocell value: {photocell_val}") mqtt_client.publish(default_topic, photocell_val) photocell_val += 1 - time.sleep(0.5) + time.sleep(3) diff --git a/examples/esp32spi/minimqtt_pub_sub_pyportal_esp32spi.py b/examples/esp32spi/minimqtt_pub_sub_pyportal_esp32spi.py index 6409cdd0..cacb37ab 100644 --- a/examples/esp32spi/minimqtt_pub_sub_pyportal_esp32spi.py +++ b/examples/esp32spi/minimqtt_pub_sub_pyportal_esp32spi.py @@ -2,32 +2,31 @@ # SPDX-License-Identifier: MIT import time -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from os import getenv + +import adafruit_connection_manager import adafruit_pyportal import adafruit_minimqtt.adafruit_minimqtt as MQTT pyportal = adafruit_pyportal.PyPortal() -### WiFi ### - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise +# Get Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") # ------------- MQTT Topic Setup ------------- # mqtt_topic = "test/topic" + ### Code ### # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Subscribing to %s" % (mqtt_topic)) + print(f"Subscribing to {mqtt_topic}") client.subscribe(mqtt_topic) @@ -42,7 +41,7 @@ def message(client, topic, message): :param str topic: The topic of the feed with a new value. :param str message: The new value """ - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") # Connect to WiFi @@ -50,16 +49,17 @@ def message(client, topic, message): pyportal.network.connect() print("Connected!") -# Initialize MQTT interface with the esp interface -# pylint: disable=protected-access -MQTT.set_socket(socket, pyportal.network._wifi.esp) +pool = adafruit_connection_manager.get_radio_socketpool(pyportal.network._wifi.esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(pyportal.network._wifi.esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - username=secrets["user"], - password=secrets["pass"], + broker=broker, + username=aio_username, + password=aio_key, is_ssl=False, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -76,7 +76,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d" % photocell_val) + print(f"Sending photocell value: {photocell_val}") mqtt_client.publish(mqtt_topic, photocell_val) photocell_val += 1 time.sleep(1) diff --git a/examples/esp32spi/minimqtt_simpletest_esp32spi.py b/examples/esp32spi/minimqtt_simpletest_esp32spi.py index 254253dc..f17f44dd 100644 --- a/examples/esp32spi/minimqtt_simpletest_esp32spi.py +++ b/examples/esp32spi/minimqtt_simpletest_esp32spi.py @@ -1,27 +1,23 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT + +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut from adafruit_esp32spi import adafruit_esp32spi -import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from digitalio import DigitalInOut + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = secrets["aio_username"] -aio_key = secrets["aio_key"] +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") # If you are using a board with pre-defined ESP32 Pins: esp32_cs = DigitalInOut(board.ESP_CS) @@ -39,11 +35,11 @@ print("Connecting to AP...") while not esp.is_connected: try: - esp.connect_AP(secrets["ssid"], secrets["password"]) + esp.connect_AP(ssid, password) except RuntimeError as e: print("could not connect to AP, retrying: ", e) continue -print("Connected to", str(esp.ssid, "utf-8"), "\tRSSI:", esp.rssi) +print("Connected to", esp.ap_info.ssid, "\tRSSI:", esp.ap_info.rssi) ### Topic Setup ### @@ -53,17 +49,17 @@ # Adafruit IO-style Topic # Use this topic if you'd like to connect to io.adafruit.com -# mqtt_topic = secrets["aio_username"] + '/feeds/temperature' +# mqtt_topic = f"{aio_username}/feeds/temperature" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(mqtt_client, userdata, flags, rc): # This function will be called when the mqtt_client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(mqtt_client, userdata, rc): @@ -74,32 +70,33 @@ def disconnect(mqtt_client, userdata, rc): def subscribe(mqtt_client, userdata, topic, granted_qos): # This method is called when the mqtt_client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") def message(client, topic, message): - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") -socket.set_interface(esp) -MQTT.set_socket(socket, esp) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - port=secrets["port"], - username=secrets["username"], - password=secrets["password"], + broker=broker, + username=aio_username, + password=aio_key, + socket_pool=pool, + ssl_context=ssl_context, ) # Connect callback handlers to mqtt_client @@ -110,17 +107,17 @@ def message(client, topic, message): mqtt_client.on_publish = publish mqtt_client.on_message = message -print("Attempting to connect to %s" % mqtt_client.broker) +print(f"Attempting to connect to {mqtt_client.broker}") mqtt_client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") mqtt_client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") mqtt_client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") mqtt_client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % mqtt_client.broker) +print(f"Disconnecting from {mqtt_client.broker}") mqtt_client.disconnect() diff --git a/examples/ethernet/minimqtt_adafruitio_eth.py b/examples/ethernet/minimqtt_adafruitio_eth.py index 753bf473..6474be24 100755 --- a/examples/ethernet/minimqtt_adafruitio_eth.py +++ b/examples/ethernet/minimqtt_adafruitio_eth.py @@ -2,21 +2,20 @@ # SPDX-License-Identifier: MIT import time +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut - from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K -import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Get Adafruit IO details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("Adafruit IO secrets are kept in secrets.py, please add them there!") - raise +# Get Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") cs = DigitalInOut(board.D10) spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) @@ -27,19 +26,19 @@ ### Feeds ### # Setup a feed named 'photocell' for publishing to a feed -photocell_feed = secrets["aio_username"] + "/feeds/photocell" +photocell_feed = f"{aio_username}/feeds/photocell" # Setup a feed named 'onoff' for subscribing to changes -onoff_feed = secrets["aio_username"] + "/feeds/onoff" +onoff_feed = f"{aio_username}/feeds/onoff" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to Adafruit IO! Listening for topic changes on %s" % onoff_feed) + print(f"Connected to Adafruit IO! Listening for topic changes on {onoff_feed}") # Subscribe to all changes on the onoff_feed. client.subscribe(onoff_feed) @@ -52,19 +51,21 @@ def disconnected(client, userdata, rc): def message(client, topic, message): # This method is called when a topic the client is subscribed to # has a new message. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") -# Initialize MQTT interface with the ethernet interface -MQTT.set_socket(socket, eth) +pool = adafruit_connection_manager.get_radio_socketpool(eth) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(eth) # Set up a MiniMQTT Client # NOTE: We'll need to connect insecurely for ethernet configurations. mqtt_client = MQTT.MQTT( broker="io.adafruit.com", - username=secrets["aio_username"], - password=secrets["aio_key"], + username=aio_username, + password=aio_key, is_ssl=False, + socket_pool=pool, + ssl_context=ssl_context, ) # Setup the callback methods above @@ -82,7 +83,7 @@ def message(client, topic, message): mqtt_client.loop() # Send a new message - print("Sending photocell value: %d..." % photocell_val) + print(f"Sending photocell value: {photocell_val}...") mqtt_client.publish(photocell_feed, photocell_val) print("Sent!") photocell_val += 1 diff --git a/examples/ethernet/minimqtt_simpletest_eth.py b/examples/ethernet/minimqtt_simpletest_eth.py index c585cf78..5130f496 100644 --- a/examples/ethernet/minimqtt_simpletest_eth.py +++ b/examples/ethernet/minimqtt_simpletest_eth.py @@ -1,20 +1,21 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT +from os import getenv + +import adafruit_connection_manager import board import busio -from digitalio import DigitalInOut from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K -import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket +from digitalio import DigitalInOut import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Get MQTT details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("MQTT secrets are kept in secrets.py, please add them there!") - raise +# Get Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") cs = DigitalInOut(board.D10) spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) @@ -33,13 +34,13 @@ ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(client, userdata, rc): @@ -50,25 +51,31 @@ def disconnect(client, userdata, rc): def subscribe(client, userdata, topic, granted_qos): # This method is called when the client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(client, userdata, topic, pid): # This method is called when the client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(client, userdata, topic, pid): # This method is called when the client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") -# Initialize MQTT interface with the ethernet interface -MQTT.set_socket(socket, eth) +pool = adafruit_connection_manager.get_radio_socketpool(eth) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(eth) # Set up a MiniMQTT Client +# NOTE: We'll need to connect insecurely for ethernet configurations. client = MQTT.MQTT( - broker=secrets["broker"], username=secrets["user"], password=secrets["pass"] + broker=broker, + username=aio_username, + password=aio_key, + is_ssl=False, + socket_pool=pool, + ssl_context=ssl_context, ) # Connect callback handlers to client @@ -78,17 +85,17 @@ def publish(client, userdata, topic, pid): client.on_unsubscribe = unsubscribe client.on_publish = publish -print("Attempting to connect to %s" % client.broker) +print(f"Attempting to connect to {client.broker}") client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % client.broker) +print(f"Disconnecting from {client.broker}") client.disconnect() diff --git a/examples/minimqtt_simpletest.py b/examples/minimqtt_simpletest.py index 0a4ca3fa..9746eab8 100644 --- a/examples/minimqtt_simpletest.py +++ b/examples/minimqtt_simpletest.py @@ -1,49 +1,65 @@ # SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT -import ssl -import socketpool -import wifi +from os import getenv + +import adafruit_connection_manager +import board +import busio +from adafruit_esp32spi import adafruit_esp32spi +from digitalio import DigitalInOut + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = secrets["aio_username"] -aio_key = secrets["aio_key"] - -print("Connecting to %s" % secrets["ssid"]) -wifi.radio.connect(secrets["ssid"], secrets["password"]) -print("Connected to %s!" % secrets["ssid"]) +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") + +# If you are using a board with pre-defined ESP32 Pins: +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) + +# If you have an externally connected ESP32: +# esp32_cs = DigitalInOut(board.D9) +# esp32_ready = DigitalInOut(board.D10) +# esp32_reset = DigitalInOut(board.D5) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +print("Connecting to AP...") +while not esp.is_connected: + try: + esp.connect_AP(ssid, password) + except RuntimeError as e: + print("could not connect to AP, retrying: ", e) + continue +print("Connected to", esp.ap_info.ssid, "\tRSSI:", esp.ap_info.rssi) ### Topic Setup ### # MQTT Topic # Use this topic if you'd like to connect to a standard MQTT broker -mqtt_topic = "test/topic" +# mqtt_topic = "test/topic" # Adafruit IO-style Topic # Use this topic if you'd like to connect to io.adafruit.com -# mqtt_topic = secrets["aio_username"] + '/feeds/temperature' +mqtt_topic = f"{aio_username}/feeds/temperature" + ### Code ### + + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connect(mqtt_client, userdata, flags, rc): # This function will be called when the mqtt_client is connected # successfully to the broker. print("Connected to MQTT Broker!") - print("Flags: {0}\n RC: {1}".format(flags, rc)) + print(f"Flags: {flags}\n RC: {rc}") def disconnect(mqtt_client, userdata, rc): @@ -54,35 +70,33 @@ def disconnect(mqtt_client, userdata, rc): def subscribe(mqtt_client, userdata, topic, granted_qos): # This method is called when the mqtt_client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def publish(mqtt_client, userdata, topic, pid): # This method is called when the mqtt_client publishes data to a feed. - print("Published to {0} with PID {1}".format(topic, pid)) + print(f"Published to {topic} with PID {pid}") def message(client, topic, message): - # Method callled when a client's subscribed feed has a new value. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") -# Create a socket pool -pool = socketpool.SocketPool(wifi.radio) +pool = adafruit_connection_manager.get_radio_socketpool(esp) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(esp) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - port=secrets["port"], - username=secrets["aio_username"], - password=secrets["aio_key"], + broker="io.adafruit.com", + username=aio_username, + password=aio_key, socket_pool=pool, - ssl_context=ssl.create_default_context(), + ssl_context=ssl_context, ) # Connect callback handlers to mqtt_client @@ -93,17 +107,17 @@ def message(client, topic, message): mqtt_client.on_publish = publish mqtt_client.on_message = message -print("Attempting to connect to %s" % mqtt_client.broker) +print(f"Attempting to connect to {mqtt_client.broker}") mqtt_client.connect() -print("Subscribing to %s" % mqtt_topic) +print(f"Subscribing to {mqtt_topic}") mqtt_client.subscribe(mqtt_topic) -print("Publishing to %s" % mqtt_topic) +print(f"Publishing to {mqtt_topic}") mqtt_client.publish(mqtt_topic, "Hello Broker!") -print("Unsubscribing from %s" % mqtt_topic) +print(f"Unsubscribing from {mqtt_topic}") mqtt_client.unsubscribe(mqtt_topic) -print("Disconnecting from %s" % mqtt_client.broker) +print(f"Disconnecting from {mqtt_client.broker}") mqtt_client.disconnect() diff --git a/examples/native_networking/minimqtt_adafruitio_native_networking.py b/examples/native_networking/minimqtt_adafruitio_native_networking.py index a4f5ecaa..85dc0b32 100644 --- a/examples/native_networking/minimqtt_adafruitio_native_networking.py +++ b/examples/native_networking/minimqtt_adafruitio_native_networking.py @@ -2,46 +2,40 @@ # SPDX-License-Identifier: MIT import time -import ssl -import socketpool +from os import getenv + +import adafruit_connection_manager import wifi + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = secrets["aio_username"] -aio_key = secrets["aio_key"] - -print("Connecting to %s" % secrets["ssid"]) -wifi.radio.connect(secrets["ssid"], secrets["password"]) -print("Connected to %s!" % secrets["ssid"]) +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") +broker = getenv("broker", "io.adafruit.com") + +print(f"Connecting to {ssid}") +wifi.radio.connect(ssid, password) +print(f"Connected to {ssid}!") ### Feeds ### # Setup a feed named 'photocell' for publishing to a feed -photocell_feed = secrets["aio_username"] + "/feeds/photocell" +photocell_feed = "photocell" # Setup a feed named 'onoff' for subscribing to changes -onoff_feed = secrets["aio_username"] + "/feeds/onoff" +onoff_feed = "onoff" ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to Adafruit IO! Listening for topic changes on %s" % onoff_feed) + print(f"Connected to Adafruit IO! Listening for topic changes on {onoff_feed}") # Subscribe to all changes on the onoff_feed. client.subscribe(onoff_feed) @@ -54,20 +48,28 @@ def disconnected(client, userdata, rc): def message(client, topic, message): # This method is called when a topic the client is subscribed to # has a new message. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") + +# Create a socket pool and ssl_context +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) -# Create a socket pool -pool = socketpool.SocketPool(wifi.radio) +# If you need to use certificate/key pair authentication (e.g. X.509), you can load them in the +# ssl context by uncommenting the lines below and adding the following keys to your settings.toml: +# "device_cert_path" - Path to the Device Certificate +# "device_key_path" - Path to the RSA Private Key +# ssl_context.load_cert_chain( +# certfile=getenv("device_cert_path"), keyfile=getenv("device_key_path") +# ) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - port=secrets["port"], - username=secrets["aio_username"], - password=secrets["aio_key"], + broker=broker, + username=aio_username, + password=aio_key, socket_pool=pool, - ssl_context=ssl.create_default_context(), + ssl_context=ssl_context, ) # Setup the callback methods above @@ -82,10 +84,10 @@ def message(client, topic, message): photocell_val = 0 while True: # Poll the message queue - mqtt_client.loop() + mqtt_client.loop(timeout=1) # Send a new message - print("Sending photocell value: %d..." % photocell_val) + print(f"Sending photocell value: {photocell_val}...") mqtt_client.publish(photocell_feed, photocell_val) print("Sent!") photocell_val += 1 diff --git a/examples/native_networking/minimqtt_pub_sub_blocking_native_networking.py b/examples/native_networking/minimqtt_pub_sub_blocking_native_networking.py index 58dbc7f7..9c345154 100644 --- a/examples/native_networking/minimqtt_pub_sub_blocking_native_networking.py +++ b/examples/native_networking/minimqtt_pub_sub_blocking_native_networking.py @@ -2,43 +2,40 @@ # SPDX-License-Identifier: MIT import time -import ssl -import socketpool +from os import getenv + +import adafruit_connection_manager import wifi + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other +# Add settings.toml to your filesystem CIRCUITPY_WIFI_SSID and CIRCUITPY_WIFI_PASSWORD keys +# with your WiFi credentials. DO NOT share that file or commit it into Git or other # source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = secrets["aio_username"] -aio_key = secrets["aio_key"] - -print("Connecting to %s" % secrets["ssid"]) -wifi.radio.connect(secrets["ssid"], secrets["password"]) -print("Connected to %s!" % secrets["ssid"]) + +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") + +print(f"Connecting to {ssid}") +wifi.radio.connect(ssid, password) +print(f"Connected to {ssid}!") ### Adafruit IO Setup ### # Setup a feed named `testfeed` for publishing. -default_topic = secrets["aio_username"] + "/feeds/testfeed" +default_topic = f"{aio_username}/feeds/testfeed" + ### Code ### # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. - print("Connected to MQTT broker! Listening for topic changes on %s" % default_topic) + print(f"Connected to MQTT broker! Listening for topic changes on {default_topic}") # Subscribe to all changes on the default_topic feed. client.subscribe(default_topic) @@ -54,20 +51,28 @@ def message(client, topic, message): :param str topic: The topic of the feed with a new value. :param str message: The new value """ - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") + +# Create a socket pool and ssl_context +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) -# Create a socket pool -pool = socketpool.SocketPool(wifi.radio) +# If you need to use certificate/key pair authentication (e.g. X.509), you can load them in the +# ssl context by uncommenting the lines below and adding the following keys to your settings.toml: +# "device_cert_path" - Path to the Device Certificate +# "device_key_path" - Path to the RSA Private Key +# ssl_context.load_cert_chain( +# certfile=getenv("device_cert_path"), keyfile=getenv("device_key_path") +# ) # Set up a MiniMQTT Client mqtt_client = MQTT.MQTT( - broker=secrets["broker"], - port=secrets["port"], - username=secrets["aio_username"], - password=secrets["aio_key"], + broker="io.adafruit.com", + username=aio_username, + password=aio_key, socket_pool=pool, - ssl_context=ssl.create_default_context(), + ssl_context=ssl_context, ) # Setup the callback methods above @@ -84,7 +89,7 @@ def message(client, topic, message): # NOTE: Network reconnection is handled within this loop while True: try: - mqtt_client.loop() + mqtt_client.loop(timeout=1) except (ValueError, RuntimeError) as e: print("Failed to get data, retrying\n", e) wifi.reset() diff --git a/examples/native_networking/minimqtt_pub_sub_blocking_topic_callbacks_native_networking.py b/examples/native_networking/minimqtt_pub_sub_blocking_topic_callbacks_native_networking.py index 2a2eddf3..8867925e 100644 --- a/examples/native_networking/minimqtt_pub_sub_blocking_topic_callbacks_native_networking.py +++ b/examples/native_networking/minimqtt_pub_sub_blocking_topic_callbacks_native_networking.py @@ -2,35 +2,28 @@ # SPDX-License-Identifier: MIT import time -import ssl -import socketpool +from os import getenv + +import adafruit_connection_manager import wifi + import adafruit_minimqtt.adafruit_minimqtt as MQTT -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# Set your Adafruit IO Username and Key in secrets.py -# (visit io.adafruit.com if you need to create an account, -# or if you need your Adafruit IO key.) -aio_username = secrets["aio_username"] -aio_key = secrets["aio_key"] - -print("Connecting to %s" % secrets["ssid"]) -wifi.radio.connect(secrets["ssid"], secrets["password"]) -print("Connected to %s!" % secrets["ssid"]) +# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml +# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.) +ssid = getenv("CIRCUITPY_WIFI_SSID") +password = getenv("CIRCUITPY_WIFI_PASSWORD") +aio_username = getenv("ADAFRUIT_AIO_USERNAME") +aio_key = getenv("ADAFRUIT_AIO_KEY") + +print(f"Connecting to {ssid}") +wifi.radio.connect(ssid, password) +print(f"Connected to {ssid}!") ### Code ### + # Define callback methods which are called when events occur -# pylint: disable=unused-argument, redefined-outer-name def connected(client, userdata, flags, rc): # This function will be called when the client is connected # successfully to the broker. @@ -44,37 +37,45 @@ def disconnected(client, userdata, rc): def subscribe(client, userdata, topic, granted_qos): # This method is called when the client subscribes to a new feed. - print("Subscribed to {0} with QOS level {1}".format(topic, granted_qos)) + print(f"Subscribed to {topic} with QOS level {granted_qos}") def unsubscribe(client, userdata, topic, pid): # This method is called when the client unsubscribes from a feed. - print("Unsubscribed from {0} with PID {1}".format(topic, pid)) + print(f"Unsubscribed from {topic} with PID {pid}") def on_battery_msg(client, topic, message): # Method called when device/batteryLife has a new value - print("Battery level: {}v".format(message)) + print(f"Battery level: {message}v") - # client.remove_topic_callback(secrets["aio_username"] + "/feeds/device.batterylevel") + # client.remove_topic_callback(f"{aio_username}/feeds/device.batterylevel") def on_message(client, topic, message): # Method callled when a client's subscribed feed has a new value. - print("New message on topic {0}: {1}".format(topic, message)) + print(f"New message on topic {topic}: {message}") + +# Create a socket pool and ssl_context +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) -# Create a socket pool -pool = socketpool.SocketPool(wifi.radio) +# If you need to use certificate/key pair authentication (e.g. X.509), you can load them in the +# ssl context by uncommenting the lines below and adding the following keys to your settings.toml: +# "device_cert_path" - Path to the Device Certificate +# "device_key_path" - Path to the RSA Private Key +# ssl_context.load_cert_chain( +# certfile=getenv("device_cert_path"), keyfile=getenv("device_key_path") +# ) # Set up a MiniMQTT Client client = MQTT.MQTT( - broker=secrets["broker"], - port=secrets["port"], - username=secrets["aio_username"], - password=secrets["aio_key"], + broker="io.adafruit.com", + username=aio_username, + password=aio_key, socket_pool=pool, - ssl_context=ssl.create_default_context(), + ssl_context=ssl_context, ) # Setup the callback methods above @@ -83,22 +84,20 @@ def on_message(client, topic, message): client.on_subscribe = subscribe client.on_unsubscribe = unsubscribe client.on_message = on_message -client.add_topic_callback( - secrets["aio_username"] + "/feeds/device.batterylevel", on_battery_msg -) +client.add_topic_callback(f"{aio_username}/feeds/device.batterylevel", on_battery_msg) # Connect the client to the MQTT broker. print("Connecting to MQTT broker...") client.connect() # Subscribe to all notifications on the device group -client.subscribe(secrets["aio_username"] + "/groups/device", 1) +client.subscribe(f"{aio_username}/groups/device", 1) # Start a blocking message loop... # NOTE: NO code below this loop will execute while True: try: - client.loop() + client.loop(timeout=1) except (ValueError, RuntimeError) as e: print("Failed to get data, retrying\n", e) wifi.reset() diff --git a/optional_requirements.txt b/optional_requirements.txt new file mode 100644 index 00000000..d4e27c4d --- /dev/null +++ b/optional_requirements.txt @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3d88672c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +[build-system] +requires = [ + "setuptools", + "wheel", + "setuptools-scm", +] + +[project] +name = "adafruit-circuitpython-minimqtt" +description = "MQTT client library for CircuitPython" +version = "0.0.0+auto.0" +readme = "README.rst" +authors = [ + {name = "Adafruit Industries", email = "circuitpython@adafruit.com"} +] +urls = {Homepage = "https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT"} +keywords = [ + "adafruit", + "blinka", + "circuitpython", + "micropython", + "minimqtt", + "mqtt,", + "client,", + "socket", +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Embedded Systems", + "Topic :: System :: Hardware", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] +dynamic = ["dependencies", "optional-dependencies"] + +[tool.setuptools] +packages = ["adafruit_minimqtt"] + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} +optional-dependencies = {optional = {file = ["optional_requirements.txt"]}} diff --git a/requirements.txt b/requirements.txt index 17a850d4..8075f629 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries # # SPDX-License-Identifier: Unlicense Adafruit-Blinka +Adafruit-Circuitpython-ConnectionManager +adafruit-circuitpython-ticks diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..0cbd6c66 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +target-version = "py38" +line-length = 100 + +# Enable preview features. +preview = true + +[lint] +select = ["I", "PL", "UP"] + +extend-select = [ + "D419", # empty-docstring + "E501", # line-too-long + "W291", # trailing-whitespace + "PLC0414", # useless-import-alias + "PLC2401", # non-ascii-name + "PLC2801", # unnecessary-dunder-call + "PLC3002", # unnecessary-direct-lambda-call + # "E999", # syntax-error + "PLE0101", # return-in-init + "F706", # return-outside-function + "F704", # yield-outside-function + "PLE0116", # continue-in-finally + "PLE0117", # nonlocal-without-binding + "PLE0241", # duplicate-bases + "PLE0302", # unexpected-special-method-signature + "PLE0604", # invalid-all-object + "PLE0605", # invalid-all-format + "PLE0643", # potential-index-error + "F821", # undefined name + "PLE0704", # misplaced-bare-raise + "PLE1141", # dict-iter-missing-items + "PLE1142", # await-outside-async + "PLE1205", # logging-too-many-args + "PLE1206", # logging-too-few-args + "PLE1307", # bad-string-format-type + "PLE1310", # bad-str-strip-call + "PLE1507", # invalid-envvar-value + "PLE2502", # bidirectional-unicode + "PLE2510", # invalid-character-backspace + "PLE2512", # invalid-character-sub + "PLE2513", # invalid-character-esc + "PLE2514", # invalid-character-nul + "PLE2515", # invalid-character-zero-width-space + "PLR0124", # comparison-with-itself + "PLR0202", # no-classmethod-decorator + "PLR0203", # no-staticmethod-decorator + "UP004", # useless-object-inheritance + "PLR0206", # property-with-parameters + "PLR0904", # too-many-public-methods + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0914", # too-many-locals + "PLR0915", # too-many-statements + "PLR0916", # too-many-boolean-expressions + "PLR1702", # too-many-nested-blocks + "PLR1704", # redefined-argument-from-local + "PLR1711", # useless-return + "C416", # unnecessary-comprehension + "PLR1733", # unnecessary-dict-index-lookup + "PLR1736", # unnecessary-list-index-lookup + + # ruff reports this rule is unstable + #"PLR6301", # no-self-use + + "PLW0108", # unnecessary-lambda + "PLW0120", # useless-else-on-loop + "PLW0127", # self-assigning-variable + "PLW0129", # assert-on-string-literal + "B033", # duplicate-value + "PLW0131", # named-expr-without-context + "PLW0245", # super-without-brackets + "PLW0406", # import-self + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "PLW0604", # global-at-module-level + + # fails on the try: import typing used by libraries + #"F401", # unused-import + + "F841", # unused-variable + "E722", # bare-except + "PLW0711", # binary-op-exception + "PLW1501", # bad-open-mode + "PLW1508", # invalid-envvar-default + "PLW1509", # subprocess-popen-preexec-fn + "PLW2101", # useless-with-lock + "PLW3301", # nested-min-max +] + +ignore = [ + "PLR2004", # magic-value-comparison + "UP030", # format literals + "PLW1514", # unspecified-encoding + +] + +[format] +line-ending = "lf" diff --git a/setup.py b/setup.py deleted file mode 100644 index 696d4b2d..00000000 --- a/setup.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries -# -# SPDX-License-Identifier: MIT - -"""A setuptools based setup module. - -See: -https://packaging.python.org/en/latest/distributing.html -https://github.com/pypa/sampleproject -""" - -from setuptools import setup, find_packages - -# To use a consistent encoding -from codecs import open -from os import path - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, "README.rst"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="adafruit-circuitpython-minimqtt", - use_scm_version=True, - setup_requires=["setuptools_scm"], - description="MQTT client library for CircuitPython", - long_description=long_description, - long_description_content_type="text/x-rst", - # The project's main homepage. - url="https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT", - # Author details - author="Adafruit Industries", - author_email="circuitpython@adafruit.com", - install_requires=["Adafruit-Blinka"], - # Choose your license - license="MIT", - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Topic :: System :: Hardware", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - ], - # What does your project relate to? - keywords="adafruit blinka circuitpython micropython minimqtt mqtt, client, socket", - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - # TODO: IF LIBRARY FILES ARE A PACKAGE FOLDER, - # CHANGE `py_modules=['...']` TO `packages=['...']` - packages=["adafruit_minimqtt"], -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..2b8b03ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2023 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""PyTest Setup""" + +import adafruit_connection_manager +import pytest + + +@pytest.fixture(autouse=True) +def reset_connection_manager(monkeypatch): + """Reset the ConnectionManager, since it's a singleton and will hold data""" + monkeypatch.setattr( + "adafruit_minimqtt.adafruit_minimqtt.get_connection_manager", + adafruit_connection_manager.ConnectionManager, + ) diff --git a/tests/mocket.py b/tests/mocket.py new file mode 100644 index 00000000..31b41015 --- /dev/null +++ b/tests/mocket.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""fake socket class for protocol level testing""" + +from unittest import mock + + +class Mocket: + """ + Mock Socket tailored for MiniMQTT testing. Records sent data, + hands out pre-recorded reply. + + Inspired by the Mocket class from Adafruit_CircuitPython_Requests + """ + + def __init__(self, to_send): + self._to_send = to_send + + self.sent = bytearray() + + self.timeout = mock.Mock() + self.connect = mock.Mock() + self.close = mock.Mock() + + def send(self, bytes_to_send): + """merely record the bytes. return the length of this bytearray.""" + self.sent.extend(bytes_to_send) + return len(bytes_to_send) + + # MiniMQTT checks for the presence of "recv_into" and switches behavior based on that. + def recv_into(self, retbuf, bufsize): + """return data from internal buffer""" + size = min(bufsize, len(self._to_send)) + if size == 0: + return size + chop = self._to_send[0:size] + retbuf[0:] = chop + self._to_send = self._to_send[size:] + return size diff --git a/tests/test_backoff.py b/tests/test_backoff.py new file mode 100644 index 00000000..7adff324 --- /dev/null +++ b/tests/test_backoff.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""exponential back-off tests""" + +import socket +import ssl +import time +from unittest.mock import call, patch + +import pytest + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +class TestExpBackOff: + """basic exponential back-off test""" + + connect_times = [] + raise_exception = None + + def fake_connect(self, arg): + """connect() replacement that records the call times and always raises OSError""" + self.connect_times.append(time.monotonic()) + raise self.raise_exception + + def test_failing_connect(self) -> None: + """test that exponential back-off is used when connect() always raises OSError""" + # use RFC 1918 address to avoid dealing with IPv6 in the call list below + host = "172.40.0.3" + port = 1883 + self.connect_times = [] + error_code = MQTT.CONNACK_ERROR_SERVER_UNAVAILABLE + self.raise_exception = MQTT.MMQTTException(MQTT.CONNACK_ERRORS[error_code], code=error_code) + + with patch.object(socket.socket, "connect") as mock_method: + mock_method.side_effect = self.fake_connect + + connect_retries = 3 + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + connect_retries=connect_retries, + ) + print("connecting") + with pytest.raises(MQTT.MMQTTException) as context: + mqtt_client.connect() + assert mqtt_client._sock is None + assert "Repeated connect failures" in str(context) + + mock_method.assert_called() + calls = [call((host, port)) for _ in range(0, connect_retries)] + mock_method.assert_has_calls(calls) + + print(f"connect() call times: {self.connect_times}") + for i in range(1, connect_retries): + assert self.connect_times[i] >= 2**i + + def test_unauthorized(self) -> None: + """test that exponential back-off is used when connect() always raises OSError""" + # use RFC 1918 address to avoid dealing with IPv6 in the call list below + host = "172.40.0.3" + port = 1883 + self.connect_times = [] + error_code = MQTT.CONNACK_ERROR_UNAUTHORIZED + self.raise_exception = MQTT.MMQTTException(MQTT.CONNACK_ERRORS[error_code], code=error_code) + + with patch.object(socket.socket, "connect") as mock_method: + mock_method.side_effect = self.fake_connect + + connect_retries = 3 + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + connect_retries=connect_retries, + ) + print("connecting") + with pytest.raises(MQTT.MMQTTException) as context: + mqtt_client.connect() + assert mqtt_client._sock is None + assert "Connection Refused - Unauthorized" in str(context) + + mock_method.assert_called() + assert len(self.connect_times) == 1 diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 00000000..898d8946 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,252 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +# ruff: noqa: PLR6301 no-self-use + +"""loop() tests""" + +import errno +import random +import socket +import ssl +import time +from unittest import mock +from unittest.mock import patch + +import pytest + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +class Nulltet: + """ + Mock Socket that does nothing. + + Inspired by the Mocket class from Adafruit_CircuitPython_Requests + """ + + def __init__(self): + self.sent = bytearray() + + self.timeout = mock.Mock() + self.connect = mock.Mock() + self.close = mock.Mock() + + def send(self, bytes_to_send): + """ + Record the bytes. return the length of this bytearray. + """ + self.sent.extend(bytes_to_send) + return len(bytes_to_send) + + # MiniMQTT checks for the presence of "recv_into" and switches behavior based on that. + def recv_into(self, retbuf, bufsize): + """Always raise timeout exception.""" + exc = OSError() + exc.errno = errno.ETIMEDOUT + raise exc + + +class Pingtet: + """ + Mock Socket tailored for PINGREQ testing. + Records sent data, hands out PINGRESP for each PINGREQ received. + + Inspired by the Mocket class from Adafruit_CircuitPython_Requests + """ + + PINGRESP = bytearray([0xD0, 0x00]) + + def __init__(self): + self._to_send = self.PINGRESP + + self.sent = bytearray() + + self.timeout = mock.Mock() + self.connect = mock.Mock() + self.close = mock.Mock() + + self._got_pingreq = False + + def send(self, bytes_to_send): + """ + Recognize PINGREQ and record the indication that it was received. + Assumes it was sent in one chunk (of 2 bytes). + Also record the bytes. return the length of this bytearray. + """ + self.sent.extend(bytes_to_send) + if bytes_to_send == b"\xc0\0": + self._got_pingreq = True + return len(bytes_to_send) + + # MiniMQTT checks for the presence of "recv_into" and switches behavior based on that. + def recv_into(self, retbuf, bufsize): + """ + If the PINGREQ indication is on, return PINGRESP, otherwise raise timeout exception. + """ + if self._got_pingreq: + size = min(bufsize, len(self._to_send)) + if size == 0: + return size + chop = self._to_send[0:size] + retbuf[0:] = chop + self._to_send = self._to_send[size:] + if len(self._to_send) == 0: + self._got_pingreq = False + self._to_send = self.PINGRESP + return size + + exc = OSError() + exc.errno = errno.ETIMEDOUT + raise exc + + +class TestLoop: + """basic loop() test""" + + connect_times = [] + INITIAL_RCS_VAL = 42 + rcs_val = INITIAL_RCS_VAL + + def fake_wait_for_msg(self, timeout=1): + """_wait_for_msg() replacement. Sleeps for 1 second and returns an integer.""" + time.sleep(timeout) + retval = self.rcs_val + self.rcs_val += 1 + return retval + + def test_loop_basic(self) -> None: + """ + test that loop() returns only after the specified timeout, regardless whether + _wait_for_msg() returned repeatedly within that timeout. + """ + + host = "172.40.0.3" + port = 1883 + + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + ) + + with patch.object(mqtt_client, "_wait_for_msg") as wait_for_msg_mock, patch.object( + mqtt_client, "is_connected" + ) as is_connected_mock: + wait_for_msg_mock.side_effect = self.fake_wait_for_msg + is_connected_mock.side_effect = lambda: True + + time_before = time.monotonic() + timeout = random.randint(3, 8) + mqtt_client._last_msg_sent_timestamp = MQTT.ticks_ms() + rcs = mqtt_client.loop(timeout=timeout) + time_after = time.monotonic() + + assert time_after - time_before >= timeout + wait_for_msg_mock.assert_called() + + # Check the return value. + assert rcs is not None + assert len(rcs) >= 1 + expected_rc = self.INITIAL_RCS_VAL + for ret_code in rcs: + assert ret_code == expected_rc + expected_rc += 1 + + def test_loop_timeout_vs_socket_timeout(self): + """ + loop() should throw ValueError if the timeout argument + is bigger than the socket timeout. + """ + mqtt_client = MQTT.MQTT( + broker="127.0.0.1", + port=1883, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + socket_timeout=1, + ) + + mqtt_client.is_connected = lambda: True + with pytest.raises(ValueError) as context: + mqtt_client.loop(timeout=0.5) + + assert "loop timeout" in str(context) + + def test_loop_is_connected(self): + """ + loop() should throw MMQTTStateError if not connected + """ + mqtt_client = MQTT.MQTT( + broker="127.0.0.1", + port=1883, + socket_pool=socket, + ssl_context=ssl.create_default_context(), + ) + + with pytest.raises(MQTT.MMQTTStateError) as context: + mqtt_client.loop(timeout=1) + + assert "not connected" in str(context) + + def test_loop_ping_timeout(self): + """Verify that ping will be sent even with loop timeout bigger than keep alive timeout + and no outgoing messages are sent.""" + + recv_timeout = 2 + keep_alive_timeout = recv_timeout * 2 + mqtt_client = MQTT.MQTT( + broker="localhost", + port=1883, + ssl_context=ssl.create_default_context(), + connect_retries=1, + socket_timeout=1, + recv_timeout=recv_timeout, + keep_alive=keep_alive_timeout, + ) + + # patch is_connected() to avoid CONNECT/CONNACK handling. + mqtt_client.is_connected = lambda: True + mocket = Pingtet() + mqtt_client._sock = mocket + + start = time.monotonic() + res = mqtt_client.loop(timeout=2 * keep_alive_timeout + recv_timeout) + assert time.monotonic() - start >= 2 * keep_alive_timeout + assert len(mocket.sent) > 0 + assert len(res) == 3 + assert set(res) == {0xD0} + + def test_loop_ping_vs_msgs_sent(self): + """Verify that ping will not be sent unnecessarily.""" + + recv_timeout = 2 + keep_alive_timeout = recv_timeout * 2 + mqtt_client = MQTT.MQTT( + broker="localhost", + port=1883, + ssl_context=ssl.create_default_context(), + connect_retries=1, + socket_timeout=1, + recv_timeout=recv_timeout, + keep_alive=keep_alive_timeout, + ) + + # patch is_connected() to avoid CONNECT/CONNACK handling. + mqtt_client.is_connected = lambda: True + + # With QoS=0 no PUBACK message is sent, so Nulltet can be used. + mocket = Nulltet() + mqtt_client._sock = mocket + + i = 0 + topic = "foo" + message = "bar" + for _ in range(3 * keep_alive_timeout): + mqtt_client.publish(topic, message, qos=0) + mqtt_client.loop(1) + i += 1 + + # This means no other messages than the PUBLISH messages generated by the code above. + assert len(mocket.sent) == i * (2 + 2 + len(topic) + len(message)) diff --git a/tests/test_port_ssl.py b/tests/test_port_ssl.py new file mode 100644 index 00000000..6156a6ca --- /dev/null +++ b/tests/test_port_ssl.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +# ruff: noqa: PLR6301 no-self-use + +"""tests that verify the connect behavior w.r.t. port number and TLS""" + +import socket +import ssl +from unittest.mock import Mock, call, patch + +import pytest + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +class TestPortSslSetup: + """This class contains tests that verify how host/port and TLS is set for connect(). + These tests assume that there is no MQTT broker running on the hosts/ports they connect to. + """ + + def test_default_port(self) -> None: + """verify default port value and that TLS is not used""" + host = "127.0.0.1" + expected_port = 1883 + + with patch.object(socket.socket, "connect") as connect_mock: + ssl_context = ssl.create_default_context() + mqtt_client = MQTT.MQTT( + broker=host, + socket_pool=socket, + ssl_context=ssl_context, + connect_retries=1, + ) + + connect_mock.side_effect = OSError + ssl_mock = Mock() + ssl_context.wrap_socket = ssl_mock + + with pytest.raises(MQTT.MMQTTException): + mqtt_client.connect() + + ssl_mock.assert_not_called() + + connect_mock.assert_called() + # Assuming the repeated calls will have the same arguments. + connect_mock.assert_has_calls([call((host, expected_port))]) + + def test_connect_override(self): + """Test that connect() can override host and port.""" + host = "127.0.0.1" + port = 1883 + + with patch.object(socket.socket, "connect") as connect_mock: + connect_mock.side_effect = OSError("artificial error") + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + socket_pool=socket, + connect_retries=1, + ) + + with pytest.raises(MQTT.MMQTTException): + expected_host = "127.0.0.2" + expected_port = 1884 + assert expected_port != port # port override should differ + assert expected_host != host # host override should differ + mqtt_client.connect(host=expected_host, port=expected_port) + + connect_mock.assert_called() + # Assuming the repeated calls will have the same arguments. + connect_mock.assert_has_calls([call((expected_host, expected_port))]) + + @pytest.mark.parametrize("port", (None, 8883)) + def test_tls_port(self, port) -> None: + """verify that when is_ssl=True is set, the default port is 8883 + and the socket is TLS wrapped. Also test that the TLS port can be overridden.""" + host = "127.0.0.1" + + if port is None: + expected_port = 8883 + else: + expected_port = port + + ssl_mock = Mock() + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + socket_pool=socket, + is_ssl=True, + ssl_context=ssl_mock, + connect_retries=1, + ) + + socket_mock = Mock() + connect_mock = Mock(side_effect=OSError) + socket_mock.connect = connect_mock + ssl_mock.wrap_socket = Mock(return_value=socket_mock) + + with pytest.raises(MQTT.MMQTTException): + mqtt_client.connect() + + ssl_mock.wrap_socket.assert_called() + + connect_mock.assert_called() + # Assuming the repeated calls will have the same arguments. + connect_mock.assert_has_calls([call((host, expected_port))]) + + def test_tls_without_ssl_context(self) -> None: + """verify that when is_ssl=True is set, the code will check that ssl_context is not None""" + host = "127.0.0.1" + + mqtt_client = MQTT.MQTT( + broker=host, + socket_pool=socket, + is_ssl=True, + ssl_context=None, + connect_retries=1, + ) + + with pytest.raises(ValueError) as context: + mqtt_client.connect() + assert "ssl_context must be provided if using ssl" in str(context) diff --git a/tests/test_reconnect.py b/tests/test_reconnect.py new file mode 100644 index 00000000..52b8c76f --- /dev/null +++ b/tests/test_reconnect.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: 2025 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""reconnect tests""" + +import logging +import ssl +import sys + +import pytest +from mocket import Mocket + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + +if not sys.implementation.name == "circuitpython": + from typing import Optional + + from circuitpython_typing.socket import ( + SocketType, + SSLContextType, + ) + + +class FakeConnectionManager: + """ + Fake ConnectionManager class + """ + + def __init__(self, socket): + self._socket = socket + self.close_cnt = 0 + + def get_socket( # noqa: PLR0913, Too many arguments + self, + host: str, + port: int, + proto: str, + session_id: Optional[str] = None, + *, + timeout: float = 1.0, + is_ssl: bool = False, + ssl_context: Optional[SSLContextType] = None, + ) -> SocketType: + """ + Return the specified socket. + """ + return self._socket + + def close_socket(self, socket) -> None: + self.close_cnt += 1 + + +def handle_subscribe(client, user_data, topic, qos): + """ + Record topics into user data. + """ + assert topic + assert user_data["topics"] is not None + assert qos == 0 + + user_data["topics"].append(topic) + + +def handle_disconnect(client, user_data, zero): + """ + Record disconnect. + """ + + user_data["disconnect"] = True + + +# The MQTT packet contents below were captured using Mosquitto client+server. +testdata = [ + ( + [], + bytearray([ + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x01, + 0x00, + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x02, + 0x00, + ]), + ), + ( + [("foo/bar", 0)], + bytearray([ + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x01, + 0x00, + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x02, + 0x00, + ]), + ), + ( + [("foo/bar", 0), ("bah", 0)], + bytearray([ + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x01, + 0x00, + 0x00, + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x02, + 0x00, + 0x90, # SUBACK + 0x03, + 0x00, + 0x03, + 0x00, + ]), + ), +] + + +@pytest.mark.parametrize( + "topics,to_send", + testdata, + ids=[ + "no_topic", + "single_topic", + "multi_topic", + ], +) +def test_reconnect(topics, to_send) -> None: + """ + Test reconnect() handling, mainly that it performs disconnect on already connected socket. + + Nothing will travel over the wire, it is all fake. + """ + logging.basicConfig() + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + host = "localhost" + port = 1883 + + user_data = {"topics": [], "disconnect": False} + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + ssl_context=ssl.create_default_context(), + connect_retries=1, + user_data=user_data, + ) + + mocket = Mocket(to_send) + mqtt_client._connection_manager = FakeConnectionManager(mocket) + mqtt_client.connect() + + mqtt_client.logger = logger + + if topics: + logger.info(f"subscribing to {topics}") + mqtt_client.subscribe(topics) + + logger.info("reconnecting") + mqtt_client.on_subscribe = handle_subscribe + mqtt_client.on_disconnect = handle_disconnect + mqtt_client.reconnect() + + assert user_data.get("disconnect") == True + assert mqtt_client._connection_manager.close_cnt == 1 + assert set(user_data.get("topics")) == set([t[0] for t in topics]) + + +def test_reconnect_not_connected() -> None: + """ + Test reconnect() handling not connected. + """ + logging.basicConfig() + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + host = "localhost" + port = 1883 + + user_data = {"topics": [], "disconnect": False} + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + ssl_context=ssl.create_default_context(), + connect_retries=1, + user_data=user_data, + ) + + mocket = Mocket( + bytearray([ + 0x20, # CONNACK + 0x02, + 0x00, + 0x00, + ]) + ) + mqtt_client._connection_manager = FakeConnectionManager(mocket) + + mqtt_client.logger = logger + mqtt_client.on_disconnect = handle_disconnect + mqtt_client.reconnect() + + assert user_data.get("disconnect") == False + assert mqtt_client._connection_manager.close_cnt == 0 diff --git a/tests/test_recv_timeout.py b/tests/test_recv_timeout.py new file mode 100644 index 00000000..73b1b19c --- /dev/null +++ b/tests/test_recv_timeout.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2024 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""receive timeout tests""" + +import socket +import time +from unittest import TestCase, main +from unittest.mock import Mock + +from mocket import Mocket + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +class RecvTimeout(TestCase): + """This class contains tests for receive timeout handling.""" + + def test_recv_timeout_vs_keepalive(self) -> None: + """verify that receive timeout as used via ping() is different to keep alive timeout""" + + for side_effect in [lambda ret_buf, buf_size: 0, socket.timeout]: + with self.subTest(): + host = "127.0.0.1" + + recv_timeout = 4 + keep_alive = recv_timeout * 2 + mqtt_client = MQTT.MQTT( + broker=host, + socket_pool=socket, + connect_retries=1, + socket_timeout=recv_timeout // 2, + recv_timeout=recv_timeout, + keep_alive=keep_alive, + ) + + # Create a mock socket that will accept anything and return nothing. + socket_mock = Mocket(b"") + socket_mock.recv_into = Mock(side_effect=side_effect) + mqtt_client._sock = socket_mock + + mqtt_client._connected = lambda: True + start = time.monotonic() + with self.assertRaises(MQTT.MMQTTException): + mqtt_client.ping() + + now = time.monotonic() + assert recv_timeout <= (now - start) <= (keep_alive + 0.5) + + +if __name__ == "__main__": + main() diff --git a/tests/test_subscribe.py b/tests/test_subscribe.py new file mode 100644 index 00000000..90e5b21f --- /dev/null +++ b/tests/test_subscribe.py @@ -0,0 +1,217 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""subscribe tests""" + +import logging +import ssl + +import pytest +from mocket import Mocket + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +def handle_subscribe(client, user_data, topic, qos): + """ + Record topics into user data. + """ + assert topic + assert qos == 0 + + user_data.append(topic) + + +# The MQTT packet contents below were captured using Mosquitto client+server. +testdata = [ + # short topic with remaining length encoded as single byte + ( + "foo/bar", + bytearray([0x90, 0x03, 0x00, 0x01, 0x00]), # SUBACK + bytearray([ + 0x82, # fixed header + 0x0C, # remaining length + 0x00, + 0x01, # message ID + 0x00, + 0x07, # topic length + 0x66, # topic + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + 0x00, # QoS + ]), + ), + # same as before but with tuple + ( + ("foo/bar", 0), + bytearray([0x90, 0x03, 0x00, 0x01, 0x00]), # SUBACK + bytearray([ + 0x82, # fixed header + 0x0C, # remaining length + 0x00, + 0x01, # message ID + 0x00, + 0x07, # topic length + 0x66, # topic + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + 0x00, # QoS + ]), + ), + # remaining length is encoded as 2 bytes due to long topic name. + ( + "f" + "o" * 257, + bytearray([0x90, 0x03, 0x00, 0x01, 0x00]), # SUBACK + bytearray( + [ + 0x82, # fixed header + 0x87, # remaining length + 0x02, + 0x00, # message ID + 0x01, + 0x01, # topic length + 0x02, + 0x66, # topic + ] + + [0x6F] * 257 + + [0x00] # QoS + ), + ), + # SUBSCRIBE responded to by PUBLISH followed by SUBACK + ( + "foo/bar", + bytearray([ + 0x30, # PUBLISH + 0x0C, + 0x00, + 0x07, + 0x66, + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + 0x66, + 0x6F, + 0x6F, + 0x90, # SUBACK + 0x03, + 0x00, + 0x01, + 0x00, + ]), + bytearray([ + 0x82, # fixed header + 0x0C, # remaining length + 0x00, + 0x01, # message ID + 0x00, + 0x07, # topic length + 0x66, # topic + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + 0x00, # QoS + ]), + ), + # use list of topics for more coverage. If the range was (1, 10000), that would be + # long enough to use 3 bytes for remaining length, however that would make the test + # run for many minutes even on modern systems, so 1001 is used instead. + # This results in 2 bytes for the remaining length. + ( + [(f"foo/bar{x:04}", 0) for x in range(1, 1001)], + bytearray( + [ + 0x90, + 0xEA, # remaining length + 0x07, + 0x00, # message ID + 0x01, + ] + + [0x00] * 1000 # success for all topics + ), + bytearray( + [ + 0x82, # fixed header + 0xB2, # remaining length + 0x6D, + 0x00, # message ID + 0x01, + ] + + sum( + [ + [0x00, 0x0B] + list(f"foo/bar{x:04}".encode("ascii")) + [0x00] + for x in range(1, 1001) + ], + [], + ) + ), + ), +] + + +@pytest.mark.parametrize( + "topic,to_send,exp_recv", + testdata, + ids=[ + "short_topic", + "short_topic_tuple", + "long_topic", + "publish_first", + "topic_list_long", + ], +) +def test_subscribe(topic, to_send, exp_recv) -> None: + """ + Protocol level testing of SUBSCRIBE and SUBACK packet handling. + + Nothing will travel over the wire, it is all fake. + """ + logging.basicConfig() + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + host = "localhost" + port = 1883 + + subscribed_topics = [] + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + ssl_context=ssl.create_default_context(), + connect_retries=1, + user_data=subscribed_topics, + ) + + mqtt_client.on_subscribe = handle_subscribe + + # patch is_connected() to avoid CONNECT/CONNACK handling. + mqtt_client.is_connected = lambda: True + mocket = Mocket(to_send) + mqtt_client._sock = mocket + + mqtt_client.logger = logger + + logger.info(f"subscribing to {topic}") + mqtt_client.subscribe(topic) + + if isinstance(topic, str): + assert topic in subscribed_topics + elif isinstance(topic, list): + for topic_name, _ in topic: + assert topic_name in subscribed_topics + assert mocket.sent == exp_recv + assert len(mocket._to_send) == 0 diff --git a/tests/test_unsubscribe.py b/tests/test_unsubscribe.py new file mode 100644 index 00000000..0f9ed2ff --- /dev/null +++ b/tests/test_unsubscribe.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# +# SPDX-License-Identifier: Unlicense + +"""unsubscribe tests""" + +import logging +import ssl + +import pytest +from mocket import Mocket + +import adafruit_minimqtt.adafruit_minimqtt as MQTT + + +def handle_unsubscribe(client, user_data, topic, pid): + """ + Record topics into user data. + """ + assert topic + + user_data.append(topic) + + +# The MQTT packet contents below were captured using Mosquitto client+server. +# These are all verbatim, except: +# - message ID that was changed from 2 to 1 since in the real world +# the UNSUBSCRIBE packet followed the SUBSCRIBE packet. +# - the long list topics is sent as individual UNSUBSCRIBE packets by Mosquitto +testdata = [ + # short topic with remaining length encoded as single byte + ( + "foo/bar", + bytearray([0xB0, 0x02, 0x00, 0x01]), + bytearray([ + 0xA2, # fixed header + 0x0B, # remaining length + 0x00, # message ID + 0x01, + 0x00, # topic length + 0x07, + 0x66, # topic + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + ]), + ), + # remaining length is encoded as 2 bytes due to long topic name. + ( + "f" + "o" * 257, + bytearray([0xB0, 0x02, 0x00, 0x01]), + bytearray( + [ + 0xA2, # fixed header + 0x86, # remaining length + 0x02, + 0x00, # message ID + 0x01, + 0x01, # topic length + 0x02, + 0x66, # topic + ] + + [0x6F] * 257 + ), + ), + # UNSUBSCRIBE responded to by PUBLISH followed by UNSUBACK + ( + "foo/bar", + bytearray([ + 0x30, # PUBLISH + 0x0C, + 0x00, + 0x07, + 0x66, + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + 0x66, + 0x6F, + 0x6F, + 0xB0, # UNSUBACK + 0x02, + 0x00, + 0x01, + ]), + bytearray([ + 0xA2, # fixed header + 0x0B, # remaining length + 0x00, + 0x01, # message ID + 0x00, + 0x07, # topic length + 0x66, # topic + 0x6F, + 0x6F, + 0x2F, + 0x62, + 0x61, + 0x72, + ]), + ), + # use list of topics for more coverage. If the range was (1, 10000), that would be + # long enough to use 3 bytes for remaining length, however that would make the test + # run for many minutes even on modern systems, so 1000 is used instead. + # This results in 2 bytes for the remaining length. + ( + [f"foo/bar{x:04}" for x in range(1, 1000)], + bytearray([0xB0, 0x02, 0x00, 0x01]), + bytearray( + [ + 0xA2, # fixed header + 0xBD, # remaining length + 0x65, + 0x00, # message ID + 0x01, + ] + + sum( + [[0x00, 0x0B] + list(f"foo/bar{x:04}".encode("ascii")) for x in range(1, 1000)], + [], + ) + ), + ), +] + + +@pytest.mark.parametrize( + "topic,to_send,exp_recv", + testdata, + ids=["short_topic", "long_topic", "publish_first", "topic_list_long"], +) +def test_unsubscribe(topic, to_send, exp_recv) -> None: + """ + Protocol level testing of UNSUBSCRIBE and UNSUBACK packet handling. + + Nothing will travel over the wire, it is all fake. + Also, the topics are not subscribed into. + """ + logging.basicConfig() + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + host = "localhost" + port = 1883 + + unsubscribed_topics = [] + mqtt_client = MQTT.MQTT( + broker=host, + port=port, + ssl_context=ssl.create_default_context(), + connect_retries=1, + user_data=unsubscribed_topics, + ) + + mqtt_client.on_unsubscribe = handle_unsubscribe + + # patch is_connected() to avoid CONNECT/CONNACK handling. + mqtt_client.is_connected = lambda: True + mocket = Mocket(to_send) + mqtt_client._sock = mocket + + mqtt_client.logger = logger + + if isinstance(topic, str): + mqtt_client._subscribed_topics = [topic] + elif isinstance(topic, list): + mqtt_client._subscribed_topics = topic + + logger.info(f"unsubscribing from {topic}") + mqtt_client.unsubscribe(topic) + + if isinstance(topic, str): + assert topic in unsubscribed_topics + elif isinstance(topic, list): + for topic_name in topic: + assert topic_name in unsubscribed_topics + assert mocket.sent == exp_recv + assert len(mocket._to_send) == 0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..d0cddcf3 --- /dev/null +++ b/tox.ini @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2023 Vladimír Kotal +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +[tox] +envlist = py311 + +[testenv] +description = run tests +deps = + pytest==7.4.3 +commands = pytest + +[testenv:coverage] +description = run coverage +deps = + pytest==7.4.3 + pytest-cov==4.1.0 +package = editable +commands = + coverage run --source=. --omit=tests/* --branch {posargs} -m pytest + coverage report + coverage html + +[testenv:lint] +description = run linters +deps = + pre-commit==3.6.0 +skip_install = true +commands = pre-commit run {posargs} + +[testenv:docs] +description = build docs +deps = + -r requirements.txt + -r docs/requirements.txt +skip_install = true +commands = sphinx-build -E -W -b html docs/. _build/html pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy