From 0455c2111e76a60503f5ea140d2d5ed1d684bc80 Mon Sep 17 00:00:00 2001 From: Josh Gruenstein Date: Tue, 16 Apr 2024 14:56:58 -0400 Subject: [PATCH] feat: initial open source release --- .github/semantic.yml | 1 + .github/workflows/cd.yml | 46 +++ .gitignore | 129 ++++++++ LICENSE | 21 ++ README.md | 16 + examples/example.py | 21 ++ poetry.lock | 464 ++++++++++++++++++++++++++++ poetry_scripts.py | 24 ++ pyproject.toml | 33 ++ setup.cfg | 24 ++ tcp_modbus_aio/__init__.py | 1 + tcp_modbus_aio/connection.py | 489 ++++++++++++++++++++++++++++++ tcp_modbus_aio/exceptions.py | 13 + tcp_modbus_aio/ping.py | 37 +++ tcp_modbus_aio/py.typed | 0 tcp_modbus_aio/typed_functions.py | 88 ++++++ 16 files changed, 1407 insertions(+) create mode 100644 .github/semantic.yml create mode 100644 .github/workflows/cd.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/example.py create mode 100644 poetry.lock create mode 100644 poetry_scripts.py create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 tcp_modbus_aio/__init__.py create mode 100644 tcp_modbus_aio/connection.py create mode 100644 tcp_modbus_aio/exceptions.py create mode 100644 tcp_modbus_aio/ping.py create mode 100644 tcp_modbus_aio/py.typed create mode 100644 tcp_modbus_aio/typed_functions.py diff --git a/.github/semantic.yml b/.github/semantic.yml new file mode 100644 index 0000000..791d9de --- /dev/null +++ b/.github/semantic.yml @@ -0,0 +1 @@ +titleOnly: true \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..476e562 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,46 @@ +name: Release +on: + push: + branches: main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps?apiVersion=2022-11-28#metadata + id-token: write + contents: write + steps: + - uses: actions/checkout@v2 + with: + ref: main + fetch-depth: 0 + + - name: Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@v8.0.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + root_options: "-vv" + + - name: Publish package distributions to PyPI + id: pypi-publish + + # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. + # See https://github.com/actions/runner/issues/1173 + if: steps.release.outputs.released == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + + - name: Publish package distributions to GitHub Releases + id: github-release + + # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. + # See https://github.com/actions/runner/issues/1173 + if: steps.release.outputs.released == 'true' + uses: python-semantic-release/upload-to-gh-release@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fe17bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45217ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tutor Intelligence + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f630578 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# tcp-modbus-aio + +[![PyPI version](https://badge.fury.io/py/tcp-modbus-aio.svg)](https://badge.fury.io/py/tcp-modbus-aio) + +asyncio client library for tcp modbus devices. built on top of [umodbus](https://pypi.org/project/uModbus/) but extended for more *industrial robustness* and asyncio compat. the [umodbus documentation](https://umodbus.readthedocs.io/en/latest/) is recommended reading to have any hope of using this code. + +narrowly constructed for the use cases of [Tutor Intelligence](http://tutorintelligence.com/), but feel free to post an issue or PR if relevant to you. + +### usage + +create a `TCPModbusClient`. once you have it, you can call the following methods on it: + - `await conn.send_modbus_message(request_function, **kwargs)`: sends a `umodbus.functions.ModbusFunction` to the modbus device and returns the corresponding `ModbusFunction` reply. + - `await conn.test_connection()`: sends a modbus message to the device to ensure it's still operational (currently hardcoded to read coil 0) and return boolean of whether it succeeded. is implemented as a cached awaitable to allow you to spam this call. + - `await conn.clear_tcp_connection()`: kill the current TCP socket (a new one will automatically be created for the next request) + - `await conn.log_watch(msg, memo_key="system_temperature", expiry_period_s=10, hz=1)`: spins up a background coroutine to log the result of that message for the next `expiry_period` seconds at `hz` frequency. `memo_key` is used to allow multiple calls to `log_watch` without having overlapping log watch loops. + - `await conn.close()`: for cleaning up the connection (kills TCP conn and ping loop) \ No newline at end of file diff --git a/examples/example.py b/examples/example.py new file mode 100644 index 0000000..020175b --- /dev/null +++ b/examples/example.py @@ -0,0 +1,21 @@ +import asyncio + +from umodbus.functions import ReadCoils + +from tcp_modbus_aio.connection import TCPModbusClient + + +async def example() -> None: + example_message = ReadCoils() + example_message.starting_address = 0 + example_message.quantity = 1 + + async with TCPModbusClient("192.168.250.204") as conn: + response = await conn.send_modbus_message(example_message) + + assert response is not None, "we expect a response from ReadCoils" + print(response.data) + + +if __name__ == "__main__": + asyncio.run(example()) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..a744445 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,464 @@ +# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand. + +[[package]] +name = "black" +version = "22.12.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, + {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, + {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, + {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, + {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, + {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, + {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, + {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, + {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, + {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, + {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, + {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cachetools" +version = "5.3.3" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, + {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "filelock" +version = "3.13.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.4-py3-none-any.whl", hash = "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f"}, + {file = "filelock-3.13.4.tar.gz", hash = "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "flake8" +version = "6.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pre-commit" +version = "2.21.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pyflakes" +version = "3.1.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, +] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +optional = false +python-versions = "*" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "setuptools" +version = "69.5.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, + {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "types-cachetools" +version = "5.3.0.7" +description = "Typing stubs for cachetools" +optional = false +python-versions = ">=3.7" +files = [ + {file = "types-cachetools-5.3.0.7.tar.gz", hash = "sha256:27c982cdb9cf3fead8b0089ee6b895715ecc99dac90ec29e2cab56eb1aaf4199"}, + {file = "types_cachetools-5.3.0.7-py3-none-any.whl", hash = "sha256:98c069dc7fc087b1b061703369c80751b0a0fc561f6fb072b554e5eee23773a0"}, +] + +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "umodbus" +version = "1.0.4" +description = "Implementation of the Modbus protocol in pure Python." +optional = false +python-versions = "*" +files = [ + {file = "uModbus-1.0.4-py2.py3-none-any.whl", hash = "sha256:aab3e61488d8bef638466687b360192ddf046a23b61a9ba3734b4f48d31efe16"}, + {file = "uModbus-1.0.4.tar.gz", hash = "sha256:26bbbeff02d6d8a3e29bb0f9d9044c672d55fc1687afe4297a2f7d68175103a7"}, +] + +[package.dependencies] +pyserial = ">=3.4,<4.0" + +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "0c3ac9d748613012276c2692d111e3514e6a6c8e0e640c1a2279dcd055b0391a" diff --git a/poetry_scripts.py b/poetry_scripts.py new file mode 100644 index 0000000..9b7d37d --- /dev/null +++ b/poetry_scripts.py @@ -0,0 +1,24 @@ +import subprocess + + +def isort() -> None: + subprocess.run(["isort", "."], check=True) + + +def black() -> None: + subprocess.run(["black", "."], check=True) + + +def flake8() -> None: + subprocess.run(["flake8"], check=True) + + +def mypy() -> None: + subprocess.run(["mypy", "."], check=True) + + +def style() -> None: + isort() + black() + flake8() + mypy() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2ff2279 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[tool.poetry] +name = "tcp-modbus-aio" +version = "0.0.1" +description = "asyncio client library for tcp modbus devices" +authors = ["Josh Gruenstein "] +license = "MIT" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.10" +umodbus = "^1.0.4" +cachetools = "^5.3.3" +types-cachetools = "^5.3.0.7" +typing-extensions = "^4.11.0" + +[tool.poetry.dev-dependencies] +flake8 = "^6.0.0" +isort = "^5.10.1" +black = "^22.3.0" +mypy = "^1.7.0" +pre-commit = "^2.19.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +style = "poetry_scripts:style" + +[tool.semantic_release] +version_variables = ["tcp_modbus_aio/__init__.py:__version__"] +version_toml = ["pyproject.toml:tool.poetry.version"] +build_command = "pip install poetry && poetry build" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..490611b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,24 @@ +[flake8] +# Recommend matching the black line length (default 88), +# rather than using the flake8 default of 79: +max-line-length = 120 +extend-ignore = + # See https://github.com/PyCQA/pycodestyle/issues/373 + E203, +# artifically low for testing purpose +max-definition-positional-args = 3 + +[black] +line-length = 120 + +[mypy] +; plugins = numpy.typing.mypy_plugin +; ignore_missing_imports = True +disallow_untyped_defs = True +namespace_packages = True + +[mypy-flake8.options.manager.*] +ignore_missing_imports = True + +[mypy-umodbus.*] +ignore_missing_imports = True diff --git a/tcp_modbus_aio/__init__.py b/tcp_modbus_aio/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/tcp_modbus_aio/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/tcp_modbus_aio/connection.py b/tcp_modbus_aio/connection.py new file mode 100644 index 0000000..d4bf7fe --- /dev/null +++ b/tcp_modbus_aio/connection.py @@ -0,0 +1,489 @@ +import asyncio +import logging +import random +import socket +import struct +import time +import uuid +from dataclasses import dataclass +from types import TracebackType +from typing import Any, ClassVar + +from cachetools import TTLCache +from typing_extensions import Self +from umodbus.functions import ModbusFunction + +from tcp_modbus_aio.exceptions import ( + ModbusCommunicationFailureError, + ModbusNotConnectedError, +) +from tcp_modbus_aio.ping import ping_ip +from tcp_modbus_aio.typed_functions import ( + ModbusFunctionT, + ReadCoils, + create_function_from_response_pdu, +) + + +@dataclass +class CoilWatchStatus: + msg: ModbusFunction + + memo_key: Any + expiry: float + hz: float + task: asyncio.Task | None = None + + +TEST_CONNECTION_MESSAGE = ReadCoils() +TEST_CONNECTION_MESSAGE.starting_address = 0 +TEST_CONNECTION_MESSAGE.quantity = 1 + + +@dataclass +class TCPModbusClient: + KEEPALIVE_AFTER_IDLE_SEC: ClassVar = 10 + KEEPALIVE_INTERVAL_SEC: ClassVar = 10 + KEEPALIVE_MAX_FAILS: ClassVar = 5 + + PING_LOOP_PERIOD: ClassVar = 1 + + DEFAULT_MODBUS_TIMEOUT_SEC: ClassVar = 0.1 + MAX_TRANSACTION_ID: ClassVar = 2**16 - 1 # maximum number that fits in 2 bytes + MODBUS_MBAP_SIZE: ClassVar = 7 + MBAP_HEADER_STRUCT_FORMAT: ClassVar = ">HHHB" + + def __init__( + self, + host: str, + port: int = 502, + slave_id: int = 1, + logger: logging.Logger | None = None, + ) -> None: + self.host = host + self.port = port + self.slave_id = slave_id + self.logger = logger + + # Unique identifier for this client (used only for logging) + self._id = uuid.uuid4() + + # TCP reader and writer objects for active connection, or None if no connection + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + + # Last ping time in seconds from ping loop, or None if the last ping failed + self._last_ping: float | None = None + + # Task that pings the device every second + self._ping_loop: asyncio.Task | None = asyncio.create_task( + self._ping_loop_task(), + name=f"TCPModbusClient._ping_loop_task[{self.host}:{self.port}]", + ) + + # List of CoilWatchStatus objects that are being logged + self._log_watches = list[CoilWatchStatus]() + + # We cache anly active awaitable testing TCP connectivity to prevent spamming the connection + self._active_connection_probe: asyncio.Task | None = None + + # We track this number because the ADAM module has a firmware bug where it will stop responding + # if there are 24 unclosed connections within a 1000s period. Hopefully if we see this happening + # we can see it in logs because of this. + self._lifetime_tcp_connection_num = 0 + + # Lock to prevent multiple concurrent requests on the same connection. In theory this should not be + # necessary because the MODBUS protocol is designed to handle multiple requests on the same connection, + # but my hope is that the performance impact of this lock is minimal, and the complexity/risk of + # concurrent requests is high enough that I'm ok with the tradeoff. + self._comms_lock = asyncio.Lock() + + # If we succesfully write a request but the response times out, we cache the transaction ID here + # so we know to throw it away if we see it again. + self._lost_transaction_ids = TTLCache[int, bool](maxsize=1000, ttl=60) + + # Instead of randomly sampling transaction IDs, we can use a global counter with random seed. + # This way, we can avoid duplicate transaction IDs via birthday paradox. + self._next_transaction_id = random.randint(0, self.MAX_TRANSACTION_ID) + + def __repr__(self) -> str: + last_ping_msg = ( + f"{self._last_ping*1000:.1f}ms ping" + if self._last_ping is not None + else "no ping" + ) + return ( + f"{self.__class__.__name__}({str(self._id)[:4]})[{self.host}:{self.port}]" + f"[{last_ping_msg}, conn #{self._lifetime_tcp_connection_num}]" + ) + + async def _ping_loop_task(self) -> None: + while True: + self._last_ping = await ping_ip(self.host) + if self.logger is not None: + self.logger.debug(f"[{self}][_ping_loop_task] ping ping ping") + + await asyncio.sleep(self.PING_LOOP_PERIOD) + + async def _get_tcp_connection( + self, timeout: float | None = DEFAULT_MODBUS_TIMEOUT_SEC + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + if self._reader is None or self._writer is None: + self._lifetime_tcp_connection_num += 1 + + if self.logger is not None: + self.logger.info( + f"[{self}][_get_tcp_connection] creating new TCP connection (#{self._lifetime_tcp_connection_num})" + ) + + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host=self.host, port=self.port), timeout + ) + + sock: socket.socket = writer.get_extra_info("socket") + + # Receive and send buffers set to 900 bytes (recommended by MODBUS implementation guide: this is + # becuase the max request size is 256 bytes + the header size of 7 bytes = 263 bytes, and the + # max response size is 256 bytes + the header size of 7 bytes = 263 bytes, so a 900 byte buffer + # can store 3 frames of buffering, which is apparently the suggestion). + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 900) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 900) + + # Reuse address (perf optimization, recommended by MODBUS implementation guide) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Enable TCP_NODELAY (prevent small packet buffering, recommended by MODBUS implementation guide) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + # Enable TCP keepalive (otherwise the Adam connection will terminate after 720 (1000?) seconds + # with an open idle connection: this is also recommended by the MODBUS implementation guide) + # + # In most cases this is not necessary because Adam commands are short lived and we + # close the connection after each command. However, if we want to keep a connection + # open for a long time we would need to enable keepalive. + + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + if hasattr(socket, "TCP_KEEPIDLE"): + # Only available on Linux so this makes typing work cross platform + sock.setsockopt( + socket.IPPROTO_TCP, + socket.TCP_KEEPIDLE, + self.KEEPALIVE_AFTER_IDLE_SEC, + ) + + sock.setsockopt( + socket.IPPROTO_TCP, + socket.TCP_KEEPINTVL, + self.KEEPALIVE_INTERVAL_SEC, + ) + sock.setsockopt( + socket.IPPROTO_TCP, socket.TCP_KEEPCNT, self.KEEPALIVE_MAX_FAILS + ) + + self._reader, self._writer = reader, writer + except (asyncio.TimeoutError, OSError): + msg = f"Cannot connect to TCP modbus device at {self.host}:{self.port}" + if self.logger is not None: + self.logger.warning(f"[{self}][_get_tcp_connection] {msg}") + raise ModbusNotConnectedError(msg) + else: + reader, writer = self._reader, self._writer + + return reader, writer + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + await self.clear_tcp_connection() + + if self._ping_loop is not None: + if self.logger is not None: + self.logger.debug(f"[{self}][close] Cancelling ping loop") + self._ping_loop.cancel() + self._ping_loop = None + + def log_watch( + self, msg: ModbusFunction, *, memo_key: Any, period: float, hz: float + ) -> None: + """ + Triggers a loop that reads the coil(s) at the given index at the given frequency and + writes the current values to logs for debugging purposes. + + This debug loop expires after the given period, but is refreshed every time this request_function + is called. + """ + + for watch in self._log_watches: + if watch.memo_key == memo_key: + watch.expiry = time.perf_counter() + period + watch.hz = hz + watch.msg = msg + + break + else: + watch = CoilWatchStatus( + memo_key=memo_key, + expiry=time.perf_counter() + period, + hz=hz, + msg=msg, + ) + self._log_watches.append(watch) + + if watch.task and not watch.task.done(): + # task is already running, we're gucci + return + + log_prefix = f"[{self.host}:_watch_loop({watch.memo_key=})]" + + async def _watch_loop() -> None: + if self.logger is not None: + self.logger.debug(f"{log_prefix} coil watch started") + + while True: + if time.perf_counter() > watch.expiry: + if self.logger is not None: + self.logger.debug(f"{log_prefix} coil watch expired") + + self._log_watches.remove(watch) + return + + try: + response = await self.send_modbus_message( + watch.msg, timeout=1 / watch.hz + ) + + if self.logger is not None: + self.logger.debug(f"[{log_prefix}] {response=}") + + except Exception: + if self.logger is not None: + self.logger.exception( + f"{log_prefix} got error reading {watch.memo_key=}" + ) + + await asyncio.sleep(1 / watch.hz) + + watch.task = asyncio.create_task( + _watch_loop(), name=f"TCPModbusClient{log_prefix}" + ) + + async def clear_tcp_connection(self) -> None: + if self._writer is not None: + if self.logger is not None: + self.logger.warning( + f"[{self}][clear_tcp_connection] closing TCP connection #{self._lifetime_tcp_connection_num}" + ) + + self._writer.close() + await self._writer.wait_closed() + + self._reader = None + self._writer = None + + async def test_connection( + self, timeout: float | None = DEFAULT_MODBUS_TIMEOUT_SEC + ) -> None: + """ + Uses a cached awaitable to prevent spamming the connection on this call + """ + + try: + if self._active_connection_probe is None: + self._active_connection_probe = asyncio.create_task( + self.send_modbus_message(TEST_CONNECTION_MESSAGE, timeout=timeout), + name=f"TCPModbusClient.test_connection({self.host}:{self.port})", + ) + + await self._active_connection_probe + finally: + self._active_connection_probe = None + + async def send_modbus_message( + self, + request_function: ModbusFunctionT, + timeout: float | None = DEFAULT_MODBUS_TIMEOUT_SEC, + retries: int = 1, + error_on_no_response: bool = True, + ) -> ModbusFunctionT | None: + """Send ADU over socket to to server and return parsed response. + + :param adu: Request ADU. + :param sock: Socket instance. + :return: Parsed response from server. + """ + + request_transaction_id = self._next_transaction_id + self._next_transaction_id = ( + self._next_transaction_id + 1 + ) % self.MAX_TRANSACTION_ID + + msg_str = f"{request_function.__class__.__name__}[{request_transaction_id}]" + + request_mbap_header = struct.pack( + self.MBAP_HEADER_STRUCT_FORMAT, + request_transaction_id, + 0, + len(request_function.request_pdu) + 1, + self.slave_id, + ) + + request_adu = request_mbap_header + request_function.request_pdu + + if self.logger is not None: + self.logger.debug( + f"[{self}][send_modbus_message] sending request {msg_str}: {request_adu=}" + ) + + async with self._comms_lock: + if self.logger is not None: + self.logger.debug( + f"[{self}][send_modbus_message] acquired lock to send {msg_str}" + ) + + reader, writer = await self._get_tcp_connection(timeout=timeout) + + try: + writer.write(request_adu) + await asyncio.wait_for(writer.drain(), timeout) + + if self.logger is not None: + self.logger.debug(f"[{self}][send_modbus_message] wrote {msg_str}") + + except (asyncio.TimeoutError, OSError): + if retries > 0: + if self.logger is not None: + self.logger.warning( + f"[{self}][send_modbus_message] Failed to send data to modbus device for " + f"request {msg_str}, retrying {retries} more time(s)" + ) + + await self.clear_tcp_connection() + + return await self.send_modbus_message( + request_function, + timeout=timeout, + retries=retries - 1, + ) + + raise ModbusCommunicationFailureError( + f"Failed to write request {msg_str} to modbus device {self.host}" + ) + + expected_response_size = ( + request_function.expected_response_pdu_size + self.MODBUS_MBAP_SIZE + ) + + try: + seen_response_transaction_ids = [] + while True: + response_adu = await asyncio.wait_for( + reader.read(expected_response_size), timeout=timeout + ) + + response_pdu = response_adu[self.MODBUS_MBAP_SIZE :] + response_mbap_header = response_adu[: self.MODBUS_MBAP_SIZE] + + ( + response_transaction_id, + _, + mbap_asserted_pdu_length_plus_one, + response_asserted_slave_id, + ) = struct.unpack( + self.MBAP_HEADER_STRUCT_FORMAT, response_mbap_header + ) + + seen_response_transaction_ids.append(response_transaction_id) + + if response_transaction_id in self._lost_transaction_ids: + self._lost_transaction_ids.pop(response_transaction_id) + if self.logger is not None: + self.logger.warning( + f"[{self}][send_modbus_message] Received response {response_transaction_id} for " + f"request {msg_str} that was previously lost, skipping" + ) + + continue + + elif len(response_adu) != expected_response_size: + msg = ( + f"[{self}][send_modbus_message] Received response {response_transaction_id} for " + f"request {msg_str} with unexpected size {len(response_adu)}, expected " + f"{expected_response_size}" + ) + + if self.logger is not None: + self.logger.error(msg) + + raise ModbusCommunicationFailureError(msg) + + elif response_asserted_slave_id != self.slave_id: + raise ModbusCommunicationFailureError( + f"Response slave ID {response_asserted_slave_id} does not match expected " + f"{self.slave_id} on {self.host}" + ) + + elif mbap_asserted_pdu_length_plus_one != len(response_pdu) + 1: + raise ModbusCommunicationFailureError( + f"Response PDU length {len(response_pdu)} does not match expected " + f"{mbap_asserted_pdu_length_plus_one-1} on {self.host}" + ) + + break + + except asyncio.TimeoutError: + self._lost_transaction_ids[request_transaction_id] = True + + if error_on_no_response: + raise ModbusCommunicationFailureError( + f"Failed to read response to {msg_str} from modbus device {self.host} " + f"({seen_response_transaction_ids=})" + ) + + else: + if self.logger is not None: + self.logger.warning( + f"[{self}][send_modbus_message] failed to read response to {msg_str}" + ) + + return None + + mismatch = response_transaction_id != request_transaction_id + + response_function = create_function_from_response_pdu( + response_pdu, request_function + ) + + response_msg = ( + f"{response_function.__class__.__name__}[{response_transaction_id}]" + ) + + if self.logger is not None: + self.logger.debug( + f"[{self}][send_modbus_message] received response {response_msg} for request " + f"{msg_str} ({mismatch=} {response_function.data=} {response_pdu=} {len(response_pdu)=})" + ) + + if mismatch: + msg = ( + f"Response transaction ID {response_transaction_id} does not match request " + f"{msg_str} on {self.host}" + ) + + if self.logger is not None: + self.logger.error( + f"[{self}][send_modbus_message] {msg} {response_adu=}" + ) + + raise ModbusCommunicationFailureError(msg) + + return response_function diff --git a/tcp_modbus_aio/exceptions.py b/tcp_modbus_aio/exceptions.py new file mode 100644 index 0000000..6bbc13c --- /dev/null +++ b/tcp_modbus_aio/exceptions.py @@ -0,0 +1,13 @@ +from umodbus.exceptions import ModbusError + + +class ModbusCommunicationFailureError(ModbusError): + """Generic modbus communication error.""" + + pass + + +class ModbusNotConnectedError(ModbusCommunicationFailureError): + """Modbus not connected error.""" + + pass diff --git a/tcp_modbus_aio/ping.py b/tcp_modbus_aio/ping.py new file mode 100644 index 0000000..4bf0765 --- /dev/null +++ b/tcp_modbus_aio/ping.py @@ -0,0 +1,37 @@ +import asyncio +import subprocess + + +async def ping_ip(ip: str, timeout: float = 0.5) -> float | None: + """Return the latency in seconds of a ping to the given ip, or None if the ping failed""" + ping_process = await asyncio.create_subprocess_shell( + f"ping -c 1 {ip}", stdout=subprocess.PIPE + ) + + try: + # Wait for the subprocess to complete or timeout + ping_output, stderr = await asyncio.wait_for( + ping_process.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + try: + ping_process.kill() + await ping_process.wait() + except ProcessLookupError: + pass + return None + else: + await ping_process.wait() + + if ping_output is None: + return None + + try: + ping_output_str = ping_output.decode("utf-8") + ping_output_str = ping_output_str.split("\n")[1] + ping_output_str = ping_output_str.split(" ")[6] + ping_output_str = ping_output_str.split("=")[1] + ping_output_latency = float(ping_output_str) + return ping_output_latency + except Exception: + return None diff --git a/tcp_modbus_aio/py.typed b/tcp_modbus_aio/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tcp_modbus_aio/typed_functions.py b/tcp_modbus_aio/typed_functions.py new file mode 100644 index 0000000..a753df2 --- /dev/null +++ b/tcp_modbus_aio/typed_functions.py @@ -0,0 +1,88 @@ +import struct +from typing import Any, TypeVar + +from umodbus.functions import ( + READ_COILS, + READ_DISCRETE_INPUTS, + READ_HOLDING_REGISTERS, + READ_INPUT_REGISTERS, + WRITE_MULTIPLE_COILS, + WRITE_MULTIPLE_REGISTERS, + WRITE_SINGLE_COIL, + WRITE_SINGLE_REGISTER, + ModbusFunction, +) +from umodbus.functions import ReadCoils as ReadCoilsUntyped +from umodbus.functions import ReadDiscreteInputs, ReadHoldingRegisters +from umodbus.functions import ReadInputRegisters as ReadInputRegistersUntyped +from umodbus.functions import ( + WriteMultipleCoils, + WriteMultipleRegisters, + WriteSingleCoil, + WriteSingleRegister, + getfullargspec, + pdu_to_function_code_or_raise_error, +) + +ModbusFunctionT = TypeVar("ModbusFunctionT", bound=ModbusFunction) + + +class ReadCoils(ReadCoilsUntyped): # type: ignore + @property + def return_value(self) -> tuple[bool, ...]: + return tuple(bool(v) for v in self.data[: self.quantity]) + + def __repr__(self) -> str: + return f"ReadInputRegisters({self.starting_address=}, {self.return_value=})" + + +class ReadInputRegisters(ReadInputRegistersUntyped): # type: ignore + def __init__(self, struct_dtype: str = "") -> None: + self.struct_dtype = struct_dtype + super().__init__() + + @property + def return_value(self) -> Any: + return struct.unpack( + self.struct_dtype, struct.pack("<" + "H" * len(self.data), *self.data) + )[0] + + def __repr__(self) -> str: + return f"ReadInputRegisters<{self.struct_dtype}>({self.starting_address=}, {self.return_value=})" + + +function_code_to_function_map = { + READ_COILS: ReadCoils, + READ_DISCRETE_INPUTS: ReadDiscreteInputs, + READ_HOLDING_REGISTERS: ReadHoldingRegisters, + READ_INPUT_REGISTERS: ReadInputRegisters, + WRITE_SINGLE_COIL: WriteSingleCoil, + WRITE_SINGLE_REGISTER: WriteSingleRegister, + WRITE_MULTIPLE_COILS: WriteMultipleCoils, + WRITE_MULTIPLE_REGISTERS: WriteMultipleRegisters, +} + + +def create_function_from_response_pdu( + resp_pdu: bytes, req: ModbusFunctionT +) -> ModbusFunctionT: + """Parse response PDU and return instance of :class:`ModbusFunction` or + raise error. + + :param resp_pdu: PDU of response. + :param req_pdu: Request PDU, some functions require more info than in + response PDU in order to create instance. Default is None. + :return: Number or list with response data. + """ + function_code = pdu_to_function_code_or_raise_error(resp_pdu) + function = function_code_to_function_map[function_code] + + if "req_pdu" in getfullargspec(function.create_from_response_pdu).args: + return_msg = function.create_from_response_pdu(resp_pdu, req.request_pdu) + else: + return_msg = function.create_from_response_pdu(resp_pdu) + + if isinstance(req, ReadInputRegisters): + return_msg.struct_dtype = req.struct_dtype + + return return_msg