diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 16b515b6..4d7624d2 100755 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,6 @@ "extensions": [ "ms-python.python", "charliermarsh.ruff", - "ms-python.black-formatter", "ms-python.vscode-pylance", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7da91946..70a5d49c 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,14 +6,6 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - - repo: local - hooks: - - id: black - name: black - entry: black - language: system - types: [python] - require_serial: true - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.2.1 hooks: diff --git a/.prettierignore b/.prettierignore index bd3739de..c76fb396 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ tests/__snapshots__ +custom_components/foxess_modbus/vendor diff --git a/.vscode/settings.json b/.vscode/settings.json index d9035590..a7538c3f 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,8 @@ "source.fixAll": "explicit" }, "editor.defaultFormatter": "ms-python.black-formatter" - } + }, + "python.analysis.extraPaths": [ + "./custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4" + ] } diff --git a/custom_components/foxess_modbus/client/custom_modbus_tcp_client.py b/custom_components/foxess_modbus/client/custom_modbus_tcp_client.py index 95f3be77..ef6bdf4a 100644 --- a/custom_components/foxess_modbus/client/custom_modbus_tcp_client.py +++ b/custom_components/foxess_modbus/client/custom_modbus_tcp_client.py @@ -5,8 +5,8 @@ from typing import Any from typing import cast -from pymodbus.client import ModbusTcpClient -from pymodbus.exceptions import ConnectionException +from ..vendor.pymodbus import ConnectionException +from ..vendor.pymodbus import ModbusTcpClient _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/foxess_modbus/client/modbus_client.py b/custom_components/foxess_modbus/client/modbus_client.py index 8b6f01ee..2752f69c 100644 --- a/custom_components/foxess_modbus/client/modbus_client.py +++ b/custom_components/foxess_modbus/client/modbus_client.py @@ -11,14 +11,6 @@ import serial from homeassistant.core import HomeAssistant -from pymodbus.client import ModbusSerialClient -from pymodbus.client import ModbusUdpClient -from pymodbus.framer import FramerType -from pymodbus.pdu import ModbusPDU -from pymodbus.pdu.register_read_message import ReadHoldingRegistersResponse -from pymodbus.pdu.register_read_message import ReadInputRegistersResponse -from pymodbus.pdu.register_write_message import WriteMultipleRegistersResponse -from pymodbus.pdu.register_write_message import WriteSingleRegisterResponse from .. import client from ..common.types import ConnectionType @@ -28,6 +20,14 @@ from ..const import TCP from ..const import UDP from ..inverter_adapters import InverterAdapter +from ..vendor.pymodbus import FramerType +from ..vendor.pymodbus import ModbusPDU +from ..vendor.pymodbus import ModbusSerialClient +from ..vendor.pymodbus import ModbusUdpClient +from ..vendor.pymodbus import ReadHoldingRegistersResponse +from ..vendor.pymodbus import ReadInputRegistersResponse +from ..vendor.pymodbus import WriteMultipleRegistersResponse +from ..vendor.pymodbus import WriteSingleRegisterResponse from .custom_modbus_tcp_client import CustomModbusTcpClient _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/foxess_modbus/flow/adapter_flow_segment.py b/custom_components/foxess_modbus/flow/adapter_flow_segment.py index f038b7a2..523793b1 100644 --- a/custom_components/foxess_modbus/flow/adapter_flow_segment.py +++ b/custom_components/foxess_modbus/flow/adapter_flow_segment.py @@ -7,8 +7,6 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import selector -from pymodbus.exceptions import ConnectionException -from pymodbus.exceptions import ModbusIOException from ..client.modbus_client import ModbusClient from ..client.modbus_client import ModbusClientFailedError @@ -23,6 +21,8 @@ from ..inverter_adapters import InverterAdapter from ..inverter_adapters import InverterAdapterType from ..modbus_controller import ModbusController +from ..vendor.pymodbus import ConnectionException +from ..vendor.pymodbus import ModbusIOException from .flow_handler_mixin import FlowHandlerMixin from .flow_handler_mixin import ValidationFailedError from .inverter_data import InverterData diff --git a/custom_components/foxess_modbus/manifest.json b/custom_components/foxess_modbus/manifest.json index 9d774a57..dcbc111e 100755 --- a/custom_components/foxess_modbus/manifest.json +++ b/custom_components/foxess_modbus/manifest.json @@ -8,6 +8,5 @@ "integration_type": "service", "iot_class": "local_push", "issue_tracker": "https://github.com/nathanmarlor/foxess_modbus/issues", - "requirements": ["pymodbus>=3.7.4"], "version": "1.0.0" } diff --git a/custom_components/foxess_modbus/modbus_controller.py b/custom_components/foxess_modbus/modbus_controller.py index abceb8a2..5d29381e 100644 --- a/custom_components/foxess_modbus/modbus_controller.py +++ b/custom_components/foxess_modbus/modbus_controller.py @@ -18,7 +18,6 @@ from homeassistant.helpers import issue_registry from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity -from pymodbus.exceptions import ConnectionException from .client.modbus_client import ModbusClient from .client.modbus_client import ModbusClientFailedError @@ -38,6 +37,7 @@ from .inverter_profiles import INVERTER_PROFILES from .inverter_profiles import InverterModelConnectionTypeProfile from .remote_control_manager import RemoteControlManager +from .vendor.pymodbus import ConnectionException _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/foxess_modbus/services/update_charge_period_service.py b/custom_components/foxess_modbus/services/update_charge_period_service.py index 9f28d161..e3d967bb 100644 --- a/custom_components/foxess_modbus/services/update_charge_period_service.py +++ b/custom_components/foxess_modbus/services/update_charge_period_service.py @@ -10,13 +10,13 @@ from homeassistant.core import ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from pymodbus.exceptions import ModbusIOException from ..const import DOMAIN from ..entities.modbus_charge_period_sensors import is_time_value_valid from ..entities.modbus_charge_period_sensors import parse_time_value from ..entities.modbus_charge_period_sensors import serialize_time_to_value from ..modbus_controller import ModbusController +from ..vendor.pymodbus import ModbusIOException from .utils import get_controller_from_friendly_name_or_device_id _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/custom_components/foxess_modbus/services/write_registers_service.py b/custom_components/foxess_modbus/services/write_registers_service.py index 3b3fa659..c42b821a 100644 --- a/custom_components/foxess_modbus/services/write_registers_service.py +++ b/custom_components/foxess_modbus/services/write_registers_service.py @@ -8,10 +8,10 @@ from homeassistant.core import ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from pymodbus.exceptions import ModbusIOException from ..const import DOMAIN from ..modbus_controller import ModbusController +from ..vendor.pymodbus import ModbusIOException from .utils import get_controller_from_friendly_name_or_device_id _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/__init__.py new file mode 100644 index 00000000..9107a25c --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/__init__.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +# Update python.analysis.extraPaths in .vscode/settings.json if you change this. +# If changed, make sure subclasses in modbus_client are still valid! +sys.path.insert(0, str((Path(__file__).parent / "pymodbus-3.7.4").absolute())) + +from pymodbus.client import ModbusSerialClient +from pymodbus.client import ModbusTcpClient +from pymodbus.client import ModbusUdpClient +from pymodbus.exceptions import ConnectionException +from pymodbus.exceptions import ModbusIOException +from pymodbus.framer import FramerType +from pymodbus.pdu import ModbusPDU +from pymodbus.pdu.register_read_message import ReadHoldingRegistersResponse +from pymodbus.pdu.register_read_message import ReadInputRegistersResponse +from pymodbus.pdu.register_write_message import WriteMultipleRegistersResponse +from pymodbus.pdu.register_write_message import WriteSingleRegisterResponse + +sys.path.pop(0) + +__all__ = [ + "ModbusSerialClient", + "ModbusTcpClient", + "ModbusUdpClient", + "ConnectionException", + "ModbusIOException", + "FramerType", + "ModbusPDU", + "ReadHoldingRegistersResponse", + "ReadInputRegistersResponse", + "WriteMultipleRegistersResponse", + "WriteSingleRegisterResponse", +] diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/bin/pymodbus.simulator.exe b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/bin/pymodbus.simulator.exe new file mode 100644 index 00000000..51aa2318 Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/bin/pymodbus.simulator.exe differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/AUTHORS.rst b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/AUTHORS.rst new file mode 100644 index 00000000..9f1a8e91 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/AUTHORS.rst @@ -0,0 +1,185 @@ +Authors +======= +All these versions would not be possible without volunteers! + +This is a complete list for each major version. + +A big "thank you" to everybody who helped out. + +Pymodbus version 3 family +------------------------- +Thanks to + +- ahcm-dev +- AKJ7 +- Alex +- Alex Ruddick +- Alexander Lanin +- Alexandre CUER +- Alois Hockenschlohe +- Andy Walker +- Arjan +- André Srinivasan +- andrew-harness +- banana-sun +- Blaise Thompson +- CapraTheBest +- cgernert +- corollaries +- Chandler Riehm +- Chris Hung +- Christian Krause +- Daniel Rauber +- dhoomakethu +- doelki +- DominicDataP +- Dominique Martinet +- Dries +- duc996 +- efdx +- Esco441-91 +- Farzad Panahi +- Fredo70 +- Gao Fang +- Ghostkeeper +- Hangyu Fan +- Hayden Roche +- Iktek +- Ilkka Ollakka +- Jakob Ruhe +- Jakob Schlyter +- James Braza +- James Cameron +- James Hilliard +- jan iversen +- Jerome Velociter +- Joe Burmeister +- John Miko +- Jonathan Reichelt Gjertsen +- julian +- Justin Standring +- Kenny Johansson +- Kürşat Aktaş +- laund +- Logan Gunthorpe +- Marko Luther +- Martyy +- Máté Szabó +- Matthias Straka +- Matthias Urlichs +- Michel F +- Mickaël Schoentgen +- Pavel Kostromitinov +- peufeu2 +- Philip Couling +- Qi Li +- Sebastian Machuca +- Sefa Keleş +- Steffen Beyer +- sumguytho +- Thijs W +- Totally a booplicate +- WouterTuinstra +- wriswith +- Yash Jani +- Yohrog +- yyokusa + + +Pymodbus version 2 family +------------------------- +Thanks to + +- alecjohanson +- Alexey Andreyev +- Andrea Canidio +- Carlos Gomez +- Cougar +- Christian Sandberg +- dhoomakethu +- dices +- Dmitri Zimine +- Emil Vanherp +- er888kh +- Eric Duminil +- Erlend Egeberg Aasland +- hackerboygn +- Jian-Hong Pan +- Jose J Rodriguez +- Justin Searle +- Karl Palsson +- Kim Hansen +- Kristoffer Sjöberg +- Kyle Altendorf +- Lars Kruse +- Malte Kliemann +- Memet Bilgin +- Michael Corcoran +- Mike +- sanjay +- Sekenre +- Siarhei Farbotka +- Steffen Vogel +- tcplomp +- Thor Michael Støre +- Tim Gates +- Ville Skyttä +- Wild Stray +- Yegor Yefremov + + +Pymodbus version 1 family +------------------------- +Thanks to + +- Antoine Pitrou +- Bart de Waal +- bashwork +- bje- +- Claudio Catterina +- Chintalagiri Shashank +- dhoomakethu +- dragoshenron +- Elvis Stansvik +- Eren Inan Canpolat +- Everley +- Fabio Bonelli +- fleimgruber +- francozappa +- Galen Collins +- Gordon Broom +- Hamilton Kibbe +- Hynek Petrak +- idahogray +- Ingo van Lil +- Jack +- jbiswas +- jon mills +- Josh Kelley +- Karl Palsson +- Matheus Frata +- Patrick Fuller +- Perry Kundert +- Philippe Gauthier +- Rahul Raghunath +- sanjay +- schubduese42 +- semyont +- Semyon Teplitsky +- Stuart Longland +- Yegor Yefremov + + +Pymodbus version 0 family +------------------------- +Thanks to + +- Albert Brandl +- Galen Collins + +Import to github was based on code from: + +- S.W.A.C. GmbH, Germany. +- S.W.A.C. Bohemia s.r.o., Czech Republic. +- Hynek Petrak +- Galen Collins diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/INSTALLER b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/LICENSE b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/LICENSE new file mode 100644 index 00000000..d3dda3d9 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/LICENSE @@ -0,0 +1,23 @@ +Copyright 2008-2023 Pymodbus + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/METADATA b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/METADATA new file mode 100644 index 00000000..5e895905 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/METADATA @@ -0,0 +1,423 @@ +Metadata-Version: 2.1 +Name: pymodbus +Version: 3.7.4 +Summary: A fully featured modbus protocol stack in python +Author: Galen Collins, Jan Iversen +Maintainer: dhoomakethu, janiversen +License: BSD-3-Clause +Project-URL: Homepage, https://github.com/pymodbus-dev/pymodbus/ +Project-URL: Source Code, https://github.com/pymodbus-dev/pymodbus +Project-URL: Bug Reports, https://github.com/pymodbus-dev/pymodbus/issues +Project-URL: Docs: Dev, https://pymodbus.readthedocs.io/en/latest/?badge=latest +Project-URL: Discord, https://discord.gg/vcP8qAz2 +Keywords: modbus,asyncio,scada,client,server,simulator +Platform: 'Linux' +Platform: 'Mac OS X' +Platform: 'Win' +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Console +Classifier: Framework :: AsyncIO +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: POSIX :: Linux +Classifier: Operating System :: Unix +Classifier: Operating System :: MacOS :: MacOS X +Classifier: Operating System :: OS Independent +Classifier: Operating System :: Microsoft +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Topic :: System :: Networking +Classifier: Topic :: Utilities +Requires-Python: >=3.9.0 +Description-Content-Type: text/x-rst +License-File: LICENSE +License-File: AUTHORS.rst +Provides-Extra: all +Requires-Dist: pymodbus[development,documentation,repl,serial,simulator] ; extra == 'all' +Provides-Extra: development +Requires-Dist: build >=1.2.2 ; extra == 'development' +Requires-Dist: codespell >=2.3.0 ; extra == 'development' +Requires-Dist: coverage >=7.6.1 ; extra == 'development' +Requires-Dist: mypy >=1.11.2 ; extra == 'development' +Requires-Dist: pylint >=3.3.0 ; extra == 'development' +Requires-Dist: pytest >=8.3.3 ; extra == 'development' +Requires-Dist: pytest-asyncio >=0.24.0 ; extra == 'development' +Requires-Dist: pytest-cov >=5.0.0 ; extra == 'development' +Requires-Dist: pytest-timeout >=2.3.1 ; extra == 'development' +Requires-Dist: pytest-xdist >=3.6.1 ; extra == 'development' +Requires-Dist: pytest-aiohttp >=1.0.5 ; extra == 'development' +Requires-Dist: ruff >=0.5.3 ; extra == 'development' +Requires-Dist: twine >=5.1.1 ; extra == 'development' +Requires-Dist: types-Pygments ; extra == 'development' +Requires-Dist: types-pyserial ; extra == 'development' +Requires-Dist: pytest-profiling >=1.7.0 ; (python_version < "3.13") and extra == 'development' +Provides-Extra: documentation +Requires-Dist: recommonmark >=0.7.1 ; extra == 'documentation' +Requires-Dist: Sphinx >=7.3.7 ; extra == 'documentation' +Requires-Dist: sphinx-rtd-theme >=2.0.0 ; extra == 'documentation' +Provides-Extra: repl +Requires-Dist: pymodbus-repl >=2.0.4 ; extra == 'repl' +Provides-Extra: serial +Requires-Dist: pyserial >=3.5 ; extra == 'serial' +Provides-Extra: simulator +Requires-Dist: aiohttp >=3.8.6 ; (python_version < "3.12") and extra == 'simulator' +Requires-Dist: aiohttp >=3.10.5 ; (python_version >= "3.12") and extra == 'simulator' + +PyModbus - A Python Modbus Stack +================================ +.. image:: https://github.com/pymodbus-dev/pymodbus/actions/workflows/ci.yml/badge.svg?branch=dev + :target: https://github.com/pymodbus-dev/pymodbus/actions/workflows/ci.yml +.. image:: https://readthedocs.org/projects/pymodbus/badge/?version=latest + :target: https://pymodbus.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status +.. image:: https://pepy.tech/badge/pymodbus + :target: https://pepy.tech/project/pymodbus + :alt: Downloads +.. image:: https://img.shields.io/badge/Gurubase-Ask%20PyModbus%20Guru-006BFF + :target: https://gurubase.io/g/pymodbus + :alt: PyModbus Guru + +Pymodbus is a full Modbus protocol implementation offering client/server with synchronous/asynchronous API and simulators. + +Our releases is defined as X.Y.Z, and we have strict rules what to release when: + +- **Z**, No API changes! bug fixes and smaller enhancements. +- **Y**, API changes, bug fixes and bigger enhancements. +- **X**, Major changes in API and/or method to use pymodbus + +Upgrade examples: + +- 3.6.1 -> 3.6.9: just plugin the new version, no changes needed. +- 3.6.1 -> 3.7.0: Smaller changes to the pymodbus calls might be needed +- 2.5.4 -> 3.0.0: Major changes in the application might be needed + +Current release is `3.7.4 <https://github.com/pymodbus-dev/pymodbus/releases/tag/v3.7.4>`_. + +Bleeding edge (not released) is `dev <https://github.com/pymodbus-dev/pymodbus/tree/dev>`_. + +Waiting for v3.8.0 (not released) is `wait_next_API <https://github.com/pymodbus-dev/pymodbus/tree/wait_next_API>`_. This contains +dev + merged pull requests that have API changes, and thus have to wait. + +All changes are described in `release notes <https://pymodbus.readthedocs.io/en/latest/source/changelog.html>`_ +and all API changes are `documented <https://pymodbus.readthedocs.io/en/latest/source/api_changes.html>`_ + +A big thanks to all the `volunteers <https://pymodbus.readthedocs.io/en/latest/source/authors.html>`_ that helps make pymodbus a great project. + +Source code on `github <https://github.com/pymodbus-dev/pymodbus>`_ + +Pymodbus in a nutshell +---------------------- +Pymodbus consist of 5 parts: + +- **client**, connect to your favorite device(s) +- **server**, simulate your favorite device(s) +- **repl**, a commandline text based client/server simulator +- **simulator**, an html based server simulator +- **examples**, showing both simple and advances usage + +Common features +^^^^^^^^^^^^^^^ +* Full modbus standard protocol implementation +* Support for custom function codes +* support serial (rs-485), tcp, tls and udp communication +* support all standard frames: socket, rtu, rtu-over-tcp, tcp and ascii +* does not have third party dependencies, apart from pyserial (optional) +* very lightweight project +* requires Python >= 3.9 +* thorough test suite, that test all corners of the library +* automatically tested on Windows, Linux and MacOS combined with python 3.9 - 3.13 +* strongly typed API (py.typed present) + +The modbus protocol specification: Modbus_Application_Protocol_V1_1b3.pdf can be found on +`modbus org <https://modbus.org>`_ + + +Client Features +^^^^^^^^^^^^^^^ +* asynchronous API and synchronous API for applications +* very simple setup and call sequence (just 6 lines of code) +* utilities to convert int/float to/from multiple registers +* payload builder/decoder to help with complex data + +`Client documentation <https://pymodbus.readthedocs.io/en/latest/source/client.html>`_ + + +Server Features +^^^^^^^^^^^^^^^ +* asynchronous implementation for high performance +* synchronous API classes for convenience +* simulate real life devices +* full server control context (device information, counters, etc) +* different backend datastores to manage register values +* callback to intercept requests/responses +* work on RS485 in parallel with other devices + +`Server documentation <https://pymodbus.readthedocs.io/en/latest/source/server.html>`_ + + +REPL Features +^^^^^^^^^^^^^ +- server/client commandline emulator +- easy test of real device (client) +- easy test of client app (server) +- simulation of broken requests/responses +- simulation of error responses (hard to provoke in real devices) + +`REPL documentation <https://github.com/pymodbus-dev/repl>`_ + + +Simulator Features +^^^^^^^^^^^^^^^^^^ +- server simulator with WEB interface +- configure the structure of a real device +- monitor traffic online +- allow distributed team members to work on a virtual device using internet +- simulation of broken requests/responses +- simulation of error responses (hard to provoke in real devices) + +`Simulator documentation <https://pymodbus.readthedocs.io/en/dev/source/simulator.html>`_ + +Use Cases +--------- +The client is the most typically used. It is embedded into applications, +where it abstract the modbus protocol from the application by providing an +easy to use API. The client is integrated into some well known projects like +`home-assistant <https://www.home-assistant.io>`_. + +Although most system administrators will find little need for a Modbus +server, the server is handy to verify the functionality of an application. + +The simulator and/or server is often used to simulate real life devices testing +applications. The server is excellent to perform high volume testing (e.g. +houndreds of devices connected to the application). The advantage of the server is +that it runs not only a "normal" computers but also on small ones like Raspberry PI. + +Since the library is written in python, it allows for easy scripting and/or integration into their existing +solutions. + +For more information please browse the project documentation: + +https://readthedocs.org/docs/pymodbus/en/latest/index.html + + + +Install +------- +The library is available on pypi.org and github.com to install with + +- :code:`pip` for those who just want to use the library +- :code:`git clone` for those who wants to help or just are curious + +Be aware that there are a number of project, who have forked pymodbus and + +- seems just to provide a version frozen in time +- extended pymodbus with extra functionality + +The latter is not because we rejected the extra functionality (we welcome all changes), +but because the codeowners made that decision. + +In both cases, please understand, we cannot offer support to users of these projects as we do not known +what have been changed nor what status the forked code have. + +A growing number of Linux distributions include pymodbus in their standard installation. + +You need to have python3 installed, preferable 3.11. + +Install with pip +^^^^^^^^^^^^^^^^ +You can install using pip by issuing the following +commands in a terminal window:: + + pip install pymodbus + +If you want to use the serial interface:: + + pip install pymodbus[serial] + +This will install pymodbus with the pyserial dependency. + +Pymodbus offers a number of extra options: + +- **repl**, needed by pymodbus.repl +- **serial**, needed for serial communication +- **simulator**, needed by pymodbus.simulator +- **documentation**, needed to generate documentation +- **development**, needed for development +- **all**, installs all of the above + +which can be installed as:: + + pip install pymodbus[<option>,...] + +It is possible to install old releases if needed:: + + pip install pymodbus==3.5.4 + + +Install with github +^^^^^^^^^^^^^^^^^^^ +On github, fork https://github.com/pymodbus-dev/pymodbus.git + +Clone the source, and make a virtual environment:: + + + git clone git://github.com/<your account>/pymodbus.git + cd pymodbus + python3 -m venv .venv + +Activate the virtual environment, this command needs repeated in every new terminal:: + + source .venv/bin/activate + +To get a specific release:: + + git checkout v3.5.2 + +or the bleeding edge:: + + git checkout dev + +Some distributions have an old pip, which needs to be upgraded: + + pip install --upgrade pip + +Install required development tools:: + + pip install ".[development]" + +Install all (allows creation of documentation etc): + + pip install ".[all]" + +Install git hooks, that helps control the commit and avoid errors when submitting a Pull Request: + + cp githooks/* .git/hooks + +This installs dependencies in your virtual environment +with pointers directly to the pymodbus directory, +so any change you make is immediately available as if installed. + +The repository contains a number of important branches and tags. + * **dev** is where all development happens, this branch is not always stable. + * **master** is where are releases are kept. + * **vX.Y.Z** (e.g. v2.5.3) is a specific release + + +Example Code +------------ +For those of you that just want to get started fast, here you go:: + + from pymodbus.client import ModbusTcpClient + + client = ModbusTcpClient('MyDevice.lan') + client.connect() + client.write_coil(1, True) + result = client.read_coils(1,1) + print(result.bits[0]) + client.close() + +We provide a couple of simple ready to go clients: + +- `async client <https://github.com/pymodbus-dev/pymodbus/blob/dev/examples/simple_async_client.py>`_ +- `sync client <https://github.com/pymodbus-dev/pymodbus/blob/dev/examples/simple_sync_client.py>`_ + +For more advanced examples, check out `Examples <https://pymodbus.readthedocs.io/en/dev/source/examples.html>`_ included in the +repository. If you have created any utilities that meet a specific +need, feel free to submit them so others can benefit. + +Also, if you have a question, please `create a post in discussions q&a topic <https://github.com/pymodbus-dev/pymodbus/discussions/new?category=q-a>`_, +so that others can benefit from the results. + +If you think, that something in the code is broken/not running well, please `open an issue <https://github.com/pymodbus-dev/pymodbus/issues/new>`_, +read the Template-text first and then post your issue with your setup information. + +`Example documentation <https://pymodbus.readthedocs.io/en/dev/source/examples.html>`_ + + +Contributing +------------ +Just fork the repo and raise your Pull Request against :code:`dev` branch. + +We always have more work than time, so feel free to open a discussion / issue on a theme you want to solve. + +If your company would like your device tested or have a cloud based device +simulation, feel free to contact us. +We are happy to help your company solve your modbus challenges. + +That said, the current work mainly involves polishing the library and +solving issues: + +* Fixing bugs/feature requests +* Architecture documentation +* Functional testing against any reference we can find + +There are 2 bigger projects ongoing: + + * rewriting the internal part of all clients (both sync and async) + * Add features to and simulator, and enhance the web design + + +Development instructions +------------------------ +The current code base is compatible with python >= 3.9. + +Here are some of the common commands to perform a range of activities:: + + source .venv/bin/activate <-- Activate the virtual environment + ./check_ci.sh <-- run the same checks as CI runs on a pull request. + + +Make a pull request:: + + git checkout dev <-- activate development branch + git pull <-- update branch with newest changes + git checkout -b feature <-- make new branch for pull request + ... make source changes + git commit <-- commit change to git + git push <-- push to your account on github + + on github open a pull request, check that CI turns green and then wait for review comments. + +Test your changes:: + + cd test + pytest + +you can also do extended testing:: + + pytest --cov <-- Coverage html report in build/html + pytest --profile <-- Call profile report in prof + +Internals +^^^^^^^^^ + +There are no documentation of the architecture (help is welcome), but most classes and +methods are documented: + +`Pymodbus internals <https://pymodbus.readthedocs.io/en/dev/source/internals.html>`_ + + +Generate documentation +^^^^^^^^^^^^^^^^^^^^^^ + +**Remark** Assumes that you have installed documentation tools:; + + pip install ".[documentation]" + +to build do:: + + cd doc + ./build_html + +The documentation is available in <root>/build/html + +Remark: this generates a new zip/tgz file of examples which are uploaded. + + +License Information +------------------- + +Released under the `BSD License <https://github.com/pymodbus-dev/pymodbus/blob/dev/LICENSE>`_ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/RECORD b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/RECORD new file mode 100644 index 00000000..e8f356ac --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/RECORD @@ -0,0 +1,122 @@ +../../bin/pymodbus.simulator.exe,sha256=jBfNi1RbmT59KnnTPmE6BcrGdRCv_9RSwOkx2exzTh8,108388 +pymodbus-3.7.4.dist-info/AUTHORS.rst,sha256=-3j3nlLKABLEBIQ0PdEe8i8HEN0-SrHcgZIAcQSSdxk,2761 +pymodbus-3.7.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +pymodbus-3.7.4.dist-info/LICENSE,sha256=R_kEJJFy55Wmsy3pH0cQcyl77__XThfK_CiNNIpVly4,1367 +pymodbus-3.7.4.dist-info/METADATA,sha256=v4vK2xwTwj7cPnclxhmsj7bu01k3-nPWIXFokCDNlLg,15450 +pymodbus-3.7.4.dist-info/RECORD,, +pymodbus-3.7.4.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +pymodbus-3.7.4.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91 +pymodbus-3.7.4.dist-info/entry_points.txt,sha256=-ig4bOfoNwZ2C3GqUWs2DgArzF8YzgdlmcWOUa1EjTc,75 +pymodbus-3.7.4.dist-info/top_level.txt,sha256=HJWcaj1-eMuBze0N2TieOGTgQD6XNXgTaqG7Ik6oxw0,9 +pymodbus-3.7.4.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1 +pymodbus/__init__.py,sha256=zY0kf_meZKxvNPDt4C65XoLc-vl1xOSKqKzwrDVwSRY,512 +pymodbus/__pycache__/__init__.cpython-313.pyc,, +pymodbus/__pycache__/constants.cpython-313.pyc,, +pymodbus/__pycache__/device.cpython-313.pyc,, +pymodbus/__pycache__/events.cpython-313.pyc,, +pymodbus/__pycache__/exceptions.cpython-313.pyc,, +pymodbus/__pycache__/logging.cpython-313.pyc,, +pymodbus/__pycache__/payload.cpython-313.pyc,, +pymodbus/__pycache__/transaction.cpython-313.pyc,, +pymodbus/__pycache__/utilities.cpython-313.pyc,, +pymodbus/client/__init__.py,sha256=fkvpNNXZfVpLtYbDHpf7cAIcHq-iNtrMLljySOD-Sds,603 +pymodbus/client/__pycache__/__init__.cpython-313.pyc,, +pymodbus/client/__pycache__/base.cpython-313.pyc,, +pymodbus/client/__pycache__/mixin.cpython-313.pyc,, +pymodbus/client/__pycache__/modbusclientprotocol.cpython-313.pyc,, +pymodbus/client/__pycache__/serial.cpython-313.pyc,, +pymodbus/client/__pycache__/tcp.cpython-313.pyc,, +pymodbus/client/__pycache__/tls.cpython-313.pyc,, +pymodbus/client/__pycache__/udp.cpython-313.pyc,, +pymodbus/client/base.py,sha256=N9-0yt9jTYQOa1R1OM1hZzs2fjEr8Lx9P0rwL55rYRk,10460 +pymodbus/client/mixin.py,sha256=_YOcVDw25fR9ho1q_GM9cKJL8G8ygZlsbJNlF0hrtKs,23947 +pymodbus/client/modbusclientprotocol.py,sha256=pdCGZivHwBcb1lEouM7eDTC3mr2cSefN30_PzXtKd5I,2712 +pymodbus/client/serial.py,sha256=E3e2N0xd5yoEzyF6emSviZTsGR9gv6cO3lN-CSIVar8,12351 +pymodbus/client/tcp.py,sha256=RsCJdPB1fkDAvVrR5Aa8UXnzH9pGoB6pXZxupkftoUI,10182 +pymodbus/client/tls.py,sha256=uLz6eosdVsIpDNtfFfVM1RkgXszrY5MbEjcIXLwFFZk,7788 +pymodbus/client/udp.py,sha256=Ci7-Oo5X2vnn4uxKVcpt1hQn5q2MX7PbDjJyi8YomVk,6817 +pymodbus/constants.py,sha256=SY8UeXMtCiNrzPidW8drNmv07PuNsConfrsI6CEcNs8,3499 +pymodbus/datastore/__init__.py,sha256=7DoMjoxfHQYq_7Sn9buUq4dOOHzNot113AmrwgEHliE,491 +pymodbus/datastore/__pycache__/__init__.cpython-313.pyc,, +pymodbus/datastore/__pycache__/context.cpython-313.pyc,, +pymodbus/datastore/__pycache__/remote.cpython-313.pyc,, +pymodbus/datastore/__pycache__/simulator.cpython-313.pyc,, +pymodbus/datastore/__pycache__/store.cpython-313.pyc,, +pymodbus/datastore/context.py,sha256=eICS-XBGrumZw81rpd7_WuRXllzU6qJNXEZjOoWpc_U,9061 +pymodbus/datastore/remote.py,sha256=3NbJWbFc3NOgHuIV4jFtxY0tCKjUTuaZM9eXI3luz80,4418 +pymodbus/datastore/simulator.py,sha256=H-5Y_6UiYF3kpo0-IY3e-BxMaSSJVyvyabjcS16WDIs,30931 +pymodbus/datastore/store.py,sha256=-XRkFlbj6igeSU7WXf754fJJg53OwlSTvO2g9LgzHt0,12036 +pymodbus/device.py,sha256=5-Uja0DjcX99WIW6rUEX3PiROeOkRhdRAJOsIPRWtcY,22039 +pymodbus/events.py,sha256=EG5BJ3liCtCmqVXWPTL4AsvI8gyofMu93iJ04r8YLCM,6318 +pymodbus/exceptions.py,sha256=cxHWXIj9LWBa8Ckvvbki3J36jySB0fFJiyEd9h5bkv4,3189 +pymodbus/framer/__init__.py,sha256=j_lGV9ifsHUmiZeHf15smPenmsT0A2-c4Kwdgjpk43Q,534 +pymodbus/framer/__pycache__/__init__.cpython-313.pyc,, +pymodbus/framer/__pycache__/ascii.cpython-313.pyc,, +pymodbus/framer/__pycache__/base.cpython-313.pyc,, +pymodbus/framer/__pycache__/rtu.cpython-313.pyc,, +pymodbus/framer/__pycache__/socket.cpython-313.pyc,, +pymodbus/framer/__pycache__/tls.cpython-313.pyc,, +pymodbus/framer/ascii.py,sha256=Pm4Dxm_q8psdd8ZPPipip5x1VzA18gJe7ako7V3xUAw,3011 +pymodbus/framer/base.py,sha256=ksXcHqHPFbuAFZZUwiBNrr4nKd4wIXvrKJBe3J2ATBg,3068 +pymodbus/framer/rtu.py,sha256=0gs2k9CYz5ZB_78QiRpaCoOWwY_IJU-3Wptf5ZxJtfQ,5951 +pymodbus/framer/socket.py,sha256=bqXNfBRvKsvIXNePlePFJBfLQjcKqWIALaFpn9N_PRU,1444 +pymodbus/framer/tls.py,sha256=TAaz-T9HtcpNh9RzqXHhsNyk2r8E3pPx50ShmJOFtzo,509 +pymodbus/logging.py,sha256=5TbQaW_VZZyds1m4Dt8QFFNe-Y3BT5Jmt4OmW8VQlvI,3996 +pymodbus/payload.py,sha256=cEzL6jij8bT1IiQUVXUDXRNgoxjPFqo6CaacY_JJGBM,15402 +pymodbus/pdu/__init__.py,sha256=sNWx9obbcXAdtyybsJvndpzAkHx5y5kNEFD4C53tgEc,257 +pymodbus/pdu/__pycache__/__init__.cpython-313.pyc,, +pymodbus/pdu/__pycache__/bit_read_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/bit_write_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/decoders.cpython-313.pyc,, +pymodbus/pdu/__pycache__/diag_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/file_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/mei_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/other_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/pdu.cpython-313.pyc,, +pymodbus/pdu/__pycache__/register_read_message.cpython-313.pyc,, +pymodbus/pdu/__pycache__/register_write_message.cpython-313.pyc,, +pymodbus/pdu/bit_read_message.py,sha256=6vi252pm59xB9jsuz4Ffv3sDLPJO9T7PTbER5snk_IQ,9340 +pymodbus/pdu/bit_write_message.py,sha256=wZHUL_NqsrKXu6Jm8qdzsazB22lzjJnpC5ZmU1-E24k,9237 +pymodbus/pdu/decoders.py,sha256=OsQeLqQ0BVUk1kRa0zrycxl_G2j1IghAq7mAxAWDpuo,6642 +pymodbus/pdu/diag_message.py,sha256=kbRw10nPXo_5hyVVemApBcgHLz_Ofn-cun3VwIfGZRQ,30729 +pymodbus/pdu/file_message.py,sha256=baO1o7WNftZ8agOZrKqgaOwyWiQZ0tcKXKqgJ_YzqGs,15316 +pymodbus/pdu/mei_message.py,sha256=zHJNc1UaZ2g17EdE6Adfrt6LJiyCASSN4rcxEGp-m2Q,7972 +pymodbus/pdu/other_message.py,sha256=9f_Lv3PcNtCbAKxty7RdDeU0oRtMVNs5-x1Dr5x616g,15541 +pymodbus/pdu/pdu.py,sha256=QjVeb7NTBGmA45UbuUOTrfPwcqPLcjru5OQd24_Iswk,3992 +pymodbus/pdu/register_read_message.py,sha256=PGwsJ43Ccr0OEsBuUNEpZo34DMpxo8MDSWwR0QcDPiQ,13564 +pymodbus/pdu/register_write_message.py,sha256=K1P2MHsifKa-pfFCLj9Z9M2TFbMwW4kC_6svm2ckOWc,12804 +pymodbus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +pymodbus/server/__init__.py,sha256=q6EiG-hXtjtj-OP5a39qmUPlFiF18HuAnk7J0j3JNJE,996 +pymodbus/server/__pycache__/__init__.cpython-313.pyc,, +pymodbus/server/__pycache__/async_io.cpython-313.pyc,, +pymodbus/server/async_io.py,sha256=1B1tlr8xzlqX_QeOR8ycdUKR6canDhBDl342hvbZJ6o,26703 +pymodbus/server/simulator/__init__.py,sha256=TkIbeFNGu-JC0VbN64G4ImZOZdWUTZyXY0x1IQY0OQk,18 +pymodbus/server/simulator/__pycache__/__init__.cpython-313.pyc,, +pymodbus/server/simulator/__pycache__/custom_actions.cpython-313.pyc,, +pymodbus/server/simulator/__pycache__/http_server.cpython-313.pyc,, +pymodbus/server/simulator/__pycache__/main.cpython-313.pyc,, +pymodbus/server/simulator/custom_actions.py,sha256=96R3tp7u5yGF5P4gNvIde9pY3M5KhlkEgGstHzg-Szk,187 +pymodbus/server/simulator/http_server.py,sha256=bdv2-1F11f_yQcgn875gRWdnvb133flvK8Ipfd7Wsqo,31476 +pymodbus/server/simulator/main.py,sha256=kKceLq968V7u2mnFMpsnn2O-aFeHde6evCEH00A98S4,4200 +pymodbus/server/simulator/setup.json,sha256=SG-qrG50Nh1zM_YHPKm0QIo2gh-zjKorGzsbPLsvODU,7643 +pymodbus/server/simulator/web/apple120.png,sha256=kymnXpkuOnlk13QIlBHZ30RXqYL0Mep6PQ7BMHhON14,11369 +pymodbus/server/simulator/web/apple152.png,sha256=56hV4HGeyfnBlbHgnMt_shWuVTJB7dKXx7EQpt9rjEc,15391 +pymodbus/server/simulator/web/apple60.png,sha256=2YegT0ZTkBCQ3v32S8dmUOqCLdXXg2UnFp4QKYMWGrQ,4817 +pymodbus/server/simulator/web/apple76.png,sha256=LDrwW4YW1ijbx1k2i384wka05U9NvBW5yvNfCTEXDe0,6344 +pymodbus/server/simulator/web/favicon.ico,sha256=-eUdKiH2TBpF3HHawVjLnIaP_Y1oqe2t_hS0dOCY2ZY,12014 +pymodbus/server/simulator/web/generator/calls,sha256=Lr7tTaTf8M7XT2vlrvROqLjyA07miMOExdsi7tmu60A,4314 +pymodbus/server/simulator/web/generator/log,sha256=D-lDQKT0SZoNzTGZL-B05RdjvPdRF_6WxB5mJrpnaGc,1132 +pymodbus/server/simulator/web/generator/pymodbus_icon_original.png,sha256=0VIAZ8wS-SP9qUfBVHaChzcNrAQzojyacFnizcieLIY,5850 +pymodbus/server/simulator/web/generator/registers,sha256=7qD_pRY8szWJp9miATGVdCrUSkV1m0U7ZcRGVPsuq2U,2710 +pymodbus/server/simulator/web/generator/server,sha256=lvkxOEA9HtP9cWhcabWK4veFIiNDpg4HunyvcedVul0,893 +pymodbus/server/simulator/web/index.html,sha256=TQO1qDFdCAvEF5t2yS2xJ-IUuvg1EcYAP_0qTxvex2c,1944 +pymodbus/server/simulator/web/pymodbus.css,sha256=0Nc2PYZ6bEgvOnvvSXICJvAhFJI-m7XOFDrCNIfcieA,919 +pymodbus/server/simulator/web/welcome.html,sha256=6VLVyWdXMIVawjE56IkXITlotkXG-kJhu5zjjzZOXuI,710 +pymodbus/transaction.py,sha256=_Ud1fvt_Lo16LtCp8qZ5gHM5KQXicoa_DrKI8lwMU-U,17512 +pymodbus/transport/__init__.py,sha256=H9lHMyh5as-OlNnfSUYbaa8pFjf1D7QZt0skOWX_C0A,225 +pymodbus/transport/__pycache__/__init__.cpython-313.pyc,, +pymodbus/transport/__pycache__/serialtransport.cpython-313.pyc,, +pymodbus/transport/__pycache__/transport.cpython-313.pyc,, +pymodbus/transport/serialtransport.py,sha256=bWQ-HjFLrWHVmRZXiwOVmHR2IoAvj0eM9SVUTOmigAQ,6223 +pymodbus/transport/transport.py,sha256=gW58_4rd7DZreWQsX2zuhDmhWJ3ZoOM2yU3S_MxvnqU,23242 +pymodbus/utilities.py,sha256=mkmB_x7tKH9VaI4VoPVB9R2-SkHoJmoI-V1qigAzx8Q,4612 diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/REQUESTED b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/REQUESTED new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/WHEEL b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/WHEEL new file mode 100644 index 00000000..da25d7b4 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (75.2.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/entry_points.txt b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/entry_points.txt new file mode 100644 index 00000000..4a799e95 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +pymodbus.simulator = pymodbus.server.simulator.main:main diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/top_level.txt b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/top_level.txt new file mode 100644 index 00000000..b5140c7e --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/top_level.txt @@ -0,0 +1 @@ +pymodbus diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/zip-safe b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/zip-safe new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus-3.7.4.dist-info/zip-safe @@ -0,0 +1 @@ + diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/__init__.py new file mode 100644 index 00000000..c2fed39e --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/__init__.py @@ -0,0 +1,22 @@ +"""Pymodbus: Modbus Protocol Implementation. + +Released under the BSD license +""" + +__all__ = [ + "ExceptionResponse", + "FramerType", + "ModbusException", + "pymodbus_apply_logging_config", + "__version__", + "__version_full__", +] + +from pymodbus.exceptions import ModbusException +from pymodbus.framer import FramerType +from pymodbus.logging import pymodbus_apply_logging_config +from pymodbus.pdu import ExceptionResponse + + +__version__ = "3.7.4" +__version_full__ = f"[pymodbus, version {__version__}]" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/__init__.py new file mode 100644 index 00000000..245bcdf0 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/__init__.py @@ -0,0 +1,19 @@ +"""Client.""" + +__all__ = [ + "AsyncModbusSerialClient", + "AsyncModbusTcpClient", + "AsyncModbusTlsClient", + "AsyncModbusUdpClient", + "ModbusBaseClient", + "ModbusSerialClient", + "ModbusTcpClient", + "ModbusTlsClient", + "ModbusUdpClient", +] + +from pymodbus.client.base import ModbusBaseClient +from pymodbus.client.serial import AsyncModbusSerialClient, ModbusSerialClient +from pymodbus.client.tcp import AsyncModbusTcpClient, ModbusTcpClient +from pymodbus.client.tls import AsyncModbusTlsClient, ModbusTlsClient +from pymodbus.client.udp import AsyncModbusUdpClient, ModbusUdpClient diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/base.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/base.py new file mode 100644 index 00000000..884d8f01 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/base.py @@ -0,0 +1,308 @@ +"""Base for all clients.""" +from __future__ import annotations + +import asyncio +import socket +from abc import abstractmethod +from collections.abc import Awaitable, Callable + +from pymodbus.client.mixin import ModbusClientMixin +from pymodbus.client.modbusclientprotocol import ModbusClientProtocol +from pymodbus.exceptions import ConnectionException, ModbusIOException +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerBase, FramerType +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU, ExceptionResponse, ModbusPDU +from pymodbus.transaction import SyncModbusTransactionManager +from pymodbus.transport import CommParams +from pymodbus.utilities import ModbusTransactionState + + +class ModbusBaseClient(ModbusClientMixin[Awaitable[ModbusPDU]]): + """**ModbusBaseClient**. + + :mod:`ModbusBaseClient` is normally not referenced outside :mod:`pymodbus`. + """ + + def __init__( + self, + framer: FramerType, + retries: int, + on_connect_callback: Callable[[bool], None] | None, + comm_params: CommParams | None = None, + ) -> None: + """Initialize a client instance. + + :meta private: + """ + ModbusClientMixin.__init__(self) # type: ignore[arg-type] + if comm_params: + self.comm_params = comm_params + self.retries = retries + self.ctx = ModbusClientProtocol( + framer, + self.comm_params, + on_connect_callback, + ) + + # Common variables. + self.use_udp = False + self.state = ModbusTransactionState.IDLE + self.last_frame_end: float | None = 0 + self.silent_interval: float = 0 + self._lock = asyncio.Lock() + self.accept_no_response_limit = 3 + self.count_no_responses = 0 + + @property + def connected(self) -> bool: + """Return state of connection.""" + return self.ctx.is_active() + + async def connect(self) -> bool: + """Call transport connect.""" + self.ctx.reset_delay() + Log.debug( + "Connecting to {}:{}.", + self.ctx.comm_params.host, + self.ctx.comm_params.port, + ) + return await self.ctx.connect() + + def register(self, custom_response_class: type[ModbusPDU]) -> None: + """Register a custom response class with the decoder (call **sync**). + + :param custom_response_class: (optional) Modbus response class. + :raises MessageRegisterException: Check exception text. + + Use register() to add non-standard responses (like e.g. a login prompt) and + have them interpreted automatically. + """ + self.ctx.framer.decoder.register(custom_response_class) + + def close(self) -> None: + """Close connection.""" + self.ctx.close() + + def execute(self, no_response_expected: bool, request: ModbusPDU): + """Execute request and get response (call **sync/async**). + + :meta private: + """ + if not self.ctx.transport: + raise ConnectionException(f"Not connected[{self!s}]") + return self.async_execute(no_response_expected, request) + + async def async_execute(self, no_response_expected: bool, request) -> ModbusPDU | None: + """Execute requests asynchronously. + + :meta private: + """ + request.transaction_id = self.ctx.transaction.getNextTID() + packet = self.ctx.framer.buildFrame(request) + + count = 0 + while count <= self.retries: + async with self._lock: + req = self.build_response(request) + self.ctx.send(packet) + if no_response_expected: + resp = None + break + try: + resp = await asyncio.wait_for( + req, timeout=self.ctx.comm_params.timeout_connect + ) + break + except asyncio.exceptions.TimeoutError: + count += 1 + if count > self.retries: + if self.count_no_responses >= self.accept_no_response_limit: + self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) + raise ModbusIOException( + f"ERROR: No response received of the last {self.accept_no_response_limit} request, CLOSING CONNECTION." + ) + self.count_no_responses += 1 + Log.error(f"No response received after {self.retries} retries, continue with next request") + return ExceptionResponse(request.function_code) + + self.count_no_responses = 0 + return resp + + def build_response(self, request: ModbusPDU): + """Return a deferred response for the current request. + + :meta private: + """ + my_future: asyncio.Future = asyncio.Future() + request.fut = my_future + if not self.ctx.transport: + if not my_future.done(): + my_future.set_exception(ConnectionException("Client is not connected")) + else: + self.ctx.transaction.addTransaction(request) + return my_future + + async def __aenter__(self): + """Implement the client with enter block. + + :returns: The current instance of the client + :raises ConnectionException: + """ + await self.connect() + return self + + async def __aexit__(self, klass, value, traceback): + """Implement the client with aexit block.""" + self.close() + + def __str__(self): + """Build a string representation of the connection. + + :returns: The string representation + """ + return ( + f"{self.__class__.__name__} {self.ctx.comm_params.host}:{self.ctx.comm_params.port}" + ) + + +class ModbusBaseSyncClient(ModbusClientMixin[ModbusPDU]): + """**ModbusBaseClient**. + + :mod:`ModbusBaseClient` is normally not referenced outside :mod:`pymodbus`. + """ + + def __init__( + self, + framer: FramerType, + retries: int, + comm_params: CommParams | None = None, + ) -> None: + """Initialize a client instance. + + :meta private: + """ + ModbusClientMixin.__init__(self) # type: ignore[arg-type] + if comm_params: + self.comm_params = comm_params + self.retries = retries + self.slaves: list[int] = [] + + # Common variables. + self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(DecodePDU(False)) + self.transaction = SyncModbusTransactionManager( + self, + self.retries, + ) + self.reconnect_delay_current = self.comm_params.reconnect_delay or 0 + self.use_udp = False + self.state = ModbusTransactionState.IDLE + self.last_frame_end: float | None = 0 + self.silent_interval: float = 0 + self.transport = None + + # ----------------------------------------------------------------------- # + # Client external interface + # ----------------------------------------------------------------------- # + def register(self, custom_response_class: type[ModbusPDU]) -> None: + """Register a custom response class with the decoder. + + :param custom_response_class: (optional) Modbus response class. + :raises MessageRegisterException: Check exception text. + + Use register() to add non-standard responses (like e.g. a login prompt) and + have them interpreted automatically. + """ + self.framer.decoder.register(custom_response_class) + + def idle_time(self) -> float: + """Time before initiating next transaction (call **sync**). + + Applications can call message functions without checking idle_time(), + this is done automatically. + """ + if self.last_frame_end is None or self.silent_interval is None: + return 0 + return self.last_frame_end + self.silent_interval + + def execute(self, no_response_expected: bool, request: ModbusPDU) -> ModbusPDU: + """Execute request and get response (call **sync/async**). + + :param no_response_expected: The client will not expect a response to the request + :param request: The request to process + :returns: The result of the request execution + :raises ConnectionException: Check exception text. + + :meta private: + """ + if not self.connect(): + raise ConnectionException(f"Failed to connect[{self!s}]") + return self.transaction.execute(no_response_expected, request) + + # ----------------------------------------------------------------------- # + # Internal methods + # ----------------------------------------------------------------------- # + def _start_send(self): + """Send request. + + :meta private: + """ + if self.state != ModbusTransactionState.RETRYING: + Log.debug('New Transaction state "SENDING"') + self.state = ModbusTransactionState.SENDING + + @abstractmethod + def send(self, request: bytes) -> int: + """Send request. + + :meta private: + """ + + @abstractmethod + def recv(self, size: int | None) -> bytes: + """Receive data. + + :meta private: + """ + + @classmethod + def get_address_family(cls, address): + """Get the correct address family. + + :meta private: + """ + try: + _ = socket.inet_pton(socket.AF_INET6, address) + except OSError: # not a valid ipv6 address + return socket.AF_INET + return socket.AF_INET6 + + def connect(self) -> bool: # type: ignore[empty-body] + """Connect to other end, overwritten.""" + + def close(self): + """Close connection, overwritten.""" + + # ----------------------------------------------------------------------- # + # The magic methods + # ----------------------------------------------------------------------- # + def __enter__(self): + """Implement the client with enter block. + + :returns: The current instance of the client + :raises ConnectionException: + """ + self.connect() + return self + + def __exit__(self, klass, value, traceback): + """Implement the client with exit block.""" + self.close() + + def __str__(self): + """Build a string representation of the connection. + + :returns: The string representation + """ + return ( + f"{self.__class__.__name__} {self.comm_params.host}:{self.comm_params.port}" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/mixin.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/mixin.py new file mode 100644 index 00000000..b31e1ef8 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/mixin.py @@ -0,0 +1,525 @@ +"""Modbus Client Common.""" +from __future__ import annotations + +import struct +from enum import Enum +from typing import Generic, TypeVar + +import pymodbus.pdu.bit_read_message as pdu_bit_read +import pymodbus.pdu.bit_write_message as pdu_bit_write +import pymodbus.pdu.diag_message as pdu_diag +import pymodbus.pdu.file_message as pdu_file_msg +import pymodbus.pdu.mei_message as pdu_mei +import pymodbus.pdu.other_message as pdu_other_msg +import pymodbus.pdu.register_read_message as pdu_reg_read +import pymodbus.pdu.register_write_message as pdu_req_write +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ModbusPDU + + +T = TypeVar("T", covariant=False) + + +class ModbusClientMixin(Generic[T]): # pylint: disable=too-many-public-methods + """**ModbusClientMixin**. + + This is an interface class to facilitate the sending requests/receiving responses like read_coils. + execute() allows to make a call with non-standard or user defined function codes (remember to add a PDU + in the transport class to interpret the request/response). + + Simple modbus message call:: + + response = client.read_coils(1, 10) + # or + response = await client.read_coils(1, 10) + + Advanced modbus message call:: + + request = ReadCoilsRequest(1,10) + response = client.execute(request) + # or + request = ReadCoilsRequest(1,10) + response = await client.execute(request) + + .. tip:: + All methods can be used directly (synchronous) or + with await <method> (asynchronous) depending on the client used. + """ + + def __init__(self): + """Initialize.""" + + def execute(self, _no_response_expected: bool, _request: ModbusPDU,) -> T: + """Execute request (code ???). + + :raises ModbusException: + + Call with custom function codes. + + .. tip:: + Response is not interpreted. + """ + raise NotImplementedError("execute of ModbusClientMixin needs to be overridden") + + def read_coils(self, address: int, count: int = 1, slave: int = 1, no_response_expected: bool = False) -> T: + """Read coils (code 0x01). + + :param address: Start address to read from + :param count: (optional) Number of coils to read + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_bit_read.ReadCoilsRequest(address=address, count=count, slave=slave)) + + def read_discrete_inputs(self, + address: int, + count: int = 1, + slave: int = 1, + no_response_expected: bool = False) -> T: + """Read discrete inputs (code 0x02). + + :param address: Start address to read from + :param count: (optional) Number of coils to read + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_bit_read.ReadDiscreteInputsRequest(address=address, count=count, slave=slave, )) + + def read_holding_registers(self, + address: int, + count: int = 1, + slave: int = 1, + no_response_expected: bool = False) -> T: + """Read holding registers (code 0x03). + + :param address: Start address to read from + :param count: (optional) Number of coils to read + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_reg_read.ReadHoldingRegistersRequest(address=address, count=count, slave=slave)) + + def read_input_registers(self, + address: int, + count: int = 1, + slave: int = 1, + no_response_expected: bool = False) -> T: + """Read input registers (code 0x04). + + :param address: Start address to read from + :param count: (optional) Number of coils to read + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_reg_read.ReadInputRegistersRequest(address, count, slave=slave)) + + def write_coil(self, address: int, value: bool, slave: int = 1, no_response_expected: bool = False) -> T: + """Write single coil (code 0x05). + + :param address: Address to write to + :param value: Boolean to write + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_bit_write.WriteSingleCoilRequest(address, value, slave=slave)) + + def write_register(self, address: int, value: bytes | int, slave: int = 1, no_response_expected: bool = False) -> T: + """Write register (code 0x06). + + :param address: Address to write to + :param value: Value to write + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_req_write.WriteSingleRegisterRequest(address, value, slave=slave)) + + def read_exception_status(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Read Exception Status (code 0x07). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_other_msg.ReadExceptionStatusRequest(slave=slave)) + + def diag_query_data(self, msg: bytes, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose query data (code 0x08 sub 0x00). + + :param msg: Message to be returned + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnQueryDataRequest(msg, slave=slave)) + + def diag_restart_communication(self, toggle: bool, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose restart communication (code 0x08 sub 0x01). + + :param toggle: True if toggled. + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.RestartCommunicationsOptionRequest(toggle, slave=slave)) + + def diag_read_diagnostic_register(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read diagnostic register (code 0x08 sub 0x02). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnDiagnosticRegisterRequest(slave=slave)) + + def diag_change_ascii_input_delimeter(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose change ASCII input delimiter (code 0x08 sub 0x03). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ChangeAsciiInputDelimiterRequest(slave=slave)) + + def diag_force_listen_only(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose force listen only (code 0x08 sub 0x04). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ForceListenOnlyModeRequest(slave=slave)) + + def diag_clear_counters(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose clear counters (code 0x08 sub 0x0A). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ClearCountersRequest(slave=slave)) + + def diag_read_bus_message_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read bus message count (code 0x08 sub 0x0B). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnBusMessageCountRequest(slave=slave)) + + def diag_read_bus_comm_error_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Bus Communication Error Count (code 0x08 sub 0x0C). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnBusCommunicationErrorCountRequest(slave=slave)) + + def diag_read_bus_exception_error_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Bus Exception Error Count (code 0x08 sub 0x0D). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnBusExceptionErrorCountRequest(slave=slave)) + + def diag_read_slave_message_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Slave Message Count (code 0x08 sub 0x0E). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnSlaveMessageCountRequest(slave=slave)) + + def diag_read_slave_no_response_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Slave No Response Count (code 0x08 sub 0x0F). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnSlaveNoResponseCountRequest(slave=slave)) + + def diag_read_slave_nak_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Slave NAK Count (code 0x08 sub 0x10). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnSlaveNAKCountRequest(slave=slave)) + + def diag_read_slave_busy_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Slave Busy Count (code 0x08 sub 0x11). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnSlaveBusyCountRequest(slave=slave)) + + def diag_read_bus_char_overrun_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Bus Character Overrun Count (code 0x08 sub 0x12). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnSlaveBusCharacterOverrunCountRequest(slave=slave)) + + def diag_read_iop_overrun_count(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose read Iop overrun count (code 0x08 sub 0x13). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ReturnIopOverrunCountRequest(slave=slave)) + + def diag_clear_overrun_counter(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose Clear Overrun Counter and Flag (code 0x08 sub 0x14). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.ClearOverrunCountRequest(slave=slave)) + + def diag_getclear_modbus_response(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose Get/Clear modbus plus (code 0x08 sub 0x15). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_diag.GetClearModbusPlusRequest(slave=slave)) + + def diag_get_comm_event_counter(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose get event counter (code 0x0B). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_other_msg.GetCommEventCounterRequest(slave=slave)) + + def diag_get_comm_event_log(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Diagnose get event counter (code 0x0C). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_other_msg.GetCommEventLogRequest(slave=slave)) + + def write_coils( + self, + address: int, + values: list[bool] | bool, + slave: int = 1, + no_response_expected: bool = False + ) -> T: + """Write coils (code 0x0F). + + :param address: Start address to write to + :param values: List of booleans to write, or a single boolean to write + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_bit_write.WriteMultipleCoilsRequest(address, values=values, slave=slave)) + + def write_registers( + self, + address: int, + values: list[bytes | int], + slave: int = 1, + skip_encode: bool = False, + no_response_expected: bool = False + ) -> T: + """Write registers (code 0x10). + + :param address: Start address to write to + :param values: List of values to write + :param slave: (optional) Modbus slave ID + :param skip_encode: (optional) do not encode values + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_req_write.WriteMultipleRegistersRequest(address, values,slave=slave,skip_encode=skip_encode)) + + def report_slave_id(self, slave: int = 1, no_response_expected: bool = False) -> T: + """Report slave ID (code 0x11). + + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_other_msg.ReportSlaveIdRequest(slave=slave)) + + def read_file_record(self, records: list[tuple], slave: int = 1, no_response_expected: bool = False) -> T: + """Read file record (code 0x14). + + :param records: List of (Reference type, File number, Record Number, Record Length) + :param slave: device id + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_file_msg.ReadFileRecordRequest(records, slave=slave)) + + def write_file_record(self, records: list[tuple], slave: int = 1, no_response_expected: bool = False) -> T: + """Write file record (code 0x15). + + :param records: List of (Reference type, File number, Record Number, Record Length) + :param slave: (optional) Device id + :param no_response_expected: (optional) The client will not expect a response to the request + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_file_msg.WriteFileRecordRequest(records,slave=slave)) + + def mask_write_register( + self, + address: int = 0x0000, + and_mask: int = 0xFFFF, + or_mask: int = 0x0000, + slave: int = 1, + no_response_expected: bool = False + ) -> T: + """Mask write register (code 0x16). + + :param address: The mask pointer address (0x0000 to 0xffff) + :param and_mask: The and bitmask to apply to the register address + :param or_mask: The or bitmask to apply to the register address + :param slave: (optional) device id + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_req_write.MaskWriteRegisterRequest(address, and_mask, or_mask, slave=slave)) + + def readwrite_registers( + self, + read_address: int = 0, + read_count: int = 0, + write_address: int = 0, + address: int | None = None, + values: list[int] | int = 0, + slave: int = 1, + no_response_expected: bool = False + ) -> T: + """Read/Write registers (code 0x17). + + :param read_address: The address to start reading from + :param read_count: The number of registers to read from address + :param write_address: The address to start writing to + :param address: (optional) use as read/write address + :param values: List of values to write, or a single value to write + :param slave: (optional) Modbus slave ID + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + if address: + read_address = address + write_address = address + return self.execute(no_response_expected, pdu_reg_read.ReadWriteMultipleRegistersRequest( read_address=read_address, read_count=read_count, write_address=write_address, write_registers=values,slave=slave)) + + def read_fifo_queue(self, address: int = 0x0000, slave: int = 1, no_response_expected: bool = False) -> T: + """Read FIFO queue (code 0x18). + + :param address: The address to start reading from + :param slave: (optional) device id + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_file_msg.ReadFifoQueueRequest(address, slave=slave)) + + # code 0x2B sub 0x0D: CANopen General Reference Request and Response, NOT IMPLEMENTED + + def read_device_information(self, read_code: int | None = None, + object_id: int = 0x00, + slave: int = 1, + no_response_expected: bool = False) -> T: + """Read FIFO queue (code 0x2B sub 0x0E). + + :param read_code: The device information read code + :param object_id: The object to read from + :param slave: (optional) Device id + :param no_response_expected: (optional) The client will not expect a response to the request + :raises ModbusException: + """ + return self.execute(no_response_expected, pdu_mei.ReadDeviceInformationRequest(read_code, object_id, slave=slave)) + + # ------------------ + # Converter methods + # ------------------ + + class DATATYPE(Enum): + """Datatype enum (name and number of bytes), used for convert_* calls.""" + + INT16 = ("h", 1) + UINT16 = ("H", 1) + INT32 = ("i", 2) + UINT32 = ("I", 2) + INT64 = ("q", 4) + UINT64 = ("Q", 4) + FLOAT32 = ("f", 2) + FLOAT64 = ("d", 4) + STRING = ("s", 0) + + @classmethod + def convert_from_registers( + cls, registers: list[int], data_type: DATATYPE + ) -> int | float | str: + """Convert registers to int/float/str. + + :param registers: list of registers received from e.g. read_holding_registers() + :param data_type: data type to convert to + :returns: int, float or str depending on "data_type" + :raises ModbusException: when size of registers is not 1, 2 or 4 + """ + byte_list = bytearray() + for x in registers: + byte_list.extend(int.to_bytes(x, 2, "big")) + if data_type == cls.DATATYPE.STRING: + if byte_list[-1:] == b"\00": + byte_list = byte_list[:-1] + return byte_list.decode("utf-8") + if len(registers) != data_type.value[1]: + raise ModbusException( + f"Illegal size ({len(registers)}) of register array, cannot convert!" + ) + return struct.unpack(f">{data_type.value[0]}", byte_list)[0] + + @classmethod + def convert_to_registers( + cls, value: int | float | str, data_type: DATATYPE + ) -> list[int]: + """Convert int/float/str to registers (16/32/64 bit). + + :param value: value to be converted + :param data_type: data type to be encoded as registers + :returns: List of registers, can be used directly in e.g. write_registers() + :raises TypeError: when there is a mismatch between data_type and value + """ + if data_type == cls.DATATYPE.STRING: + if not isinstance(value, str): + raise TypeError(f"Value should be string but is {type(value)}.") + byte_list = value.encode() + if len(byte_list) % 2: + byte_list += b"\x00" + else: + byte_list = struct.pack(f">{data_type.value[0]}", value) + regs = [ + int.from_bytes(byte_list[x : x + 2], "big") + for x in range(0, len(byte_list), 2) + ] + return regs diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/modbusclientprotocol.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/modbusclientprotocol.py new file mode 100644 index 00000000..49c788b3 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/modbusclientprotocol.py @@ -0,0 +1,83 @@ +"""ModbusProtocol implementation for all clients.""" +from __future__ import annotations + +from collections.abc import Callable + +from pymodbus.framer import ( + FRAMER_NAME_TO_CLASS, + FramerBase, + FramerType, +) +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU +from pymodbus.transaction import ModbusTransactionManager +from pymodbus.transport import CommParams, ModbusProtocol + + +class ModbusClientProtocol(ModbusProtocol): + """**ModbusClientProtocol**. + + :mod:`ModbusClientProtocol` is normally not referenced outside :mod:`pymodbus`. + """ + + def __init__( + self, + framer: FramerType, + params: CommParams, + on_connect_callback: Callable[[bool], None] | None = None, + ) -> None: + """Initialize a client instance.""" + ModbusProtocol.__init__( + self, + params, + False, + ) + self.on_connect_callback = on_connect_callback + + # Common variables. + self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(DecodePDU(False)) + self.transaction = ModbusTransactionManager() + + def _handle_response(self, reply): + """Handle the processed response and link to correct deferred.""" + if reply is not None: + tid = reply.transaction_id + if handler := self.transaction.getTransaction(tid): + reply.request = handler + if not handler.fut.done(): + handler.fut.set_result(reply) + else: + Log.debug("Unrequested message: {}", reply, ":str") + + def callback_new_connection(self): + """Call when listener receive new connection request.""" + + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + if self.on_connect_callback: + self.loop.call_soon(self.on_connect_callback, True) + + def callback_disconnected(self, exc: Exception | None) -> None: + """Call when connection is lost.""" + Log.debug("callback_disconnected called: {}", exc) + if self.on_connect_callback: + self.loop.call_soon(self.on_connect_callback, False) + + def callback_data(self, data: bytes, addr: tuple | None = None) -> int: + """Handle received data. + + returns number of bytes consumed + """ + used_len, pdu = self.framer.processIncomingFrame(data) + if pdu: + self._handle_response(pdu) + return used_len + + def __str__(self): + """Build a string representation of the connection. + + :returns: The string representation + """ + return ( + f"{self.__class__.__name__} {self.comm_params.host}:{self.comm_params.port}" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/serial.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/serial.py new file mode 100644 index 00000000..a9450262 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/serial.py @@ -0,0 +1,348 @@ +"""Modbus client async serial communication.""" +from __future__ import annotations + +import contextlib +import sys +import time +from collections.abc import Callable +from functools import partial + +from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient +from pymodbus.exceptions import ConnectionException +from pymodbus.framer import FramerType +from pymodbus.logging import Log +from pymodbus.transport import CommParams, CommType +from pymodbus.utilities import ModbusTransactionState + + +with contextlib.suppress(ImportError): + import serial + + +class AsyncModbusSerialClient(ModbusBaseClient): + """**AsyncModbusSerialClient**. + + Fixed parameters: + + :param port: Serial port used for communication. + + Optional parameters: + + :param framer: Framer name, default FramerType.RTU + :param baudrate: Bits per second. + :param bytesize: Number of bits per byte 7-8. + :param parity: 'E'ven, 'O'dd or 'N'one + :param stopbits: Number of stop bits 1, 1.5, 2. + :param handle_local_echo: Discard local echo from dongle. + :param name: Set communication name, used in logging + :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + :param on_connect_callback: Function that will be called just before a connection attempt. + + .. tip:: + **reconnect_delay** doubles automatically with each unsuccessful connect, from + **reconnect_delay** to **reconnect_delay_max**. + Set `reconnect_delay=0` to avoid automatic reconnection. + + Example:: + + from pymodbus.client import AsyncModbusSerialClient + + async def run(): + client = AsyncModbusSerialClient("dev/serial0") + + await client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + port: str, + framer: FramerType = FramerType.RTU, + baudrate: int = 19200, + bytesize: int = 8, + parity: str = "N", + stopbits: int = 1, + handle_local_echo: bool = False, + name: str = "comm", + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + on_connect_callback: Callable[[bool], None] | None = None, + ) -> None: + """Initialize Asyncio Modbus Serial Client.""" + if "serial" not in sys.modules: + raise RuntimeError( + "Serial client requires pyserial " + 'Please install with "pip install pyserial" and try again.' + ) + self.comm_params = CommParams( + comm_type=CommType.SERIAL, + host=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, + stopbits=stopbits, + handle_local_echo=handle_local_echo, + comm_name=name, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + ModbusBaseClient.__init__( + self, + framer, + retries, + on_connect_callback, + ) + + +class ModbusSerialClient(ModbusBaseSyncClient): + """**ModbusSerialClient**. + + Fixed parameters: + + :param port: Serial port used for communication. + + Optional parameters: + + :param framer: Framer name, default FramerType.RTU + :param baudrate: Bits per second. + :param bytesize: Number of bits per byte 7-8. + :param parity: 'E'ven, 'O'dd or 'N'one + :param stopbits: Number of stop bits 0-2. + :param handle_local_echo: Discard local echo from dongle. + :param name: Set communication name, used in logging + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + + Note that unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. + + Example:: + + from pymodbus.client import ModbusSerialClient + + def run(): + client = ModbusSerialClient("dev/serial0") + + client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + state = ModbusTransactionState.IDLE + inter_byte_timeout: float = 0 + silent_interval: float = 0 + + def __init__( # pylint: disable=too-many-arguments + self, + port: str, + framer: FramerType = FramerType.RTU, + baudrate: int = 19200, + bytesize: int = 8, + parity: str = "N", + stopbits: int = 1, + handle_local_echo: bool = False, + name: str = "comm", + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + ) -> None: + """Initialize Modbus Serial Client.""" + self.comm_params = CommParams( + comm_type=CommType.SERIAL, + host=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, + stopbits=stopbits, + handle_local_echo=handle_local_echo, + comm_name=name, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + super().__init__( + framer, + retries, + ) + if "serial" not in sys.modules: + raise RuntimeError( + "Serial client requires pyserial " + 'Please install with "pip install pyserial" and try again.' + ) + self.socket: serial.Serial | None = None + self.last_frame_end = None + self._t0 = float(1 + bytesize + stopbits) / baudrate + + # Check every 4 bytes / 2 registers if the reading is ready + self._recv_interval = self._t0 * 4 + # Set a minimum of 1ms for high baudrates + self._recv_interval = max(self._recv_interval, 0.001) + + if baudrate > 19200: + self.silent_interval = 1.75 / 1000 # ms + else: + self.inter_byte_timeout = 1.5 * self._t0 + self.silent_interval = 3.5 * self._t0 + self.silent_interval = round(self.silent_interval, 6) + + @property + def connected(self) -> bool: + """Check if socket exists.""" + return self.socket is not None + + def connect(self) -> bool: + """Connect to the modbus serial server.""" + if self.socket: + return True + try: + self.socket = serial.serial_for_url( + self.comm_params.host, + timeout=self.comm_params.timeout_connect, + bytesize=self.comm_params.bytesize, + stopbits=self.comm_params.stopbits, + baudrate=self.comm_params.baudrate, + parity=self.comm_params.parity, + exclusive=True, + ) + self.socket.inter_byte_timeout = self.inter_byte_timeout + self.last_frame_end = None + # except serial.SerialException as msg: + # pyserial raises undocumented exceptions like termios + except Exception as msg: # pylint: disable=broad-exception-caught + Log.error("{}", msg) + self.close() + return self.socket is not None + + def close(self): + """Close the underlying socket connection.""" + if self.socket: + self.socket.close() + self.socket = None + + def _in_waiting(self): + """Return waiting bytes.""" + return getattr(self.socket, "in_waiting") if hasattr(self.socket, "in_waiting") else getattr(self.socket, "inWaiting")() + + def _send(self, request: bytes) -> int: + """Send data on the underlying socket. + + If receive buffer still holds some data then flush it. + + Sleep if last send finished less than 3.5 character times ago. + """ + super()._start_send() + if not self.socket: + raise ConnectionException(str(self)) + if request: + if waitingbytes := self._in_waiting(): + result = self.socket.read(waitingbytes) + Log.warning("Cleanup recv buffer before send: {}", result, ":hex") + if (size := self.socket.write(request)) is None: + size = 0 + return size + return 0 + + def send(self, request: bytes) -> int: + """Send data on the underlying socket.""" + start = time.time() + if hasattr(self,"ctx"): + timeout = start + self.ctx.comm_params.timeout_connect + else: + timeout = start + self.comm_params.timeout_connect + while self.state != ModbusTransactionState.IDLE: + if self.state == ModbusTransactionState.TRANSACTION_COMPLETE: + timestamp = round(time.time(), 6) + Log.debug( + "Changing state to IDLE - Last Frame End - {} Current Time stamp - {}", + self.last_frame_end, + timestamp, + ) + if self.last_frame_end: + idle_time = self.idle_time() + if round(timestamp - idle_time, 6) <= self.silent_interval: + Log.debug( + "Waiting for 3.5 char before next send - {} ms", + self.silent_interval * 1000, + ) + time.sleep(self.silent_interval) + else: + # Recovering from last error ?? + time.sleep(self.silent_interval) + self.state = ModbusTransactionState.IDLE + elif self.state == ModbusTransactionState.RETRYING: + # Simple lets settle down!!! + # To check for higher baudrates + time.sleep(self.comm_params.timeout_connect) + break + elif time.time() > timeout: + Log.debug( + "Spent more time than the read time out, " + "resetting the transaction to IDLE" + ) + self.state = ModbusTransactionState.IDLE + else: + Log.debug("Sleeping") + time.sleep(self.silent_interval) + size = self._send(request) + self.last_frame_end = round(time.time(), 6) + return size + + def _wait_for_data(self) -> int: + """Wait for data.""" + size = 0 + more_data = False + condition = partial( + lambda start, timeout: (time.time() - start) <= timeout, + timeout=self.comm_params.timeout_connect, + ) + start = time.time() + while condition(start): + available = self._in_waiting() + if (more_data and not available) or (more_data and available == size): + break + if available and available != size: + more_data = True + size = available + time.sleep(self._recv_interval) + return size + + def recv(self, size: int | None) -> bytes: + """Read data from the underlying descriptor.""" + if not self.socket: + raise ConnectionException(str(self)) + if size is None: + size = self._wait_for_data() + if size > self._in_waiting(): + self._wait_for_data() + result = self.socket.read(size) + self.last_frame_end = round(time.time(), 6) + return result + + def is_socket_open(self) -> bool: + """Check if socket is open.""" + if self.socket: + return self.socket.is_open + return False + + def __repr__(self): + """Return string representation.""" + return ( + f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, " + f"framer={self.framer}, timeout={self.comm_params.timeout_connect}>" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tcp.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tcp.py new file mode 100644 index 00000000..0983f2c1 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tcp.py @@ -0,0 +1,290 @@ +"""Modbus client async TCP communication.""" +from __future__ import annotations + +import select +import socket +import time +from collections.abc import Callable + +from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient +from pymodbus.exceptions import ConnectionException +from pymodbus.framer import FramerType +from pymodbus.logging import Log +from pymodbus.transport import CommParams, CommType + + +class AsyncModbusTcpClient(ModbusBaseClient): + """**AsyncModbusTcpClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param framer: Framer name, default FramerType.SOCKET + :param port: Port used for communication + :param name: Set communication name, used in logging + :param source_address: source address of client + :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + :param on_connect_callback: Function that will be called just before a connection attempt. + + .. tip:: + **reconnect_delay** doubles automatically with each unsuccessful connect, from + **reconnect_delay** to **reconnect_delay_max**. + Set `reconnect_delay=0` to avoid automatic reconnection. + + Example:: + + from pymodbus.client import AsyncModbusTcpClient + + async def run(): + client = AsyncModbusTcpClient("localhost") + + await client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + host: str, + framer: FramerType = FramerType.SOCKET, + port: int = 502, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + on_connect_callback: Callable[[bool], None] | None = None, + ) -> None: + """Initialize Asyncio Modbus TCP Client.""" + if not hasattr(self,"comm_params"): + self.comm_params = CommParams( + comm_type=CommType.TCP, + host=host, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + ModbusBaseClient.__init__( + self, + framer, + retries, + on_connect_callback, + ) + + +class ModbusTcpClient(ModbusBaseSyncClient): + """**ModbusTcpClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param framer: Framer name, default FramerType.SOCKET + :param port: Port used for communication + :param name: Set communication name, used in logging + :param source_address: source address of client + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + + .. tip:: + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. + + Example:: + + from pymodbus.client import ModbusTcpClient + + async def run(): + client = ModbusTcpClient("localhost") + + client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + socket: socket.socket | None + + def __init__( + self, + host: str, + framer: FramerType = FramerType.SOCKET, + port: int = 502, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + ) -> None: + """Initialize Modbus TCP Client.""" + if not hasattr(self,"comm_params"): + self.comm_params = CommParams( + comm_type=CommType.TCP, + host=host, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + super().__init__(framer, retries) + self.socket = None + + @property + def connected(self) -> bool: + """Check if socket exists.""" + return self.socket is not None + + def connect(self): + """Connect to the modbus tcp server.""" + if self.socket: + return True + try: + self.socket = socket.create_connection( + (self.comm_params.host, self.comm_params.port), + timeout=self.comm_params.timeout_connect, + source_address=self.comm_params.source_address, + ) + Log.debug( + "Connection to Modbus server established. Socket {}", + self.socket.getsockname(), + ) + except OSError as msg: + Log.error( + "Connection to ({}, {}) failed: {}", + self.comm_params.host, + self.comm_params.port, + msg, + ) + self.close() + return self.socket is not None + + def close(self): + """Close the underlying socket connection.""" + if self.socket: + self.socket.close() + self.socket = None + + def send(self, request): + """Send data on the underlying socket.""" + super()._start_send() + if not self.socket: + raise ConnectionException(str(self)) + if request: + return self.socket.send(request) + return 0 + + def recv(self, size: int | None) -> bytes: + """Read data from the underlying descriptor.""" + if not self.socket: + raise ConnectionException(str(self)) + + # socket.recv(size) waits until it gets some data from the host but + # not necessarily the entire response that can be fragmented in + # many packets. + # To avoid split responses to be recognized as invalid + # messages and to be discarded, loops socket.recv until full data + # is received or timeout is expired. + # If timeout expires returns the read data, also if its length is + # less than the expected size. + self.socket.setblocking(False) + + timeout = self.comm_params.timeout_connect or 0 + + # If size isn't specified read up to 4096 bytes at a time. + if size is None: + recv_size = 4096 + else: + recv_size = size + + data: list[bytes] = [] + data_length = 0 + time_ = time.time() + end = time_ + timeout + while recv_size > 0: + try: + ready = select.select([self.socket], [], [], end - time_) + except ValueError: + return self._handle_abrupt_socket_close(size, data, time.time() - time_) + if ready[0]: + if (recv_data := self.socket.recv(recv_size)) == b"": + return self._handle_abrupt_socket_close( + size, data, time.time() - time_ + ) + data.append(recv_data) + data_length += len(recv_data) + time_ = time.time() + + # If size isn't specified continue to read until timeout expires. + if size: + recv_size = size - data_length + + # Timeout is reduced also if some data has been received in order + # to avoid infinite loops when there isn't an expected response + # size and the slave sends noisy data continuously. + if time_ > end: + break + self.last_frame_end = round(time.time(), 6) + return b"".join(data) + + def _handle_abrupt_socket_close(self, size: int | None, data: list[bytes], duration: float) -> bytes: + """Handle unexpected socket close by remote end. + + Intended to be invoked after determining that the remote end + has unexpectedly closed the connection, to clean up and handle + the situation appropriately. + + :param size: The number of bytes that was attempted to read + :param data: The actual data returned + :param duration: Duration from the read was first attempted + until it was determined that the remote closed the + socket + :return: The more than zero bytes read from the remote end + :raises ConnectionException: If the remote end didn't send any + data at all before closing the connection. + """ + self.close() + size_txt = size if size else "unbounded read" + readsize = f"read of {size_txt} bytes" + msg = ( + f"{self}: Connection unexpectedly closed " + f"{duration:.3f} seconds into {readsize}" + ) + if data: + result = b"".join(data) + Log.warning(" after returning {} bytes: {} ", len(result), result) + return result + msg += " without response from slave before it closed connection" + raise ConnectionException(msg) + + def is_socket_open(self) -> bool: + """Check if socket is open.""" + return self.socket is not None + + def __repr__(self): + """Return string representation.""" + return ( + f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, " + f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, timeout={self.comm_params.timeout_connect}>" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tls.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tls.py new file mode 100644 index 00000000..8b368d47 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/tls.py @@ -0,0 +1,231 @@ +"""Modbus client async TLS communication.""" +from __future__ import annotations + +import socket +import ssl +from collections.abc import Callable + +from pymodbus.client.tcp import AsyncModbusTcpClient, ModbusTcpClient +from pymodbus.framer import FramerType +from pymodbus.logging import Log +from pymodbus.transport import CommParams, CommType + + +class AsyncModbusTlsClient(AsyncModbusTcpClient): + """**AsyncModbusTlsClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param sslctx: SSLContext to use for TLS + :param framer: Framer name, default FramerType.TLS + :param port: Port used for communication + :param name: Set communication name, used in logging + :param source_address: Source address of client + :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + :param on_connect_callback: Function that will be called just before a connection attempt. + + .. tip:: + **reconnect_delay** doubles automatically with each unsuccessful connect, from + **reconnect_delay** to **reconnect_delay_max**. + Set `reconnect_delay=0` to avoid automatic reconnection. + + Example:: + + from pymodbus.client import AsyncModbusTlsClient + + async def run(): + client = AsyncModbusTlsClient("localhost") + + await client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + host: str, + sslctx: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + framer: FramerType = FramerType.TLS, + port: int = 802, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + on_connect_callback: Callable[[bool], None] | None = None, + ): + """Initialize Asyncio Modbus TLS Client.""" + self.comm_params = CommParams( + comm_type=CommType.TLS, + host=host, + sslctx=sslctx, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + AsyncModbusTcpClient.__init__( + self, + "", + framer=framer, + retries=retries, + on_connect_callback=on_connect_callback, + ) + + @classmethod + def generate_ssl( + cls, + certfile: str | None = None, + keyfile: str | None = None, + password: str | None = None, + ) -> ssl.SSLContext: + """Generate sslctx from cert/key/password. + + :param certfile: Cert file path for TLS server request + :param keyfile: Key file path for TLS server request + :param password: Password for for decrypting private key file + + Remark: + - MODBUS/TCP Security Protocol Specification demands TLSv2 at least + - verify_mode is set to ssl.NONE + """ + return CommParams.generate_ssl( + False, certfile=certfile, keyfile=keyfile, password=password + ) + +class ModbusTlsClient(ModbusTcpClient): + """**ModbusTlsClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param sslctx: SSLContext to use for TLS + :param framer: Framer name, default FramerType.TLS + :param port: Port used for communication + :param name: Set communication name, used in logging + :param source_address: Source address of client + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + + .. tip:: + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. + + Example:: + + from pymodbus.client import ModbusTlsClient + + async def run(): + client = ModbusTlsClient("localhost") + + client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + host: str, + sslctx: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT), + framer: FramerType = FramerType.TLS, + port: int = 802, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + ): + """Initialize Modbus TLS Client.""" + self.comm_params = CommParams( + comm_type=CommType.TLS, + host=host, + sslctx=sslctx, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + super().__init__( + "", + framer=framer, + retries=retries, + ) + + @classmethod + def generate_ssl( + cls, + certfile: str | None = None, + keyfile: str | None = None, + password: str | None = None, + ) -> ssl.SSLContext: + """Generate sslctx from cert/key/password. + + :param certfile: Cert file path for TLS server request + :param keyfile: Key file path for TLS server request + :param password: Password for for decrypting private key file + + Remark: + - MODBUS/TCP Security Protocol Specification demands TLSv2 at least + - verify_mode is set to ssl.NONE + """ + return CommParams.generate_ssl( + False, certfile=certfile, keyfile=keyfile, password=password, + ) + + @property + def connected(self) -> bool: + """Connect internal.""" + return self.transport is not None + + def connect(self): + """Connect to the modbus tls server.""" + if self.socket: + return True + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.comm_params.source_address: + sock.bind(self.comm_params.source_address) + self.socket = self.comm_params.sslctx.wrap_socket(sock, server_side=False) # type: ignore[union-attr] + self.socket.settimeout(self.comm_params.timeout_connect) + self.socket.connect((self.comm_params.host, self.comm_params.port)) + except OSError as msg: + Log.error( + "Connection to ({}, {}) failed: {}", + self.comm_params.host, + self.comm_params.port, + msg, + ) + self.close() + return self.socket is not None + + def __repr__(self): + """Return string representation.""" + return ( + f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, " + f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, sslctx={self.comm_params.sslctx}, " + f"timeout={self.comm_params.timeout_connect}>" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/udp.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/udp.py new file mode 100644 index 00000000..86c05895 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/client/udp.py @@ -0,0 +1,222 @@ +"""Modbus client async UDP communication.""" +from __future__ import annotations + +import socket +import time +from collections.abc import Callable + +from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient +from pymodbus.exceptions import ConnectionException +from pymodbus.framer import FramerType +from pymodbus.logging import Log +from pymodbus.transport import CommParams, CommType + + +DGRAM_TYPE = socket.SOCK_DGRAM + + +class AsyncModbusUdpClient(ModbusBaseClient): + """**AsyncModbusUdpClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param framer: Framer name, default FramerType.SOCKET + :param port: Port used for communication. + :param name: Set communication name, used in logging + :param source_address: source address of client, + :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + :param on_connect_callback: Function that will be called just before a connection attempt. + + .. tip:: + **reconnect_delay** doubles automatically with each unsuccessful connect, from + **reconnect_delay** to **reconnect_delay_max**. + Set `reconnect_delay=0` to avoid automatic reconnection. + + Example:: + + from pymodbus.client import AsyncModbusUdpClient + + async def run(): + client = AsyncModbusUdpClient("localhost") + + await client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + host: str, + framer: FramerType = FramerType.SOCKET, + port: int = 502, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + on_connect_callback: Callable[[bool], None] | None = None, + ) -> None: + """Initialize Asyncio Modbus UDP Client.""" + self.comm_params = CommParams( + comm_type=CommType.UDP, + host=host, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + ModbusBaseClient.__init__( + self, + framer, + retries, + on_connect_callback, + ) + self.source_address = source_address + + +class ModbusUdpClient(ModbusBaseSyncClient): + """**ModbusUdpClient**. + + Fixed parameters: + + :param host: Host IP address or host name + + Optional parameters: + + :param framer: Framer name, default FramerType.SOCKET + :param port: Port used for communication. + :param name: Set communication name, used in logging + :param source_address: source address of client, + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client + :param timeout: Timeout for connecting and receiving data, in seconds. + :param retries: Max number of retries per request. + + .. tip:: + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. + + Example:: + + from pymodbus.client import ModbusUdpClient + + async def run(): + client = ModbusUdpClient("localhost") + + client.connect() + ... + client.close() + + Please refer to :ref:`Pymodbus internals` for advanced usage. + """ + + socket: socket.socket | None + + def __init__( + self, + host: str, + framer: FramerType = FramerType.SOCKET, + port: int = 502, + name: str = "comm", + source_address: tuple[str, int] | None = None, + reconnect_delay: float = 0.1, + reconnect_delay_max: float = 300, + timeout: float = 3, + retries: int = 3, + ) -> None: + """Initialize Modbus UDP Client.""" + self.comm_params = CommParams( + comm_type=CommType.UDP, + host=host, + port=port, + comm_name=name, + source_address=source_address, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + timeout_connect=timeout, + ) + super().__init__(framer, retries) + self.socket = None + + @property + def connected(self) -> bool: + """Connect internal.""" + return self.socket is not None + + def connect(self): + """Connect to the modbus tcp server. + + :meta private: + """ + if self.socket: + return True + try: + family = ModbusUdpClient.get_address_family(self.comm_params.host) + self.socket = socket.socket(family, socket.SOCK_DGRAM) + self.socket.settimeout(self.comm_params.timeout_connect) + except OSError as exc: + Log.error("Unable to create udp socket {}", exc) + self.close() + return self.socket is not None + + def close(self): + """Close the underlying socket connection. + + :meta private: + """ + self.socket = None + + def send(self, request: bytes) -> int: + """Send data on the underlying socket. + + :meta private: + """ + super()._start_send() + if not self.socket: + raise ConnectionException(str(self)) + if request: + return self.socket.sendto( + request, (self.comm_params.host, self.comm_params.port) + ) + return 0 + + def recv(self, size: int | None) -> bytes: + """Read data from the underlying descriptor. + + :meta private: + """ + if not self.socket: + raise ConnectionException(str(self)) + if size is None: + size = 0 + data = self.socket.recvfrom(size)[0] + self.last_frame_end = round(time.time(), 6) + return data + + def is_socket_open(self): + """Check if socket is open. + + :meta private: + """ + return True + + def __repr__(self): + """Return string representation.""" + return ( + f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, " + f"ipaddr={self.comm_params.host}, port={self.comm_params.port}, timeout={self.comm_params.timeout_connect}>" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/constants.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/constants.py new file mode 100644 index 00000000..446defe9 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/constants.py @@ -0,0 +1,144 @@ +"""Constants For Modbus Server/Client. + +This is the single location for storing default +values for the servers and clients. +""" +import enum + + +INTERNAL_ERROR = "Pymodbus internal error" + + +class ModbusStatus(int, enum.Enum): + """These represent various status codes in the modbus protocol. + + .. attribute:: WAITING + + This indicates that a modbus device is currently + waiting for a given request to finish some running task. + + .. attribute:: READY + + This indicates that a modbus device is currently + free to perform the next request task. + + .. attribute:: ON + + This indicates that the given modbus entity is on + + .. attribute:: OFF + + This indicates that the given modbus entity is off + + .. attribute:: SLAVE_ON + + This indicates that the given modbus slave is running + + .. attribute:: SLAVE_OFF + + This indicates that the given modbus slave is not running + """ + + WAITING = 0xFFFF + READY = 0x0000 + ON = 0xFF00 + OFF = 0x0000 + SLAVE_ON = 0xFF + SLAVE_OFF = 0x00 + + +class Endian(str, enum.Enum): + """An enumeration representing the various byte endianness. + + .. attribute:: AUTO + + This indicates that the byte order is chosen by the + current native environment. + + .. attribute:: BIG + + This indicates that the bytes are in big endian format + + .. attribute:: LITTLE + + This indicates that the bytes are in little endian format + + .. note:: I am simply borrowing the format strings from the + python struct module for my convenience. + """ + + AUTO = "@" + BIG = ">" + LITTLE = "<" + + +class ModbusPlusOperation(int, enum.Enum): + """Represents the type of modbus plus request. + + .. attribute:: GET_STATISTICS + + Operation requesting that the current modbus plus statistics + be returned in the response. + + .. attribute:: CLEAR_STATISTICS + + Operation requesting that the current modbus plus statistics + be cleared and not returned in the response. + """ + + GET_STATISTICS = 0x0003 + CLEAR_STATISTICS = 0x0004 + + +class DeviceInformation(int, enum.Enum): + """Represents what type of device information to read. + + .. attribute:: BASIC + + This is the basic (required) device information to be returned. + This includes VendorName, ProductCode, and MajorMinorRevision + code. + + .. attribute:: REGULAR + + In addition to basic data objects, the device provides additional + and optional identification and description data objects. All of + the objects of this category are defined in the standard but their + implementation is optional. + + .. attribute:: EXTENDED + + In addition to regular data objects, the device provides additional + and optional identification and description private data about the + physical device itself. All of these data are device dependent. + + .. attribute:: SPECIFIC + + Request to return a single data object. + """ + + BASIC = 0x01 + REGULAR = 0x02 + EXTENDED = 0x03 + SPECIFIC = 0x04 + + def __str__(self): + """Override to force int representation for enum members.""" + return str(int(self)) + + +class MoreData(int, enum.Enum): + """Represents the more follows condition. + + .. attribute:: NOTHING + + This indicates that no more objects are going to be returned. + + .. attribute:: KEEP_READING + + This indicates that there are more objects to be returned. + """ + + NOTHING = 0x00 + KEEP_READING = 0xFF + diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/__init__.py new file mode 100644 index 00000000..e66600d7 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/__init__.py @@ -0,0 +1,21 @@ +"""Datastore.""" + +__all__ = [ + "ModbusBaseSlaveContext", + "ModbusSequentialDataBlock", + "ModbusSparseDataBlock", + "ModbusSlaveContext", + "ModbusServerContext", + "ModbusSimulatorContext", +] + +from pymodbus.datastore.context import ( + ModbusBaseSlaveContext, + ModbusServerContext, + ModbusSlaveContext, +) +from pymodbus.datastore.simulator import ModbusSimulatorContext +from pymodbus.datastore.store import ( + ModbusSequentialDataBlock, + ModbusSparseDataBlock, +) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/context.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/context.py new file mode 100644 index 00000000..ea76b7ff --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/context.py @@ -0,0 +1,250 @@ +"""Context for datastore.""" + +from __future__ import annotations + +# pylint: disable=missing-type-doc +from pymodbus.datastore.store import ModbusSequentialDataBlock +from pymodbus.exceptions import NoSuchSlaveException +from pymodbus.logging import Log + + +class ModbusBaseSlaveContext: + """Interface for a modbus slave data context. + + Derived classes must implemented the following methods: + reset(self) + validate(self, fx, address, count=1) + getValues/async_getValues(self, fc_as_hex, address, count=1) + setValues/async_setValues(self, fc_as_hex, address, values) + """ + + _fx_mapper = {2: "d", 4: "i"} + _fx_mapper.update([(i, "h") for i in (3, 6, 16, 22, 23)]) + _fx_mapper.update([(i, "c") for i in (1, 5, 15)]) + + def decode(self, fx): + """Convert the function code to the datastore to. + + :param fx: The function we are working with + :returns: one of [d(iscretes),i(nputs),h(olding),c(oils) + """ + return self._fx_mapper[fx] + + async def async_getValues(self, fc_as_hex: int, address: int, count: int = 1) -> list[int | bool | None]: + """Get `count` values from datastore. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + return self.getValues(fc_as_hex, address, count) + + async def async_setValues(self, fc_as_hex: int, address: int, values: list[int | bool]) -> None: + """Set the datastore with the supplied values. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param values: The new values to be set + """ + self.setValues(fc_as_hex, address, values) + + def getValues(self, fc_as_hex: int, address: int, count: int = 1) -> list[int | bool | None]: + """Get `count` values from datastore. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + Log.error("getValues({},{},{}) not implemented!", fc_as_hex, address, count) + return [] + + def setValues(self, fc_as_hex: int, address: int, values: list[int | bool]) -> None: + """Set the datastore with the supplied values. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param values: The new values to be set + """ + + +# ---------------------------------------------------------------------------# +# Slave Contexts +# ---------------------------------------------------------------------------# +class ModbusSlaveContext(ModbusBaseSlaveContext): + """Create a modbus data model with data stored in a block. + + :param di: discrete inputs initializer ModbusDataBlock + :param co: coils initializer ModbusDataBlock + :param hr: holding register initializer ModbusDataBlock + :param ir: input registers initializer ModbusDataBlock + :param zero_mode: Not add one to address + + When True, a request for address zero to n will map to + datastore address zero to n. + + When False, a request for address zero to n will map to + datastore address one to n+1, based on section 4.4 of + specification. + + Default is False. + + """ + + def __init__(self, *_args, + di=ModbusSequentialDataBlock.create(), + co=ModbusSequentialDataBlock.create(), + ir=ModbusSequentialDataBlock.create(), + hr=ModbusSequentialDataBlock.create(), + zero_mode=False): + """Initialize the datastores.""" + self.store = {} + self.store["d"] = di + self.store["c"] = co + self.store["i"] = ir + self.store["h"] = hr + self.zero_mode = zero_mode + + def __str__(self): + """Return a string representation of the context. + + :returns: A string representation of the context + """ + return "Modbus Slave Context" + + def reset(self): + """Reset all the datastores to their default values.""" + for datastore in iter(self.store.values()): + datastore.reset() + + def validate(self, fc_as_hex, address, count=1): + """Validate the request to make sure it is in range. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param count: The number of values to test + :returns: True if the request in within range, False otherwise + """ + if not self.zero_mode: + address += 1 + Log.debug("validate: fc-[{}] address-{}: count-{}", fc_as_hex, address, count) + return self.store[self.decode(fc_as_hex)].validate(address, count) + + def getValues(self, fc_as_hex, address, count=1): + """Get `count` values from datastore. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + if not self.zero_mode: + address += 1 + Log.debug("getValues: fc-[{}] address-{}: count-{}", fc_as_hex, address, count) + return self.store[self.decode(fc_as_hex)].getValues(address, count) + + def setValues(self, fc_as_hex, address, values): + """Set the datastore with the supplied values. + + :param fc_as_hex: The function we are working with + :param address: The starting address + :param values: The new values to be set + """ + if not self.zero_mode: + address += 1 + Log.debug("setValues[{}] address-{}: count-{}", fc_as_hex, address, len(values)) + self.store[self.decode(fc_as_hex)].setValues(address, values) + + def register(self, function_code, fc_as_hex, datablock=None): + """Register a datablock with the slave context. + + :param function_code: function code (int) + :param fc_as_hex: string representation of function code (e.g "cf" ) + :param datablock: datablock to associate with this function code + """ + self.store[fc_as_hex] = datablock or ModbusSequentialDataBlock.create() + self._fx_mapper[function_code] = fc_as_hex + + +class ModbusServerContext: + """This represents a master collection of slave contexts. + + If single is set to true, it will be treated as a single + context so every slave_id returns the same context. If single + is set to false, it will be interpreted as a collection of + slave contexts. + """ + + def __init__(self, slaves=None, single=True): + """Initialize a new instance of a modbus server context. + + :param slaves: A dictionary of client contexts + :param single: Set to true to treat this as a single context + """ + self.single = single + self._slaves = slaves or {} + if self.single: + self._slaves = {0: self._slaves} + + def __iter__(self): + """Iterate over the current collection of slave contexts. + + :returns: An iterator over the slave contexts + """ + return iter(self._slaves.items()) + + def __contains__(self, slave): + """Check if the given slave is in this list. + + :param slave: slave The slave to check for existence + :returns: True if the slave exists, False otherwise + """ + if self.single and self._slaves: + return True + return slave in self._slaves + + def __setitem__(self, slave, context): + """Use to set a new slave context. + + :param slave: The slave context to set + :param context: The new context to set for this slave + :raises NoSuchSlaveException: + """ + if self.single: + slave = 0 + if 0xF7 >= slave >= 0x00: + self._slaves[slave] = context + else: + raise NoSuchSlaveException(f"slave index :{slave} out of range") + + def __delitem__(self, slave): + """Use to access the slave context. + + :param slave: The slave context to remove + :raises NoSuchSlaveException: + """ + if not self.single and (0xF7 >= slave >= 0x00): + del self._slaves[slave] + else: + raise NoSuchSlaveException(f"slave index: {slave} out of range") + + def __getitem__(self, slave): + """Use to get access to a slave context. + + :param slave: The slave context to get + :returns: The requested slave context + :raises NoSuchSlaveException: + """ + if self.single: + slave = 0 + if slave in self._slaves: + return self._slaves.get(slave) + raise NoSuchSlaveException( + f"slave - {slave} does not exist, or is out of range" + ) + + def slaves(self): + """Define slaves.""" + # Python3 now returns keys() as iterable + return list(self._slaves.keys()) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/remote.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/remote.py new file mode 100644 index 00000000..85452be5 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/remote.py @@ -0,0 +1,129 @@ +"""Remote datastore.""" +from pymodbus.datastore import ModbusBaseSlaveContext +from pymodbus.exceptions import NotImplementedException +from pymodbus.logging import Log + + +# ---------------------------------------------------------------------------# +# Context +# ---------------------------------------------------------------------------# +class RemoteSlaveContext(ModbusBaseSlaveContext): + """TODO. + + This creates a modbus data model that connects to + a remote device (depending on the client used) + """ + + def __init__(self, client, slave=None): + """Initialize the datastores. + + :param client: The client to retrieve values with + :param slave: Unit ID of the remote slave + """ + self._client = client + self.slave = slave + self.result = None + self.__build_mapping() + if not self.__set_callbacks: + Log.error("Init went wrong.") + + def reset(self): + """Reset all the datastores to their default values.""" + raise NotImplementedException() + + def validate(self, _fc_as_hex, _address, _count): + """Validate the request to make sure it is in range. + + :returns: True + """ + return True + + def getValues(self, fc_as_hex, _address, _count=1): + """Get values from real call in validate.""" + if fc_as_hex in self._write_fc: + return [0] + group_fx = self.decode(fc_as_hex) + func_fc = self.__get_callbacks[group_fx] + self.result = func_fc(_address, _count) + return self.__extract_result(self.decode(fc_as_hex), self.result) + + def setValues(self, fc_as_hex, address, values): + """Set the datastore with the supplied values.""" + group_fx = self.decode(fc_as_hex) + if fc_as_hex not in self._write_fc: + raise ValueError(f"setValues() called with an non-write function code {fc_as_hex}") + func_fc = self.__set_callbacks[f"{group_fx}{fc_as_hex}"] + if fc_as_hex in {0x0F, 0x10}: # Write Multiple Coils, Write Multiple Registers + self.result = func_fc(address, values) + else: + self.result = func_fc(address, values[0]) + # if self.result.isError(): + # return self.result + + def __str__(self): + """Return a string representation of the context. + + :returns: A string representation of the context + """ + return f"Remote Slave Context({self._client})" + + def __build_mapping(self): + """Build the function code mapper.""" + params = {} + if self.slave: + params["slave"] = self.slave + self.__get_callbacks = { + "d": lambda a, c: self._client.read_discrete_inputs( + a, c, **params + ), + "c": lambda a, c: self._client.read_coils( + a, c, **params + ), + "h": lambda a, c: self._client.read_holding_registers( + a, c, **params + ), + "i": lambda a, c: self._client.read_input_registers( + a, c, **params + ), + } + self.__set_callbacks = { + "d5": lambda a, v: self._client.write_coil( + a, v, **params + ), + "d15": lambda a, v: self._client.write_coils( + a, v, **params + ), + "c5": lambda a, v: self._client.write_coil( + a, v, **params + ), + "c15": lambda a, v: self._client.write_coils( + a, v, **params + ), + "h6": lambda a, v: self._client.write_register( + a, v, **params + ), + "h16": lambda a, v: self._client.write_registers( + a, v, **params + ), + "i6": lambda a, v: self._client.write_register( + a, v, **params + ), + "i16": lambda a, v: self._client.write_registers( + a, v, **params + ), + } + self._write_fc = (0x05, 0x06, 0x0F, 0x10) + + def __extract_result(self, fc_as_hex, result): + """Extract the values out of a response. + + TODO make this consistent (values?) + """ + if not result.isError(): + if fc_as_hex in {"d", "c"}: + return result.bits + if fc_as_hex in {"h", "i"}: + return result.registers + else: + return result + return None diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/simulator.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/simulator.py new file mode 100644 index 00000000..69ecb2a2 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/simulator.py @@ -0,0 +1,803 @@ +"""Pymodbus ModbusSimulatorContext.""" +from __future__ import annotations + +import dataclasses +import random +import struct +from collections.abc import Callable +from datetime import datetime +from typing import Any + +from pymodbus.datastore.context import ModbusBaseSlaveContext + + +WORD_SIZE = 16 + + +@dataclasses.dataclass(frozen=True) +class CellType: + """Define single cell types.""" + + INVALID: int = 0 + BITS: int = 1 + UINT16: int = 2 + UINT32: int = 3 + FLOAT32: int = 4 + STRING: int = 5 + NEXT: int = 6 + + +@dataclasses.dataclass(repr=False, eq=False) +class Cell: + """Handle a single cell.""" + + type: int = CellType.INVALID + access: bool = False + value: int = 0 + action: int = 0 + action_parameters: dict[str, Any] | None = None + count_read: int = 0 + count_write: int = 0 + + +class TextCell: # pylint: disable=too-few-public-methods + """A textual representation of a single cell.""" + + type: str + access: str + value: str + action: str + action_parameters: str + count_read: str + count_write: str + + +@dataclasses.dataclass +class Label: # pylint: disable=too-many-instance-attributes + """Defines all dict values. + + :meta private: + """ + + action: str = "action" + addr: str = "addr" + any: str = "any" + co_size: str = "co size" + defaults: str = "defaults" + di_size: str = "di size" + hr_size: str = "hr size" + increment: str = "increment" + invalid: str = "invalid" + ir_size: str = "ir size" + parameters: str = "parameters" + method: str = "method" + next: str = "next" + none: str = "none" + random: str = "random" + repeat: str = "repeat" + reset: str = "reset" + setup: str = "setup" + shared_blocks: str = "shared blocks" + timestamp: str = "timestamp" + repeat_to: str = "to" + type: str = "type" + type_bits = "bits" + type_exception: str = "type exception" + type_uint16: str = "uint16" + type_uint32: str = "uint32" + type_float32: str = "float32" + type_string: str = "string" + uptime: str = "uptime" + value: str = "value" + write: str = "write" + + @classmethod + def try_get(cls, key, config_part): + """Check if entry is present in config.""" + if key not in config_part: + txt = f"ERROR Configuration invalid, missing {key} in {config_part}" + raise RuntimeError(txt) + return config_part[key] + + +class Setup: + """Setup simulator. + + :meta private: + """ + + def __init__(self, runtime): + """Initialize.""" + self.runtime = runtime + self.config = {} + self.config_types: dict[str, dict[str, Any]] = { + Label.type_bits: { + Label.type: CellType.BITS, + Label.next: None, + Label.value: 0, + Label.action: None, + Label.method: self.handle_type_bits, + }, + Label.type_uint16: { + Label.type: CellType.UINT16, + Label.next: None, + Label.value: 0, + Label.action: None, + Label.method: self.handle_type_uint16, + }, + Label.type_uint32: { + Label.type: CellType.UINT32, + Label.next: CellType.NEXT, + Label.value: 0, + Label.action: None, + Label.method: self.handle_type_uint32, + }, + Label.type_float32: { + Label.type: CellType.FLOAT32, + Label.next: CellType.NEXT, + Label.value: 0, + Label.action: None, + Label.method: self.handle_type_float32, + }, + Label.type_string: { + Label.type: CellType.STRING, + Label.next: CellType.NEXT, + Label.value: 0, + Label.action: None, + Label.method: self.handle_type_string, + }, + } + + def handle_type_bits(self, start, stop, value, action, action_parameters): + """Handle type bits.""" + for reg in self.runtime.registers[start:stop]: + if reg.type != CellType.INVALID: + raise RuntimeError(f'ERROR "{Label.type_bits}" {reg} used') + reg.value = value + reg.type = CellType.BITS + reg.action = action + reg.action_parameters = action_parameters + + def handle_type_uint16(self, start, stop, value, action, action_parameters): + """Handle type uint16.""" + for reg in self.runtime.registers[start:stop]: + if reg.type != CellType.INVALID: + raise RuntimeError(f'ERROR "{Label.type_uint16}" {reg} used') + reg.value = value + reg.type = CellType.UINT16 + reg.action = action + reg.action_parameters = action_parameters + + def handle_type_uint32(self, start, stop, value, action, action_parameters): + """Handle type uint32.""" + regs_value = ModbusSimulatorContext.build_registers_from_value(value, True) + for i in range(start, stop, 2): + regs = self.runtime.registers[i : i + 2] + if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID: + raise RuntimeError(f'ERROR "{Label.type_uint32}" {i},{i + 1} used') + regs[0].value = regs_value[0] + regs[0].type = CellType.UINT32 + regs[0].action = action + regs[0].action_parameters = action_parameters + regs[1].value = regs_value[1] + regs[1].type = CellType.NEXT + + def handle_type_float32(self, start, stop, value, action, action_parameters): + """Handle type uint32.""" + regs_value = ModbusSimulatorContext.build_registers_from_value(value, False) + for i in range(start, stop, 2): + regs = self.runtime.registers[i : i + 2] + if regs[0].type != CellType.INVALID or regs[1].type != CellType.INVALID: + raise RuntimeError(f'ERROR "{Label.type_float32}" {i},{i + 1} used') + regs[0].value = regs_value[0] + regs[0].type = CellType.FLOAT32 + regs[0].action = action + regs[0].action_parameters = action_parameters + regs[1].value = regs_value[1] + regs[1].type = CellType.NEXT + + def handle_type_string(self, start, stop, value, action, action_parameters): + """Handle type string.""" + regs = stop - start + reg_len = regs * 2 + if len(value) > reg_len: + raise RuntimeError( + f'ERROR "{Label.type_string}" {start} too long "{value}"' + ) + value = value.ljust(reg_len) + for i in range(stop - start): + reg = self.runtime.registers[start + i] + if reg.type != CellType.INVALID: + raise RuntimeError(f'ERROR "{Label.type_string}" {start + i} used') + j = i * 2 + reg.value = int.from_bytes(bytes(value[j : j + 2], "UTF-8"), "big") + reg.type = CellType.NEXT + self.runtime.registers[start].type = CellType.STRING + self.runtime.registers[start].action = action + self.runtime.registers[start].action_parameters = action_parameters + + def handle_setup_section(self): + """Load setup section.""" + layout = Label.try_get(Label.setup, self.config) + self.runtime.fc_offset = {key: 0 for key in range(25)} + size_co = Label.try_get(Label.co_size, layout) + size_di = Label.try_get(Label.di_size, layout) + size_hr = Label.try_get(Label.hr_size, layout) + size_ir = Label.try_get(Label.ir_size, layout) + if Label.try_get(Label.shared_blocks, layout): + total_size = max(size_co, size_di, size_hr, size_ir) + else: + # set offset (block) for each function code + # starting with fc = 1, 5, 15 + self.runtime.fc_offset[2] = size_co + total_size = size_co + size_di + self.runtime.fc_offset[4] = total_size + total_size += size_ir + for i in (3, 6, 16, 22, 23): + self.runtime.fc_offset[i] = total_size + total_size += size_hr + first_cell = Cell() + self.runtime.registers = [ + dataclasses.replace(first_cell) for i in range(total_size) + ] + self.runtime.register_count = total_size + self.runtime.type_exception = bool(Label.try_get(Label.type_exception, layout)) + defaults = Label.try_get(Label.defaults, layout) + defaults_value = Label.try_get(Label.value, defaults) + defaults_action = Label.try_get(Label.action, defaults) + for key, entry in self.config_types.items(): + entry[Label.value] = Label.try_get(key, defaults_value) + if ( + action := Label.try_get(key, defaults_action) + ) not in self.runtime.action_name_to_id: + raise RuntimeError(f"ERROR illegal action {key} in {defaults_action}") + entry[Label.action] = action + del self.config[Label.setup] + + def handle_invalid_address(self): + """Handle invalid address.""" + for entry in Label.try_get(Label.invalid, self.config): + if isinstance(entry, int): + entry = [entry, entry] + for i in range(entry[0], entry[1] + 1): + if i >= self.runtime.register_count: + raise RuntimeError( + f'Error section "{Label.invalid}" addr {entry} out of range' + ) + reg = self.runtime.registers[i] + reg.type = CellType.INVALID + del self.config[Label.invalid] + + def handle_write_allowed(self): + """Handle write allowed.""" + for entry in Label.try_get(Label.write, self.config): + if isinstance(entry, int): + entry = [entry, entry] + for i in range(entry[0], entry[1] + 1): + if i >= self.runtime.register_count: + raise RuntimeError( + f'Error section "{Label.write}" addr {entry} out of range' + ) + reg = self.runtime.registers[i] + if reg.type == CellType.INVALID: + txt = f'ERROR Configuration invalid in section "write" register {i} not defined' + raise RuntimeError(txt) + reg.access = True + del self.config[Label.write] + + def handle_types(self): + """Handle the different types.""" + for section, type_entry in self.config_types.items(): + layout = Label.try_get(section, self.config) + for entry in layout: + if not isinstance(entry, dict): + entry = {Label.addr: entry} + regs = Label.try_get(Label.addr, entry) + if not isinstance(regs, list): + regs = [regs, regs] + start = regs[0] + if (stop := regs[1]) >= self.runtime.register_count: + raise RuntimeError(f'Error "{section}" {start}, {stop} illegal') + type_entry[Label.method]( + start, + stop + 1, + entry.get(Label.value, type_entry[Label.value]), + self.runtime.action_name_to_id[ + entry.get(Label.action, type_entry[Label.action]) + ], + entry.get(Label.parameters, None), + ) + del self.config[section] + + def handle_repeat(self): + """Handle repeat.""" + for entry in Label.try_get(Label.repeat, self.config): + addr = Label.try_get(Label.addr, entry) + copy_start = addr[0] + copy_end = addr[1] + copy_inx = copy_start - 1 + addr_to = Label.try_get(Label.repeat_to, entry) + for inx in range(addr_to[0], addr_to[1] + 1): + copy_inx = copy_start if copy_inx >= copy_end else copy_inx + 1 + if inx >= self.runtime.register_count: + raise RuntimeError( + f'Error section "{Label.repeat}" entry {entry} out of range' + ) + self.runtime.registers[inx] = dataclasses.replace( + self.runtime.registers[copy_inx] + ) + del self.config[Label.repeat] + + def setup(self, config, custom_actions) -> None: + """Load layout from dict with json structure.""" + actions = { + Label.increment: self.runtime.action_increment, + Label.random: self.runtime.action_random, + Label.reset: self.runtime.action_reset, + Label.timestamp: self.runtime.action_timestamp, + Label.uptime: self.runtime.action_uptime, + } + if custom_actions: + actions.update(custom_actions) + self.runtime.action_name_to_id = {None: 0} + self.runtime.action_id_to_name = [Label.none] + self.runtime.action_methods = [None] + i = 1 + for key, method in actions.items(): + self.runtime.action_name_to_id[key] = i + self.runtime.action_id_to_name.append(key) + self.runtime.action_methods.append(method) + i += 1 + self.runtime.registerType_name_to_id = { + Label.type_bits: CellType.BITS, + Label.type_uint16: CellType.UINT16, + Label.type_uint32: CellType.UINT32, + Label.type_float32: CellType.FLOAT32, + Label.type_string: CellType.STRING, + Label.next: CellType.NEXT, + Label.invalid: CellType.INVALID, + } + self.runtime.registerType_id_to_name = [None] * len( + self.runtime.registerType_name_to_id + ) + for name, cell_type in self.runtime.registerType_name_to_id.items(): + self.runtime.registerType_id_to_name[cell_type] = name + + self.config = config + self.handle_setup_section() + self.handle_invalid_address() + self.handle_types() + self.handle_write_allowed() + self.handle_repeat() + if self.config: + raise RuntimeError(f"INVALID key in setup: {self.config}") + + +class ModbusSimulatorContext(ModbusBaseSlaveContext): + """Modbus simulator. + + :param config: A dict with structure as shown below. + :param actions: A dict with "<name>": <function> structure. + :raises RuntimeError: if json contains errors (msg explains what) + + It builds and maintains a virtual copy of a device, with simulation of + device specific functions. + + The device is described in a dict, user supplied actions will + be added to the builtin actions. + + It is used in conjunction with a pymodbus server. + + Example:: + + store = ModbusSimulatorContext(<config dict>, <actions dict>) + StartAsyncTcpServer(<host>, context=store) + + Now the server will simulate the defined device with features like: + + - invalid addresses + - write protected addresses + - optional control of access for string, uint32, bit/bits + - builtin actions for e.g. reset/datetime, value increment by read + - custom actions + + Description of the json file or dict to be supplied:: + + { + "setup": { + "di size": 0, --> Size of discrete input block (8 bit) + "co size": 0, --> Size of coils block (8 bit) + "ir size": 0, --> Size of input registers block (16 bit) + "hr size": 0, --> Size of holding registers block (16 bit) + "shared blocks": True, --> share memory for all blocks (largest size wins) + "defaults": { + "value": { --> Initial values (can be overwritten) + "bits": 0x01, + "uint16": 122, + "uint32": 67000, + "float32": 127.4, + "string": " ", + }, + "action": { --> default action (can be overwritten) + "bits": None, + "uint16": None, + "uint32": None, + "float32": None, + "string": None, + }, + }, + "type exception": False, --> return IO exception if read/write on non boundary + }, + "invalid": [ --> List of invalid addresses, IO exception returned + 51, --> single register + [78, 99], --> start, end registers, repeated as needed + ], + "write": [ --> allow write, efault is ReadOnly + [5, 5] --> start, end bytes, repeated as needed + ], + "bits": [ --> Define bits (1 register == 2 bytes) + [30, 31], --> start, end registers, repeated as needed + {"addr": [32, 34], "value": 0xF1}, --> with value + {"addr": [35, 36], "action": "increment"}, --> with action + {"addr": [37, 38], "action": "increment", "value": 0xF1} --> with action and value + {"addr": [37, 38], "action": "increment", "parameters": {"min": 0, "max": 100}} --> with action with arguments + ], + "uint16": [ --> Define uint16 (1 register == 2 bytes) + --> same as type_bits + ], + "uint32": [ --> Define 32 bit integers (2 registers == 4 bytes) + --> same as type_bits + ], + "float32": [ --> Define 32 bit floats (2 registers == 4 bytes) + --> same as type_bits + ], + "string": [ --> Define strings (variable number of registers (each 2 bytes)) + [21, 22], --> start, end registers, define 1 string + {"addr": 23, 25], "value": "ups"}, --> with value + {"addr": 26, 27], "action": "user"}, --> with action + {"addr": 28, 29], "action": "", "value": "user"} --> with action and value + ], + "repeat": [ --> allows to repeat section e.g. for n devices + {"addr": [100, 200], "to": [50, 275]} --> Repeat registers 100-200 to 50+ until 275 + ] + } + """ + + # -------------------------------------------- + # External interfaces + # -------------------------------------------- + start_time = int(datetime.now().timestamp()) + + def __init__( + self, config: dict[str, Any], custom_actions: dict[str, Callable] | None + ) -> None: + """Initialize.""" + self.registers: list[Cell] = [] + self.fc_offset: dict[int, int] = {} + self.register_count = 0 + self.type_exception = False + self.action_name_to_id: dict[str, int] = {} + self.action_id_to_name: list[str] = [] + self.action_methods: list[Callable] = [] + self.registerType_name_to_id: dict[str, int] = {} + self.registerType_id_to_name: list[str] = [] + Setup(self).setup(config, custom_actions) + + # -------------------------------------------- + # Simulator server interface + # -------------------------------------------- + def get_text_register(self, register): + """Get raw register.""" + reg = self.registers[register] + text_cell = TextCell() + text_cell.type = self.registerType_id_to_name[reg.type] + text_cell.access = str(reg.access) + text_cell.count_read = str(reg.count_read) + text_cell.count_write = str(reg.count_write) + text_cell.action = self.action_id_to_name[reg.action] + if reg.action_parameters: + text_cell.action = f"{text_cell.action}({reg.action_parameters})" + if reg.type in (CellType.INVALID, CellType.UINT16, CellType.NEXT): + text_cell.value = str(reg.value) + build_len = 0 + elif reg.type == CellType.BITS: + text_cell.value = hex(reg.value) + build_len = 0 + elif reg.type == CellType.UINT32: + tmp_regs = [reg.value, self.registers[register + 1].value] + text_cell.value = str(self.build_value_from_registers(tmp_regs, True)) + build_len = 1 + elif reg.type == CellType.FLOAT32: + tmp_regs = [reg.value, self.registers[register + 1].value] + text_cell.value = str(self.build_value_from_registers(tmp_regs, False)) + build_len = 1 + else: # reg.type == CellType.STRING: + j = register + text_cell.value = "" + while True: + text_cell.value += str( + self.registers[j].value.to_bytes(2, "big"), + encoding="utf-8", + errors="ignore", + ) + j += 1 + if self.registers[j].type != CellType.NEXT: + break + build_len = j - register - 1 + reg_txt = f"{register}-{register + build_len}" if build_len else f"{register}" + return reg_txt, text_cell + + # -------------------------------------------- + # Modbus server interface + # -------------------------------------------- + + _write_func_code = (5, 6, 15, 16, 22, 23) + _bits_func_code = (1, 2, 5, 15) + + def loop_validate(self, address, end_address, fx_write): + """Validate entry in loop. + + :meta private: + """ + i = address + while i < end_address: + reg = self.registers[i] + if fx_write and not reg.access or reg.type == CellType.INVALID: + return False + if not self.type_exception: + i += 1 + continue + if reg.type == CellType.NEXT: + return False + if reg.type in (CellType.BITS, CellType.UINT16): + i += 1 + elif reg.type in (CellType.UINT32, CellType.FLOAT32): + if i + 1 >= end_address: + return False + i += 2 + else: + i += 1 + while i < end_address: + if self.registers[i].type == CellType.NEXT: + i += 1 + return True + + def validate(self, func_code, address, count=1): + """Check to see if the request is in range. + + :meta private: + """ + if func_code in self._bits_func_code: + # Bit count, correct to register count + count = int((count + WORD_SIZE - 1) / WORD_SIZE) + address = int(address / 16) + + real_address = self.fc_offset[func_code] + address + if real_address < 0 or real_address > self.register_count: + return False + + fx_write = func_code in self._write_func_code + return self.loop_validate(real_address, real_address + count, fx_write) + + def getValues(self, func_code, address, count=1): + """Return the requested values of the datastore. + + :meta private: + """ + result = [] + if func_code not in self._bits_func_code: + real_address = self.fc_offset[func_code] + address + for i in range(real_address, real_address + count): + reg = self.registers[i] + parameters = reg.action_parameters if reg.action_parameters else {} + if reg.action: + self.action_methods[reg.action](self.registers, i, reg, **parameters) + self.registers[i].count_read += 1 + result.append(reg.value) + else: + # bit access + real_address = self.fc_offset[func_code] + int(address / 16) + bit_index = address % 16 + reg_count = int((count + bit_index + 15) / 16) + for i in range(real_address, real_address + reg_count): + reg = self.registers[i] + if reg.action: + parameters = reg.action_parameters or {} + self.action_methods[reg.action]( + self.registers, i, reg, **parameters + ) + self.registers[i].count_read += 1 + while count and bit_index < 16: + result.append(bool(reg.value & (2**bit_index))) + count -= 1 + bit_index += 1 + bit_index = 0 + return result + + def setValues(self, func_code, address, values): + """Set the requested values of the datastore. + + :meta private: + """ + if func_code not in self._bits_func_code: + real_address = self.fc_offset[func_code] + address + for value in values: + self.registers[real_address].value = value + self.registers[real_address].count_write += 1 + real_address += 1 + return + + # bit access + real_address = self.fc_offset[func_code] + int(address / 16) + bit_index = address % 16 + for value in values: + bit_mask = 2**bit_index + if bool(value): + self.registers[real_address].value |= bit_mask + else: + self.registers[real_address].value &= ~bit_mask + self.registers[real_address].count_write += 1 + bit_index += 1 + if bit_index == 16: + bit_index = 0 + real_address += 1 + return + + # -------------------------------------------- + # Internal action methods + # -------------------------------------------- + + @classmethod + def action_random(cls, registers, inx, cell, minval=1, maxval=65536): + """Update with random value. + + :meta private: + """ + if cell.type in (CellType.BITS, CellType.UINT16): + registers[inx].value = random.randint(int(minval), int(maxval)) + elif cell.type == CellType.FLOAT32: + regs = cls.build_registers_from_value( + random.uniform(float(minval), float(maxval)), False + ) + registers[inx].value = regs[0] + registers[inx + 1].value = regs[1] + elif cell.type == CellType.UINT32: + regs = cls.build_registers_from_value( + random.randint(int(minval), int(maxval)), True + ) + registers[inx].value = regs[0] + registers[inx + 1].value = regs[1] + + @classmethod + def action_increment(cls, registers, inx, cell, minval=None, maxval=None): + """Increment value reset with overflow. + + :meta private: + """ + reg = registers[inx] + reg2 = registers[inx + 1] + if cell.type in (CellType.BITS, CellType.UINT16): + value = reg.value + 1 + if maxval and value > maxval: + value = minval + if minval and value < minval: + value = minval + reg.value = value + elif cell.type == CellType.FLOAT32: + tmp_reg = [reg.value, reg2.value] + value = cls.build_value_from_registers(tmp_reg, False) + value += 1.0 + if maxval and value > maxval: + value = minval + if minval and value < minval: + value = minval + new_regs = cls.build_registers_from_value(value, False) + reg.value = new_regs[0] + reg2.value = new_regs[1] + elif cell.type == CellType.UINT32: + tmp_reg = [reg.value, reg2.value] + value = cls.build_value_from_registers(tmp_reg, True) + value += 1 + if maxval and value > maxval: + value = minval + if minval and value < minval: + value = minval + new_regs = cls.build_registers_from_value(value, True) + reg.value = new_regs[0] + reg2.value = new_regs[1] + + @classmethod + def action_timestamp(cls, registers, inx, _cell, **_parameters): + """Set current time. + + :meta private: + """ + system_time = datetime.now() + registers[inx].value = system_time.year + registers[inx + 1].value = system_time.month - 1 + registers[inx + 2].value = system_time.day + registers[inx + 3].value = system_time.weekday() + 1 + registers[inx + 4].value = system_time.hour + registers[inx + 5].value = system_time.minute + registers[inx + 6].value = system_time.second + + @classmethod + def action_reset(cls, _registers, _inx, _cell, **_parameters): + """Reboot server. + + :meta private: + """ + raise RuntimeError("RESET server") + + @classmethod + def action_uptime(cls, registers, inx, cell, **_parameters): + """Return uptime in seconds. + + :meta private: + """ + value = int(datetime.now().timestamp()) - cls.start_time + 1 + + if cell.type in (CellType.BITS, CellType.UINT16): + registers[inx].value = value + elif cell.type == CellType.FLOAT32: + regs = cls.build_registers_from_value(value, False) + registers[inx].value = regs[0] + registers[inx + 1].value = regs[1] + elif cell.type == CellType.UINT32: + regs = cls.build_registers_from_value(value, True) + registers[inx].value = regs[0] + registers[inx + 1].value = regs[1] + + # -------------------------------------------- + # Internal helper methods + # -------------------------------------------- + + def validate_type(self, func_code, real_address, count) -> bool: + """Check if request is done against correct type. + + :meta private: + """ + check: tuple + if func_code in self._bits_func_code: + # Bit access + check = (CellType.BITS, -1) + reg_step = 1 + elif count % 2: + # 16 bit access + check = (CellType.UINT16, CellType.STRING) + reg_step = 1 + else: + check = (CellType.UINT32, CellType.FLOAT32, CellType.STRING) + reg_step = 2 + + for i in range(real_address, real_address + count, reg_step): + if self.registers[i].type in check: + continue + if self.registers[i].type is CellType.NEXT: + continue + return False + return True + + @classmethod + def build_registers_from_value(cls, value, is_int): + """Build registers from int32 or float32.""" + regs = [0, 0] + if is_int: + value_bytes = int.to_bytes(value, 4, "big") + else: + value_bytes = struct.pack(">f", value) + regs[0] = int.from_bytes(value_bytes[:2], "big") + regs[1] = int.from_bytes(value_bytes[-2:], "big") + return regs + + @classmethod + def build_value_from_registers(cls, registers, is_int): + """Build int32 or float32 value from registers.""" + value_bytes = int.to_bytes(registers[0], 2, "big") + int.to_bytes( + registers[1], 2, "big" + ) + if is_int: + value = int.from_bytes(value_bytes, "big") + else: + value = struct.unpack(">f", value_bytes)[0] + return value diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/store.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/store.py new file mode 100644 index 00000000..51cfbef8 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/datastore/store.py @@ -0,0 +1,344 @@ +"""Modbus Server Datastore. + +For each server, you will create a ModbusServerContext and pass +in the default address space for each data access. The class +will create and manage the data. + +Further modification of said data accesses should be performed +with [get,set][access]Values(address, count) + +Datastore Implementation +------------------------- + +There are two ways that the server datastore can be implemented. +The first is a complete range from "address" start to "count" +number of indices. This can be thought of as a straight array:: + + data = range(1, 1 + count) + [1,2,3,...,count] + +The other way that the datastore can be implemented (and how +many devices implement it) is a associate-array:: + + data = {1:"1", 3:"3", ..., count:"count"} + [1,3,...,count] + +The difference between the two is that the latter will allow +arbitrary gaps in its datastore while the former will not. +This is seen quite commonly in some modbus implementations. +What follows is a clear example from the field: + +Say a company makes two devices to monitor power usage on a rack. +One works with three-phase and the other with a single phase. The +company will dictate a modbus data mapping such that registers:: + + n: phase 1 power + n+1: phase 2 power + n+2: phase 3 power + +Using this, layout, the first device will implement n, n+1, and n+2, +however, the second device may set the latter two values to 0 or +will simply not implemented the registers thus causing a single read +or a range read to fail. + +I have both methods implemented, and leave it up to the user to change +based on their preference. +""" +# pylint: disable=missing-type-doc +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable +from typing import Any, Generic, TypeVar + +from pymodbus.exceptions import ParameterException + + +# ---------------------------------------------------------------------------# +# Datablock Storage +# ---------------------------------------------------------------------------# + +V = TypeVar('V', list, dict[int, Any]) +class BaseModbusDataBlock(ABC, Generic[V]): + """Base class for a modbus datastore. + + Derived classes must create the following fields: + @address The starting address point + @defult_value The default value of the datastore + @values The actual datastore values + + Derived classes must implemented the following methods: + validate(self, address, count=1) + getValues(self, address, count=1) + setValues(self, address, values) + reset(self) + + Derived classes can implemented the following async methods: + async_getValues(self, address, count=1) + async_setValues(self, address, values) + but are not needed since these standard call the sync. methods. + """ + + values: V + address: int + default_value: Any + + @abstractmethod + def validate(self, address:int, count=1) -> bool: + """Check to see if the request is in range. + + :param address: The starting address + :param count: The number of values to test for + :raises TypeError: + """ + + async def async_getValues(self, address: int, count=1) -> Iterable: + """Return the requested values from the datastore. + + :param address: The starting address + :param count: The number of values to retrieve + :raises TypeError: + """ + return self.getValues(address, count) + + @abstractmethod + def getValues(self, address:int, count=1) -> Iterable: + """Return the requested values from the datastore. + + :param address: The starting address + :param count: The number of values to retrieve + :raises TypeError: + """ + + async def async_setValues(self, address: int, values: list[int|bool]) -> None: + """Set the requested values in the datastore. + + :param address: The starting address + :param values: The values to store + :raises TypeError: + """ + self.setValues(address, values) + + @abstractmethod + def setValues(self, address:int, values) -> None: + """Set the requested values in the datastore. + + :param address: The starting address + :param values: The values to store + :raises TypeError: + """ + + def __str__(self): + """Build a representation of the datastore. + + :returns: A string representation of the datastore + """ + return f"DataStore({len(self.values)}, {self.default_value})" + + def __iter__(self): + """Iterate over the data block data. + + :returns: An iterator of the data block data + """ + if isinstance(self.values, dict): + return iter(self.values.items()) + return enumerate(self.values, self.address) + + +class ModbusSequentialDataBlock(BaseModbusDataBlock[list]): + """Creates a sequential modbus datastore.""" + + def __init__(self, address, values): + """Initialize the datastore. + + :param address: The starting address of the datastore + :param values: Either a list or a dictionary of values + """ + self.address = address + if hasattr(values, "__iter__"): + self.values = list(values) + else: + self.values = [values] + self.default_value = self.values[0].__class__() + + @classmethod + def create(cls): + """Create a datastore. + + With the full address space initialized to 0x00 + + :returns: An initialized datastore + """ + return cls(0x00, [0x00] * 65536) + + def default(self, count, value=False): + """Use to initialize a store to one value. + + :param count: The number of fields to set + :param value: The default value to set to the fields + """ + self.default_value = value + self.values = [self.default_value] * count + self.address = 0x00 + + def reset(self): + """Reset the datastore to the initialized default value.""" + self.values = [self.default_value] * len(self.values) + + def validate(self, address, count=1): + """Check to see if the request is in range. + + :param address: The starting address + :param count: The number of values to test for + :returns: True if the request in within range, False otherwise + """ + result = self.address <= address + result &= (self.address + len(self.values)) >= (address + count) + return result + + def getValues(self, address, count=1): + """Return the requested values of the datastore. + + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + start = address - self.address + return self.values[start : start + count] + + def setValues(self, address, values): + """Set the requested values of the datastore. + + :param address: The starting address + :param values: The new values to be set + """ + if not isinstance(values, list): + values = [values] + start = address - self.address + self.values[start : start + len(values)] = values + + +class ModbusSparseDataBlock(BaseModbusDataBlock[dict[int, Any]]): + """A sparse modbus datastore. + + E.g Usage. + sparse = ModbusSparseDataBlock({10: [3, 5, 6, 8], 30: 1, 40: [0]*20}) + + This would create a datablock with 3 blocks + One starts at offset 10 with length 4, one at 30 with length 1, and one at 40 with length 20 + + sparse = ModbusSparseDataBlock([10]*100) + Creates a sparse datablock of length 100 starting at offset 0 and default value of 10 + + sparse = ModbusSparseDataBlock() --> Create empty datablock + sparse.setValues(0, [10]*10) --> Add block 1 at offset 0 with length 10 (default value 10) + sparse.setValues(30, [20]*5) --> Add block 2 at offset 30 with length 5 (default value 20) + + Unless 'mutable' is set to True during initialization, the datablock cannot be altered with + setValues (new datablocks cannot be added) + """ + + def __init__(self, values=None, mutable=True): + """Initialize a sparse datastore. + + Will only answer to addresses registered, + either initially here, or later via setValues() + + :param values: Either a list or a dictionary of values + :param mutable: Whether the data-block can be altered later with setValues (i.e add more blocks) + + If values is a list, a sequential datablock will be created. + + If values is a dictionary, it should be in {offset: <int | list>} format + For each list, a sparse datablock is created, starting at 'offset' with the length of the list + For each integer, the value is set for the corresponding offset. + + """ + self.values = {} + self._process_values(values) + self.mutable = mutable + self.default_value = self.values.copy() + + @classmethod + def create(cls, values=None): + """Create sparse datastore. + + Use setValues to initialize registers. + + :param values: Either a list or a dictionary of values + :returns: An initialized datastore + """ + return cls(values) + + def reset(self): + """Reset the store to the initially provided defaults.""" + self.values = self.default_value.copy() + + def validate(self, address, count=1): + """Check to see if the request is in range. + + :param address: The starting address + :param count: The number of values to test for + :returns: True if the request in within range, False otherwise + """ + if not count: + return False + handle = set(range(address, address + count)) + return handle.issubset(set(iter(self.values.keys()))) + + def getValues(self, address, count=1): + """Return the requested values of the datastore. + + :param address: The starting address + :param count: The number of values to retrieve + :returns: The requested values from a:a+c + """ + return [self.values[i] for i in range(address, address + count)] + + def _process_values(self, values): + """Process values.""" + + def _process_as_dict(values): + for idx, val in iter(values.items()): + if isinstance(val, (list, tuple)): + for i, v_item in enumerate(val): + self.values[idx + i] = v_item + else: + self.values[idx] = int(val) + + if isinstance(values, dict): + _process_as_dict(values) + return + if hasattr(values, "__iter__"): + values = dict(enumerate(values)) + elif values is None: + values = {} # Must make a new dict here per instance + else: + raise ParameterException( + "Values for datastore must be a list or dictionary" + ) + _process_as_dict(values) + + def setValues(self, address, values, use_as_default=False): + """Set the requested values of the datastore. + + :param address: The starting address + :param values: The new values to be set + :param use_as_default: Use the values as default + :raises ParameterException: + """ + if isinstance(values, dict): + new_offsets = list(set(values.keys()) - set(self.values.keys())) + if new_offsets and not self.mutable: + raise ParameterException(f"Offsets {new_offsets} not in range") + self._process_values(values) + else: + if not isinstance(values, list): + values = [values] + for idx, val in enumerate(values): + if address + idx not in self.values and not self.mutable: + raise ParameterException("Offset {address+idx} not in range") + self.values[address + idx] = val + if use_as_default: + for idx, val in iter(self.values.items()): + self.default_value[idx] = val diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/device.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/device.py new file mode 100644 index 00000000..136ac0c8 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/device.py @@ -0,0 +1,586 @@ +"""Modbus Device Controller. + +These are the device management handlers. They should be +maintained in the server context and the various methods +should be inserted in the correct locations. +""" +from __future__ import annotations + + +__all__ = [ + "ModbusPlusStatistics", + "ModbusDeviceIdentification", + "DeviceInformationFactory", +] + +import struct + +# pylint: disable=missing-type-doc +from collections import OrderedDict + +from pymodbus.constants import INTERNAL_ERROR, DeviceInformation +from pymodbus.events import ModbusEvent +from pymodbus.utilities import dict_property + + +# ---------------------------------------------------------------------------# +# Modbus Plus Statistics +# ---------------------------------------------------------------------------# +class ModbusPlusStatistics: + """This is used to maintain the current modbus plus statistics count. + + As of right now this is simply a stub to complete the modbus implementation. + For more information, see the modbus implementation guide page 87. + """ + + __data = OrderedDict( + { + "node_type_id": [0x00] * 2, # 00 + "software_version_number": [0x00] * 2, # 01 + "network_address": [0x00] * 2, # 02 + "mac_state_variable": [0x00] * 2, # 03 + "peer_status_code": [0x00] * 2, # 04 + "token_pass_counter": [0x00] * 2, # 05 + "token_rotation_time": [0x00] * 2, # 06 + "program_master_token_failed": [0x00], # 07 hi + "data_master_token_failed": [0x00], # 07 lo + "program_master_token_owner": [0x00], # 08 hi + "data_master_token_owner": [0x00], # 08 lo + "program_slave_token_owner": [0x00], # 09 hi + "data_slave_token_owner": [0x00], # 09 lo + "data_slave_command_transfer": [0x00], # 10 hi + "__unused_10_lowbit": [0x00], # 10 lo + "program_slave_command_transfer": [0x00], # 11 hi + "program_master_rsp_transfer": [0x00], # 11 lo + "program_slave_auto_logout": [0x00], # 12 hi + "program_master_connect_status": [0x00], # 12 lo + "receive_buffer_dma_overrun": [0x00], # 13 hi + "pretransmit_deferral_error": [0x00], # 13 lo + "frame_size_error": [0x00], # 14 hi + "repeated_command_received": [0x00], # 14 lo + "receiver_alignment_error": [0x00], # 15 hi + "receiver_collision_abort_error": [0x00], # 15 lo + "bad_packet_length_error": [0x00], # 16 hi + "receiver_crc_error": [0x00], # 16 lo + "transmit_buffer_dma_underrun": [0x00], # 17 hi + "bad_link_address_error": [0x00], # 17 lo + "bad_mac_function_code_error": [0x00], # 18 hi + "internal_packet_length_error": [0x00], # 18 lo + "communication_failed_error": [0x00], # 19 hi + "communication_retries": [0x00], # 19 lo + "no_response_error": [0x00], # 20 hi + "good_receive_packet": [0x00], # 20 lo + "unexpected_path_error": [0x00], # 21 hi + "exception_response_error": [0x00], # 21 lo + "forgotten_transaction_error": [0x00], # 22 hi + "unexpected_response_error": [0x00], # 22 lo + "active_station_bit_map": [0x00] * 8, # 23-26 + "token_station_bit_map": [0x00] * 8, # 27-30 + "global_data_bit_map": [0x00] * 8, # 31-34 + "receive_buffer_use_bit_map": [0x00] * 8, # 35-37 + "data_master_output_path": [0x00] * 8, # 38-41 + "data_slave_input_path": [0x00] * 8, # 42-45 + "program_master_outptu_path": [0x00] * 8, # 46-49 + "program_slave_input_path": [0x00] * 8, # 50-53 + } + ) + + def __init__(self): + """Initialize the modbus plus statistics with the default information.""" + self.reset() + + def __iter__(self): + """Iterate over the statistics. + + :returns: An iterator of the modbus plus statistics + """ + return iter(self.__data.items()) + + def reset(self): + """Clear all of the modbus plus statistics.""" + for key in self.__data: + self.__data[key] = [0x00] * len(self.__data[key]) + + def summary(self): + """Return a summary of the modbus plus statistics. + + :returns: 54 16-bit words representing the status + """ + return iter(self.__data.values()) + + def encode(self): + """Return a summary of the modbus plus statistics. + + :returns: 54 16-bit words representing the status + """ + total, values = [], sum(self.__data.values(), []) # noqa: RUF017 + for i in range(0, len(values), 2): + total.append((values[i] << 8) | values[i + 1]) + return total + + +# ---------------------------------------------------------------------------# +# Device Information Control +# ---------------------------------------------------------------------------# +class ModbusDeviceIdentification: + """This is used to supply the device identification. + + For the readDeviceIdentification function + + For more information read section 6.21 of the modbus + application protocol. + """ + + __data = { + 0x00: "", # VendorName + 0x01: "", # ProductCode + 0x02: "", # MajorMinorRevision + 0x03: "", # VendorUrl + 0x04: "", # ProductName + 0x05: "", # ModelName + 0x06: "", # UserApplicationName + 0x07: "", # reserved + 0x08: "", # reserved + # 0x80 -> 0xFF are privatek + } + + __names = [ + "VendorName", + "ProductCode", + "MajorMinorRevision", + "VendorUrl", + "ProductName", + "ModelName", + "UserApplicationName", + ] + + def __init__(self, info=None, info_name=None): + """Initialize the datastore with the elements you need. + + (note acceptable range is [0x00-0x06,0x80-0xFF] inclusive) + + :param info: A dictionary of {int:string} of values + :param set: A dictionary of {name:string} of values + """ + if isinstance(info_name, dict): + for key in info_name: + inx = self.__names.index(key) + self.__data[inx] = info_name[key] + + if isinstance(info, dict): + for key in info: + if (0x06 >= key >= 0x00) or (0xFF >= key >= 0x80): + self.__data[key] = info[key] + + def __iter__(self): + """Iterate over the device information. + + :returns: An iterator of the device information + """ + return iter(self.__data.items()) + + def summary(self): + """Return a summary of the main items. + + :returns: An dictionary of the main items + """ + return dict(zip(self.__names, iter(self.__data.values()))) + + def update(self, value): + """Update the values of this identity. + + using another identify as the value + + :param value: The value to copy values from + """ + self.__data.update(value) + + def __setitem__(self, key, value): + """Access the device information. + + :param key: The register to set + :param value: The new value for referenced register + """ + if key not in [0x07, 0x08]: + self.__data[key] = value + + def __getitem__(self, key): + """Access the device information. + + :param key: The register to read + """ + return self.__data.setdefault(key, "") + + def __str__(self): + """Build a representation of the device. + + :returns: A string representation of the device + """ + return "DeviceIdentity" + + # -------------------------------------------------------------------------# + # Properties + # -------------------------------------------------------------------------# + # fmt: off + VendorName = dict_property(lambda s: s.__data, 0) # pylint: disable=protected-access + ProductCode = dict_property(lambda s: s.__data, 1) # pylint: disable=protected-access + MajorMinorRevision = dict_property(lambda s: s.__data, 2) # pylint: disable=protected-access + VendorUrl = dict_property(lambda s: s.__data, 3) # pylint: disable=protected-access + ProductName = dict_property(lambda s: s.__data, 4) # pylint: disable=protected-access + ModelName = dict_property(lambda s: s.__data, 5) # pylint: disable=protected-access + UserApplicationName = dict_property(lambda s: s.__data, 6) # pylint: disable=protected-access + # fmt: on + + +class DeviceInformationFactory: # pylint: disable=too-few-public-methods + """This is a helper. + + That really just hides + some of the complexity of processing the device information + requests (function code 0x2b 0x0e). + """ + + __lookup = { + DeviceInformation.BASIC: lambda c, r, i: c.__gets( # pylint: disable=protected-access + r, list(range(i, 0x03)) + ), + DeviceInformation.REGULAR: lambda c, r, i: c.__gets( # pylint: disable=protected-access + r, + list(range(i, 0x07)) + if c.__get(r, i)[i] # pylint: disable=protected-access + else list(range(0, 0x07)), + ), + DeviceInformation.EXTENDED: lambda c, r, i: c.__gets( # pylint: disable=protected-access + r, + [x for x in range(i, 0x100) if x not in range(0x07, 0x80)] + if c.__get(r, i)[i] # pylint: disable=protected-access + else [x for x in range(0, 0x100) if x not in range(0x07, 0x80)], + ), + DeviceInformation.SPECIFIC: lambda c, r, i: c.__get( # pylint: disable=protected-access + r, i + ), + } + + @classmethod + def get(cls, control, read_code=DeviceInformation.BASIC, object_id=0x00): + """Get the requested device data from the system. + + :param control: The control block to pull data from + :param read_code: The read code to process + :param object_id: The specific object_id to read + :returns: The requested data (id, length, value) + """ + identity = control.Identity + return cls.__lookup[read_code](cls, identity, object_id) + + @classmethod + def __get(cls, identity, object_id): # pylint: disable=unused-private-member + """Read a single object_id from the device information. + + :param identity: The identity block to pull data from + :param object_id: The specific object id to read + :returns: The requested data (id, length, value) + """ + return {object_id: identity[object_id]} + + @classmethod + def __gets(cls, identity, object_ids): # pylint: disable=unused-private-member + """Read multiple object_ids from the device information. + + :param identity: The identity block to pull data from + :param object_ids: The specific object ids to read + :returns: The requested data (id, length, value) + """ + return {oid: identity[oid] for oid in object_ids if identity[oid]} + + def __init__(self): + """Prohibit objects.""" + raise RuntimeError(INTERNAL_ERROR) + + +# ---------------------------------------------------------------------------# +# Counters Handler +# ---------------------------------------------------------------------------# +class ModbusCountersHandler: + """This is a helper class to simplify the properties for the counters. + + 0x0B 1 Return Bus Message Count + + Quantity of messages that the remote + device has detected on the communications system since its + last restart, clear counters operation, or power-up. Messages + with bad CRC are not taken into account. + + 0x0C 2 Return Bus Communication Error Count + + Quantity of CRC errors encountered by the remote device since its + last restart, clear counters operation, or power-up. In case of + an error detected on the character level, (overrun, parity error), + or in case of a message length < 3 bytes, the receiving device is + not able to calculate the CRC. In such cases, this counter is + also incremented. + + 0x0D 3 Return Slave Exception Error Count + + Quantity of MODBUS exception error detected by the remote device + since its last restart, clear counters operation, or power-up. + Exception errors are described and listed in "MODBUS Application + Protocol Specification" document. + + 0xOE 4 Return Slave Message Count + + Quantity of messages addressed to the remote device that the remote + device has processed since its last restart, clear counters operation, + or power-up. + + 0x0F 5 Return Slave No Response Count + + Quantity of messages received by the remote device for which it + returned no response (neither a normal response nor an exception + response), since its last restart, clear counters operation, or + power-up. + + 0x10 6 Return Slave NAK Count + + Quantity of messages addressed to the remote device for which it + returned a Negative Acknowledge (NAK) exception response, since + its last restart, clear counters operation, or power-up. Exception + responses are described and listed in "MODBUS Application Protocol + Specification" document. + + 0x11 7 Return Slave Busy Count + + Quantity of messages addressed to the remote device for which it + returned a Slave Device Busy exception response, since its last + restart, clear counters operation, or power-up. Exception + responses are described and listed in "MODBUS Application + Protocol Specification" document. + + 0x12 8 Return Bus Character Overrun Count + + Quantity of messages addressed to the remote device that it could + not handle due to a character overrun condition, since its last + restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster + than they can. + + .. note:: I threw the event counter in here for convenience + """ + + __data = {i: 0x0000 for i in range(9)} + __names = [ + "BusMessage", + "BusCommunicationError", + "SlaveExceptionError", + "SlaveMessage", + "SlaveNoResponse", + "SlaveNAK", + "SlaveBusy", + "BusCharacterOverrun", + ] + + def __iter__(self): + """Iterate over the device counters. + + :returns: An iterator of the device counters + """ + return zip(self.__names, iter(self.__data.values())) + + def update(self, values): + """Update the values of this identity. + + using another identify as the value + + :param values: The value to copy values from + """ + for k, v_item in iter(values.items()): + v_item += self.__getattribute__( # pylint: disable=unnecessary-dunder-call + k + ) + self.__setattr__(k, v_item) # pylint: disable=unnecessary-dunder-call + + def reset(self): + """Clear all of the system counters.""" + self.__data = {i: 0x0000 for i in range(9)} + + def summary(self): + """Return a summary of the counters current status. + + :returns: A byte with each bit representing each counter + """ + count, result = 0x01, 0x00 + for i in iter(self.__data.values()): + if i != 0x00: # pylint: disable=compare-to-zero + result |= count + count <<= 1 + return result + + # -------------------------------------------------------------------------# + # Properties + # -------------------------------------------------------------------------# + # fmt: off + BusMessage = dict_property(lambda s: s.__data, 0) # pylint: disable=protected-access + BusCommunicationError = dict_property(lambda s: s.__data, 1) # pylint: disable=protected-access + BusExceptionError = dict_property(lambda s: s.__data, 2) # pylint: disable=protected-access + SlaveMessage = dict_property(lambda s: s.__data, 3) # pylint: disable=protected-access + SlaveNoResponse = dict_property(lambda s: s.__data, 4) # pylint: disable=protected-access + SlaveNAK = dict_property(lambda s: s.__data, 5) # pylint: disable=protected-access + SlaveBusy = dict_property(lambda s: s.__data, 6) # pylint: disable=protected-access + BusCharacterOverrun = dict_property(lambda s: s.__data, 7) # pylint: disable=protected-access + Event = dict_property(lambda s: s.__data, 8) # pylint: disable=protected-access + # fmt: on + + +# ---------------------------------------------------------------------------# +# Main server control block +# ---------------------------------------------------------------------------# +class ModbusControlBlock: + """This is a global singleton that controls all system information. + + All activity should be logged here and all diagnostic requests + should come from here. + """ + + _mode = "ASCII" + _diagnostic = [False] * 16 + _listen_only = False + _delimiter = b"\r" + _counters = ModbusCountersHandler() + _identity = ModbusDeviceIdentification() + _plus = ModbusPlusStatistics() + _events: list[ModbusEvent] = [] + + # -------------------------------------------------------------------------# + # Magic + # -------------------------------------------------------------------------# + def __str__(self): + """Build a representation of the control block. + + :returns: A string representation of the control block + """ + return "ModbusControl" + + def __iter__(self): + """Iterate over the device counters. + + :returns: An iterator of the device counters + """ + return self._counters.__iter__() + + def __new__(cls): + """Create a new instance.""" + if "_inst" not in vars(cls): + cls._inst = object.__new__(cls) + return cls._inst + + # -------------------------------------------------------------------------# + # Events + # -------------------------------------------------------------------------# + def addEvent(self, event: ModbusEvent): + """Add a new event to the event log. + + :param event: A new event to add to the log + """ + self._events.insert(0, event) + self._events = self._events[0:64] # chomp to 64 entries + self.Counter.Event += 1 + + def getEvents(self): + """Return an encoded collection of the event log. + + :returns: The encoded events packet + """ + events = [event.encode() for event in self._events] + return b"".join(events) + + def clearEvents(self): + """Clear the current list of events.""" + self._events = [] + + # -------------------------------------------------------------------------# + # Other Properties + # -------------------------------------------------------------------------# + Identity = property(lambda s: s._identity) + Counter = property(lambda s: s._counters) + Events = property(lambda s: s._events) + Plus = property(lambda s: s._plus) + + def reset(self): + """Clear all of the system counters and the diagnostic register.""" + self._events = [] + self._counters.reset() + self._diagnostic = [False] * 16 + + # -------------------------------------------------------------------------# + # Listen Properties + # -------------------------------------------------------------------------# + def _setListenOnly(self, value): + """Toggle the listen only status. + + :param value: The value to set the listen status to + """ + self._listen_only = bool(value) + + ListenOnly = property(lambda s: s._listen_only, _setListenOnly) + + # -------------------------------------------------------------------------# + # Mode Properties + # -------------------------------------------------------------------------# + def _setMode(self, mode): + """Toggle the current serial mode. + + :param mode: The data transfer method in (RTU, ASCII) + """ + if mode in {"ASCII", "RTU"}: + self._mode = mode + + Mode = property(lambda s: s._mode, _setMode) + + # -------------------------------------------------------------------------# + # Delimiter Properties + # -------------------------------------------------------------------------# + def _setDelimiter(self, char): + """Change the serial delimiter character. + + :param char: The new serial delimiter character + """ + if isinstance(char, str): + self._delimiter = char.encode() + if isinstance(char, bytes): + self._delimiter = char + elif isinstance(char, int): + self._delimiter = struct.pack(">B", char) + + Delimiter = property(lambda s: s._delimiter, _setDelimiter) + + # -------------------------------------------------------------------------# + # Diagnostic Properties + # -------------------------------------------------------------------------# + def setDiagnostic(self, mapping): + """Set the value in the diagnostic register. + + :param mapping: Dictionary of key:value pairs to set + """ + for entry in iter(mapping.items()): + if entry[0] >= 0 and entry[0] < len(self._diagnostic): + self._diagnostic[entry[0]] = bool(entry[1]) + + def getDiagnostic(self, bit): + """Get the value in the diagnostic register. + + :param bit: The bit to get + :returns: The current value of the requested bit + """ + try: + if bit and 0 <= bit < len(self._diagnostic): + return self._diagnostic[bit] + except Exception: # pylint: disable=broad-except + return None + return None + + def getDiagnosticRegister(self): + """Get the entire diagnostic register. + + :returns: The diagnostic register collection + """ + return self._diagnostic diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/events.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/events.py new file mode 100644 index 00000000..aa576a07 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/events.py @@ -0,0 +1,201 @@ +"""Modbus Remote Events. + +An event byte returned by the Get Communications Event Log function +can be any one of four types. The type is defined by bit 7 +(the high-order bit) in each byte. It may be further defined by bit 6. +""" +# pylint: disable=missing-type-doc +from abc import ABC, abstractmethod + +from pymodbus.exceptions import ParameterException +from pymodbus.utilities import pack_bitstring, unpack_bitstring + + +class ModbusEvent(ABC): + """Define modbus events.""" + + @abstractmethod + def encode(self) -> bytes: + """Encode the status bits to an event message.""" + + @abstractmethod + def decode(self, event): + """Decode the event message to its status bits. + + :param event: The event to decode + """ + + +class RemoteReceiveEvent(ModbusEvent): + """Remote device MODBUS Receive Event. + + The remote device stores this type of event byte when a query message + is received. It is stored before the remote device processes the message. + This event is defined by bit 7 set to logic "1". The other bits will be + set to a logic "1" if the corresponding condition is TRUE. The bit layout + is:: + + Bit Contents + ---------------------------------- + 0 Not Used + 2 Not Used + 3 Not Used + 4 Character Overrun + 5 Currently in Listen Only Mode + 6 Broadcast Receive + 7 1 + """ + + def __init__(self, overrun=False, listen=False, broadcast=False): + """Initialize a new event instance.""" + self.overrun = overrun + self.listen = listen + self.broadcast = broadcast + + def encode(self) -> bytes: + """Encode the status bits to an event message. + + :returns: The encoded event message + """ + bits = [False] * 3 + bits += [self.overrun, self.listen, self.broadcast, True] + packet = pack_bitstring(bits) + return packet + + def decode(self, event: bytes) -> None: + """Decode the event message to its status bits. + + :param event: The event to decode + """ + bits = unpack_bitstring(event) + self.overrun = bits[4] + self.listen = bits[5] + self.broadcast = bits[6] + + +class RemoteSendEvent(ModbusEvent): + """Remote device MODBUS Send Event. + + The remote device stores this type of event byte when it finishes + processing a request message. It is stored if the remote device + returned a normal or exception response, or no response. + + This event is defined by bit 7 set to a logic "0", with bit 6 set to a "1". + The other bits will be set to a logic "1" if the corresponding + condition is TRUE. The bit layout is:: + + Bit Contents + ----------------------------------------------------------- + 0 Read Exception Sent (Exception Codes 1-3) + 1 Slave Abort Exception Sent (Exception Code 4) + 2 Slave Busy Exception Sent (Exception Codes 5-6) + 3 Slave Program NAK Exception Sent (Exception Code 7) + 4 Write Timeout Error Occurred + 5 Currently in Listen Only Mode + 6 1 + 7 0 + """ + + def __init__(self, read=False, slave_abort=False, slave_busy=False, slave_nak=False, write_timeout=False, listen=False): + """Initialize a new event instance.""" + self.read = read + self.slave_abort = slave_abort + self.slave_busy = slave_busy + self.slave_nak = slave_nak + self.write_timeout = write_timeout + self.listen = listen + + def encode(self): + """Encode the status bits to an event message. + + :returns: The encoded event message + """ + bits = [ + self.read, + self.slave_abort, + self.slave_busy, + self.slave_nak, + self.write_timeout, + self.listen, + ] + bits += [True, False] + packet = pack_bitstring(bits) + return packet + + def decode(self, event): + """Decode the event message to its status bits. + + :param event: The event to decode + """ + # todo fix the start byte count # pylint: disable=fixme + bits = unpack_bitstring(event) + self.read = bits[0] + self.slave_abort = bits[1] + self.slave_busy = bits[2] + self.slave_nak = bits[3] + self.write_timeout = bits[4] + self.listen = bits[5] + + +class EnteredListenModeEvent(ModbusEvent): + """Enter Remote device Listen Only Mode. + + The remote device stores this type of event byte when it enters + the Listen Only Mode. The event is defined by a content of 04 hex. + """ + + value = 0x04 + __encoded = b"\x04" + + def encode(self): + """Encode the status bits to an event message. + + :returns: The encoded event message + """ + return self.__encoded + + def decode(self, event): + """Decode the event message to its status bits. + + :param event: The event to decode + :raises ParameterException: + """ + if event != self.__encoded: + raise ParameterException("Invalid decoded value") + + +class CommunicationRestartEvent(ModbusEvent): + """Restart remote device Initiated Communication. + + The remote device stores this type of event byte when its communications + port is restarted. The remote device can be restarted by the Diagnostics + function (code 08), with sub-function Restart Communications Option + (code 00 01). + + That function also places the remote device into a "Continue on Error" + or "Stop on Error" mode. If the remote device is placed into "Continue on + Error" mode, the event byte is added to the existing event log. If the + remote device is placed into "Stop on Error" mode, the byte is added to + the log and the rest of the log is cleared to zeros. + + The event is defined by a content of zero. + """ + + value = 0x00 + __encoded = b"\x00" + + def encode(self): + """Encode the status bits to an event message. + + :returns: The encoded event message + """ + return self.__encoded + + def decode(self, event): + """Decode the event message to its status bits. + + :param event: The event to decode + :raises ParameterException: + """ + if event != self.__encoded: + raise ParameterException("Invalid decoded value") diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/exceptions.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/exceptions.py new file mode 100644 index 00000000..b3238c27 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/exceptions.py @@ -0,0 +1,116 @@ +"""Pymodbus Exceptions. + +Custom exceptions to be used in the Modbus code. +""" + +__all__ = [ + "ModbusIOException", + "ParameterException", + "NotImplementedException", + "ConnectionException", + "NoSuchSlaveException", + "InvalidMessageReceivedException", + "MessageRegisterException", +] + + +class ModbusException(Exception): + """Base modbus exception.""" + + def __init__(self, string): + """Initialize the exception. + + :param string: The message to append to the error + """ + self.string = string + super().__init__(string) + + def __str__(self): + """Return string representation.""" + return f"Modbus Error: {self.string}" + + def isError(self): + """Error""" + return True + + +class ModbusIOException(ModbusException): + """Error resulting from data i/o.""" + + def __init__(self, string="", function_code=None): + """Initialize the exception. + + :param string: The message to append to the error + """ + self.fcode = function_code + self.message = f"[Input/Output] {string}" + ModbusException.__init__(self, self.message) + + +class ParameterException(ModbusException): + """Error resulting from invalid parameter.""" + + def __init__(self, string=""): + """Initialize the exception. + + :param string: The message to append to the error + """ + message = f"[Invalid Parameter] {string}" + ModbusException.__init__(self, message) + + +class NoSuchSlaveException(ModbusException): + """Error resulting from making a request to a slave that does not exist.""" + + def __init__(self, string=""): + """Initialize the exception. + + :param string: The message to append to the error + """ + message = f"[No Such Slave] {string}" + ModbusException.__init__(self, message) + + +class NotImplementedException(ModbusException): + """Error resulting from not implemented function.""" + + def __init__(self, string=""): + """Initialize the exception. + + :param string: The message to append to the error + """ + message = f"[Not Implemented] {string}" + ModbusException.__init__(self, message) + + +class ConnectionException(ModbusException): + """Error resulting from a bad connection.""" + + def __init__(self, string=""): + """Initialize the exception. + + :param string: The message to append to the error + """ + message = f"[Connection] {string}" + ModbusException.__init__(self, message) + + +class InvalidMessageReceivedException(ModbusException): + """Error resulting from invalid response received or decoded.""" + + def __init__(self, string=""): + """Initialize the exception. + + :param string: The message to append to the error + """ + message = f"[Invalid Message] {string}" + ModbusException.__init__(self, message) + + +class MessageRegisterException(ModbusException): + """Error resulting from failing to register a custom message request/response.""" + + def __init__(self, string=""): + """Initialize.""" + message = f"[Error registering message] {string}" + ModbusException.__init__(self, message) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/__init__.py new file mode 100644 index 00000000..aa40ccc9 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/__init__.py @@ -0,0 +1,23 @@ +"""Framer.""" +__all__ = [ + "FramerBase", + "FramerType", + "FramerAscii", + "FramerRTU", + "FramerSocket", + "FramerTLS" +] + +from pymodbus.framer.ascii import FramerAscii +from pymodbus.framer.base import FramerBase, FramerType +from pymodbus.framer.rtu import FramerRTU +from pymodbus.framer.socket import FramerSocket +from pymodbus.framer.tls import FramerTLS + + +FRAMER_NAME_TO_CLASS = { + FramerType.ASCII: FramerAscii, + FramerType.RTU: FramerRTU, + FramerType.SOCKET: FramerSocket, + FramerType.TLS: FramerTLS, +} diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/ascii.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/ascii.py new file mode 100644 index 00000000..cc8a2f3a --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/ascii.py @@ -0,0 +1,86 @@ +"""ModbusMessage layer. + +is extending ModbusProtocol to handle receiving and sending of messsagees. + +ModbusMessage provides a unified interface to send/receive Modbus requests/responses. +""" +from __future__ import annotations + +from binascii import a2b_hex, b2a_hex + +from pymodbus.framer.base import FramerBase +from pymodbus.logging import Log + + +class FramerAscii(FramerBase): + r"""Modbus ASCII Frame Controller. + + Layout:: + [ Start ][ Dev id ][ Function ][ Data ][ LRC ][ End ] + 1c 2c 2c N*2c 1c 2c + + * data can be 1 - 2x252 chars + * end is "\\r\\n" (Carriage return line feed), however the line feed + character can be changed via a special command + * start is ":" + + This framer is used for serial transmission. Unlike the RTU protocol, + the data in this framer is transferred in plain text ascii. + """ + + START = b':' + END = b'\r\n' + MIN_SIZE = 10 + + + def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + """Decode ADU.""" + used_len = 0 + data_len = len(data) + while True: + if data_len - used_len < self.MIN_SIZE: + Log.debug("Short frame: {} wait for more data", data, ":hex") + return used_len, 0, 0, self.EMPTY + buffer = data[used_len:] + if buffer[0:1] != self.START: + if (i := buffer.find(self.START)) == -1: + Log.debug("No frame start in data: {}, wait for data", data, ":hex") + return data_len, 0, 0, self.EMPTY + used_len += i + continue + if (end := buffer.find(self.END)) == -1: + Log.debug("Incomplete frame: {} wait for more data", data, ":hex") + return used_len, 0, 0, self.EMPTY + dev_id = int(buffer[1:3], 16) + lrc = int(buffer[end - 2: end], 16) + msg = a2b_hex(buffer[1 : end - 2]) + used_len += end + 2 + if not self.check_LRC(msg, lrc): + Log.debug("LRC wrong in frame: {} skipping", data, ":hex") + continue + return used_len, dev_id, 0, msg[1:] + + def encode(self, data: bytes, device_id: int, _tid: int) -> bytes: + """Encode ADU.""" + dev_id = device_id.to_bytes(1,'big') + checksum = self.compute_LRC(dev_id + data) + frame = ( + self.START + + f"{device_id:02x}".encode() + + b2a_hex(data) + + f"{checksum:02x}".encode() + + self.END + ).upper() + return frame + + @classmethod + def compute_LRC(cls, data: bytes) -> int: + """Use to compute the longitudinal redundancy check against a string.""" + lrc = sum(int(a) for a in data) & 0xFF + lrc = (lrc ^ 0xFF) + 1 + return lrc & 0xFF + + @classmethod + def check_LRC(cls, data: bytes, check: int) -> bool: + """Check if the passed in data matches the LRC.""" + return cls.compute_LRC(data) == check diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/base.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/base.py new file mode 100644 index 00000000..ffdbf52c --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/base.py @@ -0,0 +1,103 @@ +"""Framer implementations. + +The implementation is responsible for encoding/decoding requests/responses. + +According to the selected type of modbus frame a prefix/suffix is added/removed +""" +from __future__ import annotations + +from enum import Enum + +from pymodbus.exceptions import ModbusIOException +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU, ModbusPDU + + +class FramerType(str, Enum): + """Type of Modbus frame.""" + + ASCII = "ascii" + RTU = "rtu" + SOCKET = "socket" + TLS = "tls" + + +class FramerBase: + """Intern base.""" + + EMPTY = b'' + MIN_SIZE = 0 + + def __init__( + self, + decoder: DecodePDU, + ) -> None: + """Initialize a ADU (framer) instance.""" + self.decoder = decoder + self.databuffer = b"" + + def decode(self, _data: bytes) -> tuple[int, int, int, bytes]: + """Decode ADU. + + returns: + used_len (int) or 0 to read more + dev_id, + tid, + modbus request/response (bytes) + """ + return 0, 0, 0, self.EMPTY + + def encode(self, data: bytes, _dev_id: int, _tid: int) -> bytes: + """Encode ADU. + + returns: + modbus ADU (bytes) + """ + return data + + def buildFrame(self, message: ModbusPDU) -> bytes: + """Create a ready to send modbus packet. + + :param message: The populated request/response to send + """ + data = message.function_code.to_bytes(1,'big') + message.encode() + frame = self.encode(data, message.slave_id, message.transaction_id) + return frame + + def processIncomingFrame(self, data: bytes) -> tuple[int, ModbusPDU | None]: + """Process new packet pattern. + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. + """ + used_len = 0 + while True: + data_len, pdu = self._processIncomingFrame(data[used_len:]) + used_len += data_len + if not data_len: + return used_len, None + if pdu: + return used_len, pdu + + def _processIncomingFrame(self, data: bytes) -> tuple[int, ModbusPDU | None]: + """Process new packet pattern. + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. + """ + Log.debug("Processing: {}", data, ":hex") + if not data: + return 0, None + used_len, dev_id, tid, frame_data = self.decode(data) + if not frame_data: + return used_len, None + if (result := self.decoder.decode(frame_data)) is None: + raise ModbusIOException("Unable to decode request") + result.slave_id = dev_id + result.transaction_id = tid + Log.debug("Frame advanced, resetting header!!") + return used_len, result diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/rtu.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/rtu.py new file mode 100644 index 00000000..76fe0767 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/rtu.py @@ -0,0 +1,159 @@ +"""Modbus RTU frame implementation.""" +from __future__ import annotations + +from pymodbus.framer.base import FramerBase +from pymodbus.logging import Log + + +class FramerRTU(FramerBase): + """Modbus RTU frame type. + + Layout:: + + [ Start Wait ] [Address ][ Function Code] [ Data ][ CRC ] + 3.5 chars 1b 1b Nb 2b + + .. note:: + + due to the USB converter and the OS drivers, timing cannot be quaranteed + neither when receiving nor when sending. + + Decoding is a complicated process because the RTU frame does not have a fixed prefix + only suffix, therefore it is necessary to decode the content (PDU) to get length etc. + There are some protocol restrictions that help with the detection. + + For client: + - a request causes 1 response ! + - Multiple requests are NOT allowed (master-slave protocol) + - the server will not retransmit responses + + this means decoding is always exactly 1 frame (response) + + For server (Single device) + - only 1 request allowed (master-slave) protocol + - the client (master) may retransmit but in larger time intervals + + this means decoding is always exactly 1 frame (request) + + For server (Multidrop line --> devices in parallel) + - only 1 request allowed (master-slave) protocol + - other devices will send responses + - the client (master) may retransmit but in larger time intervals + + this means decoding is always exactly 1 frame request, however some requests + will be for unknown slaves, which must be ignored together with the + response from the unknown slave. + + Recovery from bad cabling and unstable USB etc is important, + the following scenarios is possible: + + - garble data before frame + - garble data in frame + - garble data after frame + - data in frame garbled (wrong CRC) + + decoding assumes the frame is sound, and if not enters a hunting mode. + + The 3.5 byte transmission time at the slowest speed 1.200Bps is 31ms. + Device drivers will typically flush buffer after 10ms of silence. + If no data is received for 50ms the transmission / frame can be considered + complete. + + The following table is a listing of the baud wait times for the specified + baud rates:: + + ------------------------------------------------------------------ + Baud 1.5c (18 bits) 3.5c (38 bits) + ------------------------------------------------------------------ + 1200 13333.3 us 31666.7 us + 4800 3333.3 us 7916.7 us + 9600 1666.7 us 3958.3 us + 19200 833.3 us 1979.2 us + 38400 416.7 us 989.6 us + ------------------------------------------------------------------ + 1 Byte = start + 8 bits + parity + stop = 11 bits + (1/Baud)(bits) = delay seconds + """ + + MIN_SIZE = 4 # <slave id><function code><crc 2 bytes> + + @classmethod + def generate_crc16_table(cls) -> list[int]: + """Generate a crc16 lookup table. + + .. note:: This will only be generated once + """ + result = [] + for byte in range(256): + crc = 0x0000 + for _ in range(8): + if (byte ^ crc) & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + byte >>= 1 + result.append(crc) + return result + crc16_table: list[int] = [0] + + + def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + """Decode ADU.""" + data_len = len(data) + for used_len in range(data_len): + if data_len - used_len < self.MIN_SIZE: + Log.debug("Short frame: {} wait for more data", data, ":hex") + return used_len, 0, 0, self.EMPTY + dev_id = int(data[used_len]) + func_code = int(data[used_len + 1]) + if func_code & 0x7F not in self.decoder.lookup: + continue + pdu_class = self.decoder.lookupPduClass(func_code) + if not (size := pdu_class.calculateRtuFrameSize(data[used_len:])): + size = data_len +1 + if data_len < used_len +size: + Log.debug("Frame - not ready") + return used_len, dev_id, 0, self.EMPTY + start_crc = used_len + size -2 + crc = data[start_crc : start_crc + 2] + crc_val = (int(crc[0]) << 8) + int(crc[1]) + if not FramerRTU.check_CRC(data[used_len : start_crc], crc_val): + Log.debug("Frame check failed, ignoring!!") + continue + return start_crc + 2, dev_id, 0, data[used_len + 1 : start_crc] + return 0, 0, 0, self.EMPTY + + + def encode(self, pdu: bytes, device_id: int, _tid: int) -> bytes: + """Encode ADU.""" + frame = device_id.to_bytes(1,'big') + pdu + return frame + FramerRTU.compute_CRC(frame).to_bytes(2,'big') + + @classmethod + def check_CRC(cls, data: bytes, check: int) -> bool: + """Check if the data matches the passed in CRC. + + :param data: The data to create a crc16 of + :param check: The CRC to validate + :returns: True if matched, False otherwise + """ + return cls.compute_CRC(data) == check + + @classmethod + def compute_CRC(cls, data: bytes) -> int: + """Compute a crc16 on the passed in bytes. + + The difference between modbus's crc16 and a normal crc16 + is that modbus starts the crc value out at 0xffff. + + :param data: The data to create a crc16 of + :returns: The calculated CRC + """ + crc = 0xFFFF + for data_byte in data: + idx = cls.crc16_table[(crc ^ int(data_byte)) & 0xFF] + crc = ((crc >> 8) & 0xFF) ^ idx + swapped = ((crc << 8) & 0xFF00) | ((crc >> 8) & 0x00FF) + return swapped + +FramerRTU.crc16_table = FramerRTU.generate_crc16_table() diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/socket.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/socket.py new file mode 100644 index 00000000..eb261768 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/socket.py @@ -0,0 +1,46 @@ +"""Modbus Socket frame implementation.""" +from __future__ import annotations + +from pymodbus.framer.base import FramerBase +from pymodbus.logging import Log + + +class FramerSocket(FramerBase): + """Modbus Socket frame type. + + Layout:: + + [ MBAP Header ] [ Function Code] [ Data ] + [ tid ][ pid ][ length ][ uid ] + 2b 2b 2b 1b 1b Nb + + length = uid + function code + data + """ + + MIN_SIZE = 8 + + def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + """Decode ADU.""" + if (data_len := len(data)) < self.MIN_SIZE: + Log.debug("Very short frame (NO MBAP): {} wait for more data", data, ":hex") + return 0, 0, 0, self.EMPTY + tid = int.from_bytes(data[0:2], 'big') + msg_len = int.from_bytes(data[4:6], 'big') + 6 + dev_id = int(data[6]) + if data_len < msg_len: + Log.debug("Short frame: {} wait for more data", data, ":hex") + return 0, 0, 0, self.EMPTY + if msg_len == 8 and data_len == 9: + msg_len = 9 + return msg_len, dev_id, tid, data[7:msg_len] + + def encode(self, pdu: bytes, device_id: int, tid: int) -> bytes: + """Encode ADU.""" + frame = ( + tid.to_bytes(2, 'big') + + b'\x00\x00' + + (len(pdu) + 1).to_bytes(2, 'big') + + device_id.to_bytes(1, 'big') + + pdu + ) + return frame diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/tls.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/tls.py new file mode 100644 index 00000000..dd10f1f6 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/framer/tls.py @@ -0,0 +1,21 @@ +"""Modbus TLS frame implementation.""" +from __future__ import annotations + +from pymodbus.framer.base import FramerBase + + +class FramerTLS(FramerBase): + """Modbus TLS frame type. + + Layout:: + [ Function Code] [ Data ] + 1b Nb + """ + + def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + """Decode ADU.""" + return len(data), 0, 0, data + + def encode(self, pdu: bytes, _device_id: int, _tid: int) -> bytes: + """Encode ADU.""" + return pdu diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/logging.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/logging.py new file mode 100644 index 00000000..764eab21 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/logging.py @@ -0,0 +1,121 @@ +"""Pymodbus: Modbus Protocol Implementation. + +Released under the BSD license +""" +from __future__ import annotations + +import logging +from binascii import b2a_hex +from logging import NullHandler as __null + +from pymodbus.utilities import hexlify_packets + + +# ---------------------------------------------------------------------------# +# Block unhandled logging +# ---------------------------------------------------------------------------# +logging.getLogger("pymodbus_internal").addHandler(__null()) + + +def pymodbus_apply_logging_config( + level: str | int = logging.DEBUG, log_file_name: str | None = None +): + """Apply basic logging configuration used by default by Pymodbus maintainers. + + :param level: (optional) set log level, if not set it is inherited. + :param log_file_name: (optional) log additional to file + + Please call this function to format logging appropriately when opening issues. + """ + if isinstance(level, str): + level = level.upper() + Log.apply_logging_config(level, log_file_name) + + +class Log: + """Class to hide logging complexity. + + :meta private: + """ + + _logger = logging.getLogger(__name__) + + @classmethod + def apply_logging_config(cls, level, log_file_name): + """Apply basic logging configuration.""" + if level == logging.NOTSET: + level = cls._logger.getEffectiveLevel() + if isinstance(level, str): + level = level.upper() + log_stream_handler = logging.StreamHandler() + log_formatter = logging.Formatter( + "%(asctime)s %(levelname)-5s %(module)s:%(lineno)s %(message)s" + ) + log_stream_handler.setFormatter(log_formatter) + cls._logger.addHandler(log_stream_handler) + if log_file_name: + log_file_handler = logging.FileHandler(log_file_name) + log_file_handler.setFormatter(log_formatter) + cls._logger.addHandler(log_file_handler) + cls.setLevel(level) + + @classmethod + def setLevel(cls, level): + """Apply basic logging level.""" + cls._logger.setLevel(level) + + @classmethod + def build_msg(cls, txt, *args): + """Build message.""" + string_args = [] + count_args = len(args) - 1 + skip = False + for i in range(count_args + 1): + if skip: + skip = False + continue + if ( + i < count_args + and isinstance(args[i + 1], str) + and args[i + 1][0] == ":" + ): + if args[i + 1] == ":hex": + string_args.append(hexlify_packets(args[i])) + elif args[i + 1] == ":str": + string_args.append(str(args[i])) + elif args[i + 1] == ":b2a": + string_args.append(b2a_hex(args[i])) + skip = True + else: + string_args.append(args[i]) + return txt.format(*string_args) + + @classmethod + def info(cls, txt, *args): + """Log info messages.""" + if cls._logger.isEnabledFor(logging.INFO): + cls._logger.info(cls.build_msg(txt, *args), stacklevel=2) + + @classmethod + def debug(cls, txt, *args): + """Log debug messages.""" + if cls._logger.isEnabledFor(logging.DEBUG): + cls._logger.debug(cls.build_msg(txt, *args), stacklevel=2) + + @classmethod + def warning(cls, txt, *args): + """Log warning messages.""" + if cls._logger.isEnabledFor(logging.WARNING): + cls._logger.warning(cls.build_msg(txt, *args), stacklevel=2) + + @classmethod + def error(cls, txt, *args): + """Log error messages.""" + if cls._logger.isEnabledFor(logging.ERROR): + cls._logger.error(cls.build_msg(txt, *args), stacklevel=2) + + @classmethod + def critical(cls, txt, *args): + """Log critical messages.""" + if cls._logger.isEnabledFor(logging.CRITICAL): + cls._logger.critical(cls.build_msg(txt, *args), stacklevel=2) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/payload.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/payload.py new file mode 100644 index 00000000..b6ec8e1c --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/payload.py @@ -0,0 +1,452 @@ +"""Modbus Payload Builders. + +A collection of utilities for building and decoding +modbus messages payloads. +""" +from __future__ import annotations + + +__all__ = [ + "BinaryPayloadBuilder", + "BinaryPayloadDecoder", +] + +from array import array + +# pylint: disable=missing-type-doc +from struct import pack, unpack + +from pymodbus.constants import Endian +from pymodbus.exceptions import ParameterException +from pymodbus.logging import Log +from pymodbus.utilities import ( + pack_bitstring, + unpack_bitstring, +) + + +class BinaryPayloadBuilder: + """A utility that helps build payload messages to be written with the various modbus messages. + + It really is just a simple wrapper around the struct module, + however it saves time looking up the format strings. + What follows is a simple example:: + + builder = BinaryPayloadBuilder(byteorder=Endian.Little) + builder.add_8bit_uint(1) + builder.add_16bit_uint(2) + payload = builder.build() + """ + + def __init__( + self, payload=None, byteorder=Endian.LITTLE, wordorder=Endian.BIG, repack=False + ): + """Initialize a new instance of the payload builder. + + :param payload: Raw binary payload data to initialize with + :param byteorder: The endianness of the bytes in the words + :param wordorder: The endianness of the word (when wordcount is >= 2) + :param repack: Repack the provided payload based on BO + """ + self._payload = payload or [] + self._byteorder = byteorder + self._wordorder = wordorder + self._repack = repack + + def _pack_words(self, fstring: str, value) -> bytes: + """Pack words based on the word order and byte order. + + # ---------------------------------------------- # + # pack in to network ordered value # + # unpack in to network ordered unsigned integer # + # Change Word order if little endian word order # + # Pack values back based on correct byte order # + # ---------------------------------------------- # + + :param fstring: + :param value: Value to be packed + :return: + """ + value = pack(f"!{fstring}", value) + if Endian.LITTLE in {self._byteorder, self._wordorder}: + value = array("H", value) + if self._byteorder == Endian.LITTLE: + value.byteswap() + if self._wordorder == Endian.LITTLE: + value.reverse() + value = value.tobytes() + return value + + def encode(self) -> bytes: + """Get the payload buffer encoded in bytes.""" + return b"".join(self._payload) + + def __str__(self) -> str: + """Return the payload buffer as a string. + + :returns: The payload buffer as a string + """ + return self.encode().decode("utf-8") + + def reset(self) -> None: + """Reset the payload buffer.""" + self._payload = [] + + def to_registers(self): + """Convert the payload buffer to register layout that can be used as a context block. + + :returns: The register layout to use as a block + """ + # fstring = self._byteorder+"H" + fstring = "!H" + payload = self.build() + if self._repack: + payload = [unpack(self._byteorder + "H", value)[0] for value in payload] + else: + payload = [unpack(fstring, value)[0] for value in payload] + Log.debug("{}", payload) + return payload + + def to_coils(self) -> list[bool]: + """Convert the payload buffer into a coil layout that can be used as a context block. + + :returns: The coil layout to use as a block + """ + payload = self.to_registers() + coils = [bool(int(bit)) for reg in payload for bit in format(reg, "016b")] + return coils + + def build(self) -> list[bytes]: + """Return the payload buffer as a list. + + This list is two bytes per element and can + thus be treated as a list of registers. + + :returns: The payload buffer as a list + """ + buffer = self.encode() + length = len(buffer) + buffer += b"\x00" * (length % 2) + return [buffer[i : i + 2] for i in range(0, length, 2)] + + def add_bits(self, values: list[bool]) -> None: + """Add a collection of bits to be encoded. + + If these are less than a multiple of eight, + they will be left padded with 0 bits to make + it so. + + :param values: The value to add to the buffer + """ + value = pack_bitstring(values) + self._payload.append(value) + + def add_8bit_uint(self, value: int) -> None: + """Add a 8 bit unsigned int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + "B" + self._payload.append(pack(fstring, value)) + + def add_16bit_uint(self, value: int) -> None: + """Add a 16 bit unsigned int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + "H" + self._payload.append(pack(fstring, value)) + + def add_32bit_uint(self, value: int) -> None: + """Add a 32 bit unsigned int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "I" + # fstring = self._byteorder + "I" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_uint(self, value: int) -> None: + """Add a 64 bit unsigned int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "Q" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_8bit_int(self, value: int) -> None: + """Add a 8 bit signed int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + "b" + self._payload.append(pack(fstring, value)) + + def add_16bit_int(self, value: int) -> None: + """Add a 16 bit signed int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + "h" + self._payload.append(pack(fstring, value)) + + def add_32bit_int(self, value: int) -> None: + """Add a 32 bit signed int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "i" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_int(self, value: int) -> None: + """Add a 64 bit signed int to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "q" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_16bit_float(self, value: float) -> None: + """Add a 16 bit float to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "e" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_32bit_float(self, value: float) -> None: + """Add a 32 bit float to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "f" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_64bit_float(self, value: float) -> None: + """Add a 64 bit float(double) to the buffer. + + :param value: The value to add to the buffer + """ + fstring = "d" + p_string = self._pack_words(fstring, value) + self._payload.append(p_string) + + def add_string(self, value: str) -> None: + """Add a string to the buffer. + + :param value: The value to add to the buffer + """ + fstring = self._byteorder + str(len(value)) + "s" + self._payload.append(pack(fstring, value.encode())) + + +class BinaryPayloadDecoder: + """A utility that helps decode payload messages from a modbus response message. + + It really is just a simple wrapper around + the struct module, however it saves time looking up the format + strings. What follows is a simple example:: + + decoder = BinaryPayloadDecoder(payload) + first = decoder.decode_8bit_uint() + second = decoder.decode_16bit_uint() + """ + + def __init__(self, payload, byteorder=Endian.LITTLE, wordorder=Endian.BIG): + """Initialize a new payload decoder. + + :param payload: The payload to decode with + :param byteorder: The endianness of the payload + :param wordorder: The endianness of the word (when wordcount is >= 2) + """ + self._payload = payload + self._pointer = 0x00 + self._byteorder = byteorder + self._wordorder = wordorder + + @classmethod + def fromRegisters( + cls, + registers, + byteorder=Endian.LITTLE, + wordorder=Endian.BIG, + ): + """Initialize a payload decoder. + + With the result of reading a collection of registers from a modbus device. + + The registers are treated as a list of 2 byte values. + We have to do this because of how the data has already + been decoded by the rest of the library. + + :param registers: The register results to initialize with + :param byteorder: The Byte order of each word + :param wordorder: The endianness of the word (when wordcount is >= 2) + :returns: An initialized PayloadDecoder + :raises ParameterException: + """ + Log.debug("{}", registers) + if isinstance(registers, list): # repack into flat binary + payload = pack(f"!{len(registers)}H", *registers) + return cls(payload, byteorder, wordorder) + raise ParameterException("Invalid collection of registers supplied") + + @classmethod + def bit_chunks(cls, coils, size=8): + """Return bit chunks.""" + chunks = [coils[i : i + size] for i in range(0, len(coils), size)] + return chunks + + @classmethod + def fromCoils( + cls, + coils, + byteorder=Endian.LITTLE, + _wordorder=Endian.BIG, + ): + """Initialize a payload decoder with the result of reading of coils.""" + if isinstance(coils, list): + payload = b"" + if padding := len(coils) % 8: # Pad zeros + extra = [False] * padding + coils = extra + coils + chunks = cls.bit_chunks(coils) + for chunk in chunks: + payload += pack_bitstring(chunk[::-1]) + return cls(payload, byteorder) + raise ParameterException("Invalid collection of coils supplied") + + def _unpack_words(self, handle) -> bytes: + """Unpack words based on the word order and byte order. + + # ---------------------------------------------- # + # Unpack in to network ordered unsigned integer # + # Change Word order if little endian word order # + # Pack values back based on correct byte order # + # ---------------------------------------------- # + """ + if Endian.LITTLE in {self._byteorder, self._wordorder}: + handle = array("H", handle) + if self._byteorder == Endian.LITTLE: + handle.byteswap() + if self._wordorder == Endian.LITTLE: + handle.reverse() + handle = handle.tobytes() + Log.debug("handle: {}", handle) + return handle + + def reset(self): + """Reset the decoder pointer back to the start.""" + self._pointer = 0x00 + + def decode_8bit_uint(self): + """Decode a 8 bit unsigned int from the buffer.""" + self._pointer += 1 + fstring = self._byteorder + "B" + handle = self._payload[self._pointer - 1 : self._pointer] + return unpack(fstring, handle)[0] + + def decode_bits(self, package_len=1): + """Decode a byte worth of bits from the buffer.""" + self._pointer += package_len + # fstring = self._endian + "B" + handle = self._payload[self._pointer - 1 : self._pointer] + return unpack_bitstring(handle) + + def decode_16bit_uint(self): + """Decode a 16 bit unsigned int from the buffer.""" + self._pointer += 2 + fstring = self._byteorder + "H" + handle = self._payload[self._pointer - 2 : self._pointer] + return unpack(fstring, handle)[0] + + def decode_32bit_uint(self): + """Decode a 32 bit unsigned int from the buffer.""" + self._pointer += 4 + fstring = "I" + handle = self._payload[self._pointer - 4 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_64bit_uint(self): + """Decode a 64 bit unsigned int from the buffer.""" + self._pointer += 8 + fstring = "Q" + handle = self._payload[self._pointer - 8 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_8bit_int(self): + """Decode a 8 bit signed int from the buffer.""" + self._pointer += 1 + fstring = self._byteorder + "b" + handle = self._payload[self._pointer - 1 : self._pointer] + return unpack(fstring, handle)[0] + + def decode_16bit_int(self): + """Decode a 16 bit signed int from the buffer.""" + self._pointer += 2 + fstring = self._byteorder + "h" + handle = self._payload[self._pointer - 2 : self._pointer] + return unpack(fstring, handle)[0] + + def decode_32bit_int(self): + """Decode a 32 bit signed int from the buffer.""" + self._pointer += 4 + fstring = "i" + handle = self._payload[self._pointer - 4 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_64bit_int(self): + """Decode a 64 bit signed int from the buffer.""" + self._pointer += 8 + fstring = "q" + handle = self._payload[self._pointer - 8 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_16bit_float(self): + """Decode a 16 bit float from the buffer.""" + self._pointer += 2 + fstring = "e" + handle = self._payload[self._pointer - 2 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_32bit_float(self): + """Decode a 32 bit float from the buffer.""" + self._pointer += 4 + fstring = "f" + handle = self._payload[self._pointer - 4 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_64bit_float(self): + """Decode a 64 bit float(double) from the buffer.""" + self._pointer += 8 + fstring = "d" + handle = self._payload[self._pointer - 8 : self._pointer] + handle = self._unpack_words(handle) + return unpack("!" + fstring, handle)[0] + + def decode_string(self, size=1): + """Decode a string from the buffer. + + :param size: The size of the string to decode + """ + self._pointer += size + return self._payload[self._pointer - size : self._pointer] + + def skip_bytes(self, nbytes): + """Skip n bytes in the buffer. + + :param nbytes: The number of bytes to skip + """ + self._pointer += nbytes diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/__init__.py new file mode 100644 index 00000000..60ba42e5 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/__init__.py @@ -0,0 +1,11 @@ +"""Framer.""" +__all__ = [ + "DecodePDU", + "ExceptionResponse", + "ExceptionResponse", + "ModbusExceptions", + "ModbusPDU", +] + +from pymodbus.pdu.decoders import DecodePDU +from pymodbus.pdu.pdu import ExceptionResponse, ModbusExceptions, ModbusPDU diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_read_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_read_message.py new file mode 100644 index 00000000..e37a6985 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_read_message.py @@ -0,0 +1,258 @@ +"""Bit Reading Request/Response messages.""" + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.pdu.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ModbusPDU +from pymodbus.utilities import pack_bitstring, unpack_bitstring + + +class ReadBitsRequestBase(ModbusPDU): + """Base class for Messages Requesting bit values.""" + + _rtu_frame_size = 8 + + def __init__(self, address, count, slave, transaction, skip_encode): + """Initialize the read request data. + + :param address: The start address to read from + :param count: The number of bits after "address" to read + :param slave: Modbus slave slave ID + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.count = count + + def encode(self): + """Encode a request pdu. + + :returns: The encoded pdu + """ + return struct.pack(">HH", self.address, self.count) + + def decode(self, data): + """Decode a request pdu. + + :param data: The packet data to decode + """ + self.address, self.count = struct.unpack(">HH", data) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Byte Count(1 byte) + Quantity of Coils (n Bytes)/8, + if the remainder is different of 0 then N = N+1 + :return: + """ + count = self.count // 8 + if self.count % 8: # pragma: no cover + count += 1 + + return 1 + 1 + count + + def __str__(self): + """Return a string representation of the instance.""" + return f"ReadBitRequest({self.address},{self.count})" + + +class ReadBitsResponseBase(ModbusPDU): + """Base class for Messages responding to bit-reading values. + + The requested bits can be found in the .bits list. + """ + + _rtu_byte_count_pos = 2 + + def __init__(self, values, slave, transaction, skip_encode): + """Initialize a new instance. + + :param values: The requested values to be returned + :param slave: Modbus slave slave ID + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + + #: A list of booleans representing bit values + self.bits = values or [] + + def encode(self): + """Encode response pdu. + + :returns: The encoded packet message + """ + result = pack_bitstring(self.bits) + packet = struct.pack(">B", len(result)) + result + return packet + + def decode(self, data): + """Decode response pdu. + + :param data: The packet data to decode + """ + self.byte_count = int(data[0]) # pylint: disable=attribute-defined-outside-init + self.bits = unpack_bitstring(data[1:]) + + def setBit(self, address, value=1): + """Set the specified bit. + + :param address: The bit to set + :param value: The value to set the bit to + """ + self.bits[address] = bool(value) + + def resetBit(self, address): + """Set the specified bit to 0. + + :param address: The bit to reset + """ + self.setBit(address, 0) + + def getBit(self, address): + """Get the specified bit's value. + + :param address: The bit to query + :returns: The value of the requested bit + """ + return self.bits[address] + + def __str__(self): + """Return a string representation of the instance.""" + return f"{self.__class__.__name__}({len(self.bits)})" + + +class ReadCoilsRequest(ReadBitsRequestBase): + """This function code is used to read from 1 to 2000(0x7d0) contiguous status of coils in a remote device. + + The Request PDU specifies the starting + address, ie the address of the first coil specified, and the number of + coils. In the PDU Coils are addressed starting at zero. Therefore coils + numbered 1-16 are addressed as 0-15. + """ + + function_code = 1 + function_code_name = "read_coils" + + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The address to start reading from + :param count: The number of bits to read + :param slave: Modbus slave slave ID + """ + ReadBitsRequestBase.__init__(self, address, count, slave, transaction, skip_encode) + + async def update_datastore(self, context): # pragma: no cover + """Run a read coils request against a datastore. + + Before running the request, we make sure that the request is in + the max valid range (0x001-0x7d0). Next we make sure that the + request is valid against the current datastore. + + :param context: The datastore to request from + :returns: An initialized :py:class:`~pymodbus.register_read_message.ReadCoilsResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred + """ + if not (1 <= self.count <= 0x7D0): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = await context.async_getValues( + self.function_code, self.address, self.count + ) + return ReadCoilsResponse(values) + + +class ReadCoilsResponse(ReadBitsResponseBase): + """The coils in the response message are packed as one coil per bit of the data field. + + Status is indicated as 1= ON and 0= OFF. The LSB of the + first data byte contains the output addressed in the query. The other + coils follow toward the high order end of this byte, and from low order + to high order in subsequent bytes. + + If the returned output quantity is not a multiple of eight, the + remaining bits in the final data byte will be padded with zeros + (toward the high order end of the byte). The Byte Count field specifies + the quantity of complete bytes of data. + + The requested coils can be found in boolean form in the .bits list. + """ + + function_code = 1 + + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param values: The request values to respond with + :param slave: Modbus slave slave ID + """ + ReadBitsResponseBase.__init__(self, values, slave, transaction, skip_encode) + + +class ReadDiscreteInputsRequest(ReadBitsRequestBase): + """This function code is used to read from 1 to 2000(0x7d0). + + Contiguous status of discrete inputs in a remote device. The Request PDU specifies the + starting address, ie the address of the first input specified, and the + number of inputs. In the PDU Discrete Inputs are addressed starting at + zero. Therefore Discrete inputs numbered 1-16 are addressed as 0-15. + """ + + function_code = 2 + function_code_name = "read_discrete_input" + + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The address to start reading from + :param count: The number of bits to read + :param slave: Modbus slave slave ID + """ + ReadBitsRequestBase.__init__(self, address, count, slave, transaction, skip_encode) + + async def update_datastore(self, context): # pragma: no cover + """Run a read discrete input request against a datastore. + + Before running the request, we make sure that the request is in + the max valid range (0x001-0x7d0). Next we make sure that the + request is valid against the current datastore. + + :param context: The datastore to request from + :returns: An initialized :py:class:`~pymodbus.register_read_message.ReadDiscreteInputsResponse`, or an :py:class:`~pymodbus.pdu.ExceptionResponse` if an error occurred + """ + if not (1 <= self.count <= 0x7D0): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = await context.async_getValues( + self.function_code, self.address, self.count + ) + return ReadDiscreteInputsResponse(values) + + +class ReadDiscreteInputsResponse(ReadBitsResponseBase): + """The discrete inputs in the response message are packed as one input per bit of the data field. + + Status is indicated as 1= ON; 0= OFF. The LSB of + the first data byte contains the input addressed in the query. The other + inputs follow toward the high order end of this byte, and from low order + to high order in subsequent bytes. + + If the returned input quantity is not a multiple of eight, the + remaining bits in the final data byte will be padded with zeros + (toward the high order end of the byte). The Byte Count field specifies + the quantity of complete bytes of data. + + The requested coils can be found in boolean form in the .bits list. + """ + + function_code = 2 + + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param values: The request values to respond with + :param slave: Modbus slave slave ID + """ + ReadBitsResponseBase.__init__(self, values, slave, transaction, skip_encode) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_write_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_write_message.py new file mode 100644 index 00000000..ad995f9a --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/bit_write_message.py @@ -0,0 +1,273 @@ +"""Bit Writing Request/Response. + +TODO write mask request/response +""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.constants import ModbusStatus +from pymodbus.pdu.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ModbusPDU +from pymodbus.utilities import pack_bitstring, unpack_bitstring + + +# ---------------------------------------------------------------------------# +# Local Constants +# ---------------------------------------------------------------------------# +# These are defined in the spec to turn a coil on/off +# ---------------------------------------------------------------------------# +_turn_coil_on = struct.pack(">H", ModbusStatus.ON) +_turn_coil_off = struct.pack(">H", ModbusStatus.OFF) + + +class WriteSingleCoilRequest(ModbusPDU): + """This function code is used to write a single output to either ON or OFF in a remote device. + + The requested ON/OFF state is specified by a constant in the request + data field. A value of FF 00 hex requests the output to be ON. A value + of 00 00 requests it to be OFF. All other values are illegal and will + not affect the output. + + The Request PDU specifies the address of the coil to be forced. Coils + are addressed starting at zero. Therefore coil numbered 1 is addressed + as 0. The requested ON/OFF state is specified by a constant in the Coil + Value field. A value of 0XFF00 requests the coil to be ON. A value of + 0X0000 requests the coil to be off. All other values are illegal and + will not affect the coil. + """ + + function_code = 5 + function_code_name = "write_coil" + + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new instance. + + :param address: The variable address to write + :param value: The value to write at address + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.value = bool(value) + + def encode(self): + """Encode write coil request. + + :returns: The byte encoded message + """ + result = struct.pack(">H", self.address) + if self.value: # pragma: no cover + result += _turn_coil_on + else: + result += _turn_coil_off # pragma: no cover + return result + + def decode(self, data): + """Decode a write coil request. + + :param data: The packet data to decode + """ + self.address, value = struct.unpack(">HH", data) + self.value = value == ModbusStatus.ON + + async def update_datastore(self, context): # pragma: no cover + """Run a write coil request against a datastore. + + :param context: The datastore to request from + :returns: The populated response or exception message + """ + # if self.value not in [ModbusStatus.Off, ModbusStatus.On]: + # return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + + await context.async_setValues(self.function_code, self.address, [self.value]) + values = await context.async_getValues(self.function_code, self.address, 1) + return WriteSingleCoilResponse(self.address, values[0]) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Output Address (2 byte) + Output Value (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + """Return a string representation of the instance.""" + return f"WriteCoilRequest({self.address}, {self.value}) => " + + +class WriteSingleCoilResponse(ModbusPDU): + """The normal response is an echo of the request. + + Returned after the coil state has been written. + """ + + function_code = 5 + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The variable address written to + :param value: The value written at address + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.value = value + + def encode(self): + """Encode write coil response. + + :return: The byte encoded message + """ + result = struct.pack(">H", self.address) + if self.value: # pragma: no cover + result += _turn_coil_on + else: + result += _turn_coil_off # pragma: no cover + return result + + def decode(self, data): + """Decode a write coil response. + + :param data: The packet data to decode + """ + self.address, value = struct.unpack(">HH", data) + self.value = value == ModbusStatus.ON + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + return f"WriteCoilResponse({self.address}) => {self.value}" + + +class WriteMultipleCoilsRequest(ModbusPDU): + """This function code is used to forcea sequence of coils. + + To either ON or OFF in a remote device. The Request PDU specifies the coil + references to be forced. Coils are addressed starting at zero. Therefore + coil numbered 1 is addressed as 0. + + The requested ON/OFF states are specified by contents of the request + data field. A logical "1" in a bit position of the field requests the + corresponding output to be ON. A logical "0" requests it to be OFF." + """ + + function_code = 15 + function_code_name = "write_coils" + _rtu_byte_count_pos = 6 + + def __init__(self, address=0, values=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new instance. + + :param address: The starting request address + :param values: The values to write + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + if values is None: # pragma: no cover + values = [] + elif not hasattr(values, "__iter__"): + values = [values] + self.values = values + self.byte_count = (len(self.values) + 7) // 8 + + def encode(self): + """Encode write coils request. + + :returns: The byte encoded message + """ + count = len(self.values) + self.byte_count = (count + 7) // 8 + packet = struct.pack(">HHB", self.address, count, self.byte_count) + packet += pack_bitstring(self.values) + return packet + + def decode(self, data): + """Decode a write coils request. + + :param data: The packet data to decode + """ + self.address, count, self.byte_count = struct.unpack(">HHB", data[0:5]) + values = unpack_bitstring(data[5:]) + self.values = values[:count] + + async def update_datastore(self, context): # pragma: no cover + """Run a write coils request against a datastore. + + :param context: The datastore to request from + :returns: The populated response or exception message + """ + count = len(self.values) + if not 1 <= count <= 0x07B0: + return self.doException(merror.IllegalValue) + if self.byte_count != (count + 7) // 8: + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, count): + return self.doException(merror.IllegalAddress) + + await context.async_setValues( + self.function_code, self.address, self.values + ) + return WriteMultipleCoilsResponse(self.address, count) + + def __str__(self): + """Return a string representation of the instance.""" + return f"WriteNCoilRequest ({self.address}) => {len(self.values)}" + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Output Address (2 byte) + Quantity of Outputs (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + +class WriteMultipleCoilsResponse(ModbusPDU): + """The normal response returns the function code. + + Starting address, and quantity of coils forced. + """ + + function_code = 15 + _rtu_frame_size = 8 + + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The starting variable address written to + :param count: The number of values written + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.count = count + + def encode(self): + """Encode write coils response. + + :returns: The byte encoded message + """ + return struct.pack(">HH", self.address, self.count) + + def decode(self, data): + """Decode a write coils response. + + :param data: The packet data to decode + """ + self.address, self.count = struct.unpack(">HH", data) + + def __str__(self): + """Return a string representation of the instance.""" + return f"WriteNCoilResponse({self.address}, {self.count})" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/decoders.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/decoders.py new file mode 100644 index 00000000..6df645b8 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/decoders.py @@ -0,0 +1,113 @@ +"""Modbus Request/Response Decoders.""" +from __future__ import annotations + +import pymodbus.pdu.bit_read_message as bit_r_msg +import pymodbus.pdu.bit_write_message as bit_w_msg +import pymodbus.pdu.diag_message as diag_msg +import pymodbus.pdu.file_message as file_msg +import pymodbus.pdu.mei_message as mei_msg +import pymodbus.pdu.other_message as o_msg +import pymodbus.pdu.pdu as base +import pymodbus.pdu.register_read_message as reg_r_msg +import pymodbus.pdu.register_write_message as reg_w_msg +from pymodbus.exceptions import MessageRegisterException, ModbusException +from pymodbus.logging import Log + + +class DecodePDU: + """Decode pdu requests/responses (server/client).""" + + _pdu_class_table: set[tuple[type[base.ModbusPDU], type[base.ModbusPDU]]] = { + (reg_r_msg.ReadHoldingRegistersRequest, reg_r_msg.ReadHoldingRegistersResponse), + (bit_r_msg.ReadDiscreteInputsRequest, bit_r_msg.ReadDiscreteInputsResponse), + (reg_r_msg.ReadInputRegistersRequest, reg_r_msg.ReadInputRegistersResponse), + (bit_r_msg.ReadCoilsRequest, bit_r_msg.ReadCoilsResponse), + (bit_w_msg.WriteMultipleCoilsRequest, bit_w_msg.WriteMultipleCoilsResponse), + (reg_w_msg.WriteMultipleRegistersRequest, reg_w_msg.WriteMultipleRegistersResponse), + (reg_w_msg.WriteSingleRegisterRequest, reg_w_msg.WriteSingleRegisterResponse), + (bit_w_msg.WriteSingleCoilRequest, bit_w_msg.WriteSingleCoilResponse), + (reg_r_msg.ReadWriteMultipleRegistersRequest, reg_r_msg.ReadWriteMultipleRegistersResponse), + (diag_msg.DiagnosticStatusRequest, diag_msg.DiagnosticStatusResponse), + (o_msg.ReadExceptionStatusRequest, o_msg.ReadExceptionStatusResponse), + (o_msg.GetCommEventCounterRequest, o_msg.GetCommEventCounterResponse), + (o_msg.GetCommEventLogRequest, o_msg.GetCommEventLogResponse), + (o_msg.ReportSlaveIdRequest, o_msg.ReportSlaveIdResponse), + (file_msg.ReadFileRecordRequest, file_msg.ReadFileRecordResponse), + (file_msg.WriteFileRecordRequest, file_msg.WriteFileRecordResponse), + (reg_w_msg.MaskWriteRegisterRequest, reg_w_msg.MaskWriteRegisterResponse), + (file_msg.ReadFifoQueueRequest, file_msg.ReadFifoQueueResponse), + (mei_msg.ReadDeviceInformationRequest, mei_msg.ReadDeviceInformationResponse), + } + + _pdu_sub_class_table: set[tuple[type[base.ModbusPDU], type[base.ModbusPDU]]] = { + (diag_msg.ReturnQueryDataRequest, diag_msg.ReturnQueryDataResponse), + (diag_msg.RestartCommunicationsOptionRequest, diag_msg.RestartCommunicationsOptionResponse), + (diag_msg.ReturnDiagnosticRegisterRequest, diag_msg.ReturnDiagnosticRegisterResponse), + (diag_msg.ChangeAsciiInputDelimiterRequest, diag_msg.ChangeAsciiInputDelimiterResponse), + (diag_msg.ForceListenOnlyModeRequest, diag_msg.ForceListenOnlyModeResponse), + (diag_msg.ClearCountersRequest, diag_msg.ClearCountersResponse), + (diag_msg.ReturnBusMessageCountRequest, diag_msg.ReturnBusMessageCountResponse), + (diag_msg.ReturnBusCommunicationErrorCountRequest, diag_msg.ReturnBusCommunicationErrorCountResponse), + (diag_msg.ReturnBusExceptionErrorCountRequest, diag_msg.ReturnBusExceptionErrorCountResponse), + (diag_msg.ReturnSlaveMessageCountRequest, diag_msg.ReturnSlaveMessageCountResponse), + (diag_msg.ReturnSlaveNoResponseCountRequest, diag_msg.ReturnSlaveNoResponseCountResponse), + (diag_msg.ReturnSlaveNAKCountRequest, diag_msg.ReturnSlaveNAKCountResponse), + (diag_msg.ReturnSlaveBusyCountRequest, diag_msg.ReturnSlaveBusyCountResponse), + (diag_msg.ReturnSlaveBusCharacterOverrunCountRequest, diag_msg.ReturnSlaveBusCharacterOverrunCountResponse), + (diag_msg.ReturnIopOverrunCountRequest, diag_msg.ReturnIopOverrunCountResponse), + (diag_msg.ClearOverrunCountRequest, diag_msg.ClearOverrunCountResponse), + (diag_msg.GetClearModbusPlusRequest, diag_msg.GetClearModbusPlusResponse), + (mei_msg.ReadDeviceInformationRequest, mei_msg.ReadDeviceInformationResponse), + } + + def __init__(self, is_server: bool) -> None: + """Initialize function_tables.""" + inx = 0 if is_server else 1 + self.lookup: dict[int, type[base.ModbusPDU]] = {cl[inx].function_code: cl[inx] for cl in self._pdu_class_table} + self.sub_lookup: dict[int, dict[int, type[base.ModbusPDU]]] = {f: {} for f in self.lookup} + for f in self._pdu_sub_class_table: + self.sub_lookup[f[inx].function_code][f[inx].sub_function_code] = f[inx] + + def lookupPduClass(self, function_code: int) -> type[base.ModbusPDU]: + """Use `function_code` to determine the class of the PDU.""" + return self.lookup.get(function_code, base.ExceptionResponse) + + def register(self, custom_class: type[base.ModbusPDU]) -> None: + """Register a function and sub function class with the decoder.""" + if not issubclass(custom_class, base.ModbusPDU): + raise MessageRegisterException( + f'"{custom_class.__class__.__name__}" is Not a valid Modbus Message' + ". Class needs to be derived from " + "`pymodbus.pdu.ModbusPDU` " + ) + self.lookup[custom_class.function_code] = custom_class + if custom_class.sub_function_code >= 0: + if custom_class.function_code not in self.sub_lookup: + self.sub_lookup[custom_class.function_code] = {} + self.sub_lookup[custom_class.function_code][ + custom_class.sub_function_code + ] = custom_class + + def decode(self, frame: bytes) -> base.ModbusPDU | None: + """Decode a frame.""" + try: + if (function_code := int(frame[0])) > 0x80: + pdu_exp = base.ExceptionResponse(function_code & 0x7F) + pdu_exp.decode(frame[1:]) + return pdu_exp + if not (pdu_type := self.lookup.get(function_code, None)): + Log.debug("decode PDU failed for function code {}", function_code) + raise ModbusException(f"Unknown response {function_code}") + pdu = pdu_type() + pdu.setData(0, 0, False) + Log.debug("decode PDU for {}", function_code) + pdu.decode(frame[1:]) + + if pdu.sub_function_code >= 0: + lookup = self.sub_lookup.get(pdu.function_code, {}) + if subtype := lookup.get(pdu.sub_function_code, None): + pdu.__class__ = subtype + return pdu + except (ModbusException, ValueError, IndexError) as exc: + Log.warning("Unable to decode frame {}", exc) + return None diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/diag_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/diag_message.py new file mode 100644 index 00000000..3fa58033 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/diag_message.py @@ -0,0 +1,832 @@ +"""Diagnostic Record Read/Write. + +These need to be tied into a the current server context +or linked to the appropriate data +""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.constants import ModbusPlusOperation, ModbusStatus +from pymodbus.device import ModbusControlBlock +from pymodbus.exceptions import ModbusException, NotImplementedException +from pymodbus.pdu.pdu import ModbusPDU +from pymodbus.utilities import pack_bitstring + + +_MCB = ModbusControlBlock() + + +# ---------------------------------------------------------------------------# +# Diagnostic Function Codes Base Classes +# diagnostic 08, 00-18,20 +# ---------------------------------------------------------------------------# +# TODO Make sure all the data is decoded from the response # pylint: disable=fixme +# ---------------------------------------------------------------------------# +class DiagnosticStatusRequest(ModbusPDU): + """This is a base class for all of the diagnostic request functions.""" + + function_code = 0x08 + function_code_name = "diagnostic_status" + sub_function_code = 9999 + _rtu_frame_size = 8 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize a diagnostic request.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + self.message = None + + def encode(self): + """Encode a diagnostic response. + + we encode the data set in self.message + + :returns: The encoded packet + """ + packet = struct.pack(">H", self.sub_function_code) + if self.message is not None: + if isinstance(self.message, str): # pragma: no cover + packet += self.message.encode() + elif isinstance(self.message, bytes): + packet += self.message + elif isinstance(self.message, (list, tuple)): + for piece in self.message: + packet += struct.pack(">H", piece) + elif isinstance(self.message, int): # pragma: no cover + packet += struct.pack(">H", self.message) + return packet + + def decode(self, data): + """Decode a diagnostic request. + + :param data: The data to decode into the function code + """ + (self.sub_function_code, ) = struct.unpack(">H", data[:2]) + if self.sub_function_code == ReturnQueryDataRequest.sub_function_code: # pragma: no cover + self.message = data[2:] + else: + (self.message,) = struct.unpack(">H", data[2:]) # pragma: no cover + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Sub function code (2 byte) + Data (2 * N bytes) + :return: + """ + if not isinstance(self.message, list): + self.message = [self.message] + return 1 + 2 + 2 * len(self.message) + + +class DiagnosticStatusResponse(ModbusPDU): + """Diagnostic status. + + This is a base class for all of the diagnostic response functions + + It works by performing all of the encoding and decoding of variable + data and lets the higher classes define what extra data to append + and how to update_datastore a request + """ + + function_code = 0x08 + sub_function_code = 9999 + _rtu_frame_size = 8 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize a diagnostic response.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + self.message = None + + def encode(self): + """Encode diagnostic response. + + we encode the data set in self.message + + :returns: The encoded packet + """ + packet = struct.pack(">H", self.sub_function_code) + if self.message is not None: + if isinstance(self.message, str): # pragma: no cover + packet += self.message.encode() + elif isinstance(self.message, bytes): + packet += self.message + elif isinstance(self.message, (list, tuple)): + for piece in self.message: + packet += struct.pack(">H", piece) + elif isinstance(self.message, int): # pragma: no cover + packet += struct.pack(">H", self.message) + return packet + + def decode(self, data): + """Decode diagnostic response. + + :param data: The data to decode into the function code + """ + (self.sub_function_code, ) = struct.unpack(">H", data[:2]) + data = data[2:] + if self.sub_function_code == ReturnQueryDataRequest.sub_function_code: + self.message = data + else: + word_len = len(data) // 2 + if len(data) % 2: # pragma: no cover + word_len += 1 + data += b"0" + data = struct.unpack(">" + "H" * word_len, data) + self.message = data + + +class DiagnosticStatusSimpleRequest(DiagnosticStatusRequest): + """Return diagnostic status. + + A large majority of the diagnostic functions are simple + status request functions. They work by sending 0x0000 + as data and their function code and they are returned + 2 bytes of data. + + If a function inherits this, they only need to implement + the update_datastore method + """ + + def __init__(self, data=0x0000, slave=1, transaction=0, skip_encode=False): + """Initialize a simple diagnostic request. + + The data defaults to 0x0000 if not provided as over half + of the functions require it. + + :param data: The data to send along with the request + """ + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + self.message = data + + async def update_datastore(self, *args): # pragma: no cover + """Raise if not implemented.""" + raise NotImplementedException("Diagnostic Message Has No update_datastore Method") + + +class DiagnosticStatusSimpleResponse(DiagnosticStatusResponse): + """Diagnostic status. + + A large majority of the diagnostic functions are simple + status request functions. They work by sending 0x0000 + as data and their function code and they are returned + 2 bytes of data. + """ + + def __init__(self, data=0x0000, slave=1, transaction=0, skip_encode=False): + """Return a simple diagnostic response. + + :param data: The resulting data to return to the client + """ + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + self.message = data + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 00 +# ---------------------------------------------------------------------------# +class ReturnQueryDataRequest(DiagnosticStatusRequest): + """Return query data. + + The data passed in the request data field is to be returned (looped back) + in the response. The entire response message should be identical to the + request. + """ + + sub_function_code = 0x0000 + + def __init__(self, message=b"\x00\x00", slave=1, transaction=0, skip_encode=False): + """Initialize a new instance of the request. + + :param message: The message to send to loopback + """ + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + if not isinstance(message, bytes): # pragma: no cover + raise ModbusException(f"message({type(message)}) must be bytes") + self.message = message + + async def update_datastore(self, *_args): # pragma: no cover + """update_datastore the loopback request (builds the response). + + :returns: The populated loopback response message + """ + return ReturnQueryDataResponse(self.message) + + +class ReturnQueryDataResponse(DiagnosticStatusResponse): + """Return query data. + + The data passed in the request data field is to be returned (looped back) + in the response. The entire response message should be identical to the + request. + """ + + sub_function_code = 0x0000 + + def __init__(self, message=b"\x00\x00", slave=1, transaction=0, skip_encode=False): + """Initialize a new instance of the response. + + :param message: The message to loopback + """ + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + if not isinstance(message, bytes): # pragma: no cover + raise ModbusException(f"message({type(message)}) must be bytes") + self.message = message + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 01 +# ---------------------------------------------------------------------------# +class RestartCommunicationsOptionRequest(DiagnosticStatusRequest): + """Restart communication. + + The remote device serial line port must be initialized and restarted, and + all of its communications event counters are cleared. If the port is + currently in Listen Only Mode, no response is returned. This function is + the only one that brings the port out of Listen Only Mode. If the port is + not currently in Listen Only Mode, a normal response is returned. This + occurs before the restart is update_datastored. + """ + + sub_function_code = 0x0001 + + def __init__(self, toggle=False, slave=1, transaction=0, skip_encode=False): + """Initialize a new request. + + :param toggle: Set to True to toggle, False otherwise + """ + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + if toggle: # pragma: no cover + self.message = [ModbusStatus.ON] + else: + self.message = [ModbusStatus.OFF] # pragma: no cover + + async def update_datastore(self, *_args): # pragma: no cover + """Clear event log and restart. + + :returns: The initialized response message + """ + # if _MCB.ListenOnly: + return RestartCommunicationsOptionResponse(self.message) + + +class RestartCommunicationsOptionResponse(DiagnosticStatusResponse): + """Restart Communication. + + The remote device serial line port must be initialized and restarted, and + all of its communications event counters are cleared. If the port is + currently in Listen Only Mode, no response is returned. This function is + the only one that brings the port out of Listen Only Mode. If the port is + not currently in Listen Only Mode, a normal response is returned. This + occurs before the restart is update_datastored. + """ + + sub_function_code = 0x0001 + + def __init__(self, toggle=False, slave=1, transaction=0, skip_encode=False): + """Initialize a new response. + + :param toggle: Set to True if we toggled, False otherwise + """ + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + if toggle: # pragma: no cover + self.message = [ModbusStatus.ON] + else: + self.message = [ModbusStatus.OFF] # pragma: no cover + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 02 +# ---------------------------------------------------------------------------# +class ReturnDiagnosticRegisterRequest(DiagnosticStatusSimpleRequest): + """The contents of the remote device's 16-bit diagnostic register are returned in the response.""" + + sub_function_code = 0x0002 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + # if _MCB.isListenOnly(): + register = pack_bitstring(_MCB.getDiagnosticRegister()) + return ReturnDiagnosticRegisterResponse(register) + + +class ReturnDiagnosticRegisterResponse(DiagnosticStatusSimpleResponse): + """Return diagnostic register. + + The contents of the remote device's 16-bit diagnostic register are + returned in the response + """ + + sub_function_code = 0x0002 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 03 +# ---------------------------------------------------------------------------# +class ChangeAsciiInputDelimiterRequest(DiagnosticStatusSimpleRequest): + """Change ascii input delimiter. + + The character "CHAR" passed in the request data field becomes the end of + message delimiter for future messages (replacing the default LF + character). This function is useful in cases of a Line Feed is not + required at the end of ASCII messages. + """ + + sub_function_code = 0x0003 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + char = (self.message & 0xFF00) >> 8 # type: ignore[operator] + _MCB.Delimiter = char + return ChangeAsciiInputDelimiterResponse(self.message) + + +class ChangeAsciiInputDelimiterResponse(DiagnosticStatusSimpleResponse): + """Change ascii input delimiter. + + The character "CHAR" passed in the request data field becomes the end of + message delimiter for future messages (replacing the default LF + character). This function is useful in cases of a Line Feed is not + required at the end of ASCII messages. + """ + + sub_function_code = 0x0003 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 04 +# ---------------------------------------------------------------------------# +class ForceListenOnlyModeRequest(DiagnosticStatusSimpleRequest): + """Forces the addressed remote device to its Listen Only Mode for MODBUS communications. + + This isolates it from the other devices on the network, + allowing them to continue communicating without interruption from the + addressed remote device. No response is returned. + """ + + sub_function_code = 0x0004 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + _MCB.ListenOnly = True + return ForceListenOnlyModeResponse() + + +class ForceListenOnlyModeResponse(DiagnosticStatusResponse): + """Forces the addressed remote device to its Listen Only Mode for MODBUS communications. + + This isolates it from the other devices on the network, + allowing them to continue communicating without interruption from the + addressed remote device. No response is returned. + + This does not send a response + """ + + sub_function_code = 0x0004 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize to block a return response.""" + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) + self.message = [] + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 10 +# ---------------------------------------------------------------------------# +class ClearCountersRequest(DiagnosticStatusSimpleRequest): + """Clear ll counters and the diagnostic register. + + Also, counters are cleared upon power-up + """ + + sub_function_code = 0x000A + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + _MCB.reset() + return ClearCountersResponse(self.message) + + +class ClearCountersResponse(DiagnosticStatusSimpleResponse): + """Clear ll counters and the diagnostic register. + + Also, counters are cleared upon power-up + """ + + sub_function_code = 0x000A + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 11 +# ---------------------------------------------------------------------------# +class ReturnBusMessageCountRequest(DiagnosticStatusSimpleRequest): + """Return bus message count. + + The response data field returns the quantity of messages that the + remote device has detected on the communications systems since its last + restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000B + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.BusMessage + return ReturnBusMessageCountResponse(count) + + +class ReturnBusMessageCountResponse(DiagnosticStatusSimpleResponse): + """Return bus message count. + + The response data field returns the quantity of messages that the + remote device has detected on the communications systems since its last + restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000B + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 12 +# ---------------------------------------------------------------------------# +class ReturnBusCommunicationErrorCountRequest(DiagnosticStatusSimpleRequest): + """Return bus comm. count. + + The response data field returns the quantity of CRC errors encountered + by the remote device since its last restart, clear counter operation, or + power-up + """ + + sub_function_code = 0x000C + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.BusCommunicationError + return ReturnBusCommunicationErrorCountResponse(count) + + +class ReturnBusCommunicationErrorCountResponse(DiagnosticStatusSimpleResponse): + """Return bus comm. error. + + The response data field returns the quantity of CRC errors encountered + by the remote device since its last restart, clear counter operation, or + power-up + """ + + sub_function_code = 0x000C + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 13 +# ---------------------------------------------------------------------------# +class ReturnBusExceptionErrorCountRequest(DiagnosticStatusSimpleRequest): + """Return bus exception. + + The response data field returns the quantity of modbus exception + responses returned by the remote device since its last restart, + clear counters operation, or power-up + """ + + sub_function_code = 0x000D + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.BusExceptionError + return ReturnBusExceptionErrorCountResponse(count) + + +class ReturnBusExceptionErrorCountResponse(DiagnosticStatusSimpleResponse): + """Return bus exception. + + The response data field returns the quantity of modbus exception + responses returned by the remote device since its last restart, + clear counters operation, or power-up + """ + + sub_function_code = 0x000D + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 14 +# ---------------------------------------------------------------------------# +class ReturnSlaveMessageCountRequest(DiagnosticStatusSimpleRequest): + """Return slave message count. + + The response data field returns the quantity of messages addressed to the + remote device, that the remote device has processed since + its last restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000E + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.SlaveMessage + return ReturnSlaveMessageCountResponse(count) + + +class ReturnSlaveMessageCountResponse(DiagnosticStatusSimpleResponse): + """Return slave message count. + + The response data field returns the quantity of messages addressed to the + remote device, that the remote device has processed since + its last restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000E + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 15 +# ---------------------------------------------------------------------------# +class ReturnSlaveNoResponseCountRequest(DiagnosticStatusSimpleRequest): + """Return slave no response. + + The response data field returns the quantity of messages addressed to the + remote device, that the remote device has processed since + its last restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000F + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.SlaveNoResponse + return ReturnSlaveNoResponseCountResponse(count) + + +class ReturnSlaveNoResponseCountResponse(DiagnosticStatusSimpleResponse): + """Return slave no response. + + The response data field returns the quantity of messages addressed to the + remote device, that the remote device has processed since + its last restart, clear counters operation, or power-up + """ + + sub_function_code = 0x000F + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 16 +# ---------------------------------------------------------------------------# +class ReturnSlaveNAKCountRequest(DiagnosticStatusSimpleRequest): + """Return slave NAK count. + + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Negative Acknowledge (NAK) exception + response, since its last restart, clear counters operation, or power-up. + Exception responses are described and listed in section 7 . + """ + + sub_function_code = 0x0010 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.SlaveNAK + return ReturnSlaveNAKCountResponse(count) + + +class ReturnSlaveNAKCountResponse(DiagnosticStatusSimpleResponse): + """Return slave NAK. + + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Negative Acknowledge (NAK) exception + response, since its last restart, clear counters operation, or power-up. + Exception responses are described and listed in section 7. + """ + + sub_function_code = 0x0010 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 17 +# ---------------------------------------------------------------------------# +class ReturnSlaveBusyCountRequest(DiagnosticStatusSimpleRequest): + """Return slave busy count. + + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Slave Device Busy exception response, + since its last restart, clear counters operation, or power-up. + """ + + sub_function_code = 0x0011 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.SlaveBusy + return ReturnSlaveBusyCountResponse(count) + + +class ReturnSlaveBusyCountResponse(DiagnosticStatusSimpleResponse): + """Return slave busy count. + + The response data field returns the quantity of messages addressed to the + remote device for which it returned a Slave Device Busy exception response, + since its last restart, clear counters operation, or power-up. + """ + + sub_function_code = 0x0011 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 18 +# ---------------------------------------------------------------------------# +class ReturnSlaveBusCharacterOverrunCountRequest(DiagnosticStatusSimpleRequest): + """Return slave character overrun. + + The response data field returns the quantity of messages addressed to the + remote device that it could not handle due to a character overrun condition, + since its last restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster than they + can be stored, or by the loss of a character due to a hardware malfunction. + """ + + sub_function_code = 0x0012 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.BusCharacterOverrun + return ReturnSlaveBusCharacterOverrunCountResponse(count) + + +class ReturnSlaveBusCharacterOverrunCountResponse(DiagnosticStatusSimpleResponse): + """Return the quantity of messages addressed to the remote device unhandled due to a character overrun. + + Since its last restart, clear counters operation, or power-up. A character + overrun is caused by data characters arriving at the port faster than they + can be stored, or by the loss of a character due to a hardware malfunction. + """ + + sub_function_code = 0x0012 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 19 +# ---------------------------------------------------------------------------# +class ReturnIopOverrunCountRequest(DiagnosticStatusSimpleRequest): + """Return IopOverrun. + + An IOP overrun is caused by data characters arriving at the port + faster than they can be stored, or by the loss of a character due + to a hardware malfunction. This function is specific to the 884. + """ + + sub_function_code = 0x0013 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + count = _MCB.Counter.BusCharacterOverrun + return ReturnIopOverrunCountResponse(count) + + +class ReturnIopOverrunCountResponse(DiagnosticStatusSimpleResponse): + """Return Iop overrun count. + + The response data field returns the quantity of messages + addressed to the slave that it could not handle due to an 884 + IOP overrun condition, since its last restart, clear counters + operation, or power-up. + """ + + sub_function_code = 0x0013 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 20 +# ---------------------------------------------------------------------------# +class ClearOverrunCountRequest(DiagnosticStatusSimpleRequest): + """Clear the overrun error counter and reset the error flag. + + An error flag should be cleared, but nothing else in the + specification mentions is, so it is ignored. + """ + + sub_function_code = 0x0014 + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + _MCB.Counter.BusCharacterOverrun = 0x0000 + return ClearOverrunCountResponse(self.message) + + +class ClearOverrunCountResponse(DiagnosticStatusSimpleResponse): + """Clear the overrun error counter and reset the error flag.""" + + sub_function_code = 0x0014 + + +# ---------------------------------------------------------------------------# +# Diagnostic Sub Code 21 +# ---------------------------------------------------------------------------# +class GetClearModbusPlusRequest(DiagnosticStatusSimpleRequest): + """Get/Clear modbus plus request. + + In addition to the Function code (08) and Subfunction code + (00 15 hex) in the query, a two-byte Operation field is used + to specify either a "Get Statistics" or a "Clear Statistics" + operation. The two operations are exclusive - the "Get" + operation cannot clear the statistics, and the "Clear" + operation does not return statistics prior to clearing + them. Statistics are also cleared on power-up of the slave + device. + """ + + sub_function_code = 0x0015 + + def __init__(self, data=0, slave=1, transaction=0, skip_encode=False): + """Initialize.""" + super().__init__(slave=slave, transaction=transaction, skip_encode=skip_encode) + self.message=data + + def get_response_pdu_size(self): + """Return a series of 54 16-bit words (108 bytes) in the data field of the response. + + This function differs from the usual two-byte length of the data field. + The data contains the statistics for the Modbus Plus peer processor in the slave device. + Func_code (1 byte) + Sub function code (2 byte) + Operation (2 byte) + Data (108 bytes) + :return: + """ + if self.message == ModbusPlusOperation.GET_STATISTICS: # pragma: no cover + data = 2 + 108 # byte count(2) + data (54*2) + else: + data = 0 + return 1 + 2 + 2 + 2 + data + + async def update_datastore(self, *args): # pragma: no cover + """update_datastore the diagnostic request on the given device. + + :returns: The initialized response message + """ + message = None # the clear operation does not return info + if self.message == ModbusPlusOperation.CLEAR_STATISTICS: + _MCB.Plus.reset() + message = self.message + else: + message = [self.message] + message += _MCB.Plus.encode() + return GetClearModbusPlusResponse(message) + + def encode(self): + """Encode a diagnostic response. + + we encode the data set in self.message + + :returns: The encoded packet + """ + packet = struct.pack(">H", self.sub_function_code) + packet += struct.pack(">H", self.message) + return packet + + +class GetClearModbusPlusResponse(DiagnosticStatusSimpleResponse): + """Return a series of 54 16-bit words (108 bytes) in the data field of the response. + + This function differs from the usual two-byte length of the data field. + The data contains the statistics for the Modbus Plus peer processor in the slave device. + """ + + sub_function_code = 0x0015 diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/file_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/file_message.py new file mode 100644 index 00000000..16337579 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/file_message.py @@ -0,0 +1,437 @@ +"""File Record Read/Write Messages. + +Currently none of these messages are implemented +""" +from __future__ import annotations + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.pdu.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ModbusPDU + + +# ---------------------------------------------------------------------------# +# File Record Types +# ---------------------------------------------------------------------------# +class FileRecord: # pylint: disable=eq-without-hash + """Represents a file record and its relevant data.""" + + def __init__(self, reference_type=0x06, file_number=0x00, record_number=0x00, record_data=b'', record_length=None, response_length=None): + """Initialize a new instance. + + :params reference_type: must be 0x06 + :params file_number: Indicates which file number we are reading + :params record_number: Indicates which record in the file + :params record_data: The actual data of the record + :params record_length: The length in registers of the record + :params response_length: The length in bytes of the record + """ + self.reference_type = reference_type + self.file_number = file_number + self.record_number = record_number + self.record_data = record_data + + self.record_length = record_length if record_length else len(self.record_data) // 2 + self.response_length = response_length if response_length else len(self.record_data) + 1 + + def __eq__(self, relf): + """Compare the left object to the right.""" + return ( # pragma: no cover + self.reference_type == relf.reference_type + and self.file_number == relf.file_number + and self.record_number == relf.record_number + and self.record_length == relf.record_length + and self.record_data == relf.record_data + ) + + def __ne__(self, relf): + """Compare the left object to the right.""" + return not self.__eq__(relf) # pragma: no cover + + def __repr__(self): # pragma: no cover + """Give a representation of the file record.""" + params = (self.file_number, self.record_number, self.record_length) + return ( + "FileRecord(file=%d, record=%d, length=%d)" # pylint: disable=consider-using-f-string + % params + ) + + +# ---------------------------------------------------------------------------# +# File Requests/Responses +# ---------------------------------------------------------------------------# +class ReadFileRecordRequest(ModbusPDU): + """Read file record request. + + This function code is used to perform a file record read. All request + data lengths are provided in terms of number of bytes and all record + lengths are provided in terms of registers. + + A file is an organization of records. Each file contains 10000 records, + addressed 0000 to 9999 decimal or 0x0000 to 0x270f. For example, record + 12 is addressed as 12. The function can read multiple groups of + references. The groups can be separating (non-contiguous), but the + references within each group must be sequential. Each group is defined + in a separate "sub-request" field that contains seven bytes:: + + The reference type: 1 byte (must be 0x06) + The file number: 2 bytes + The starting record number within the file: 2 bytes + The length of the record to be read: 2 bytes + + The quantity of registers to be read, combined with all other fields + in the expected response, must not exceed the allowable length of the + MODBUS PDU: 235 bytes. + """ + + function_code = 0x14 + function_code_name = "read_file_record" + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param records: The file record requests to be read + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.records = records or [] + + def encode(self): + """Encode the request packet. + + :returns: The byte encoded packet + """ + packet = struct.pack("B", len(self.records) * 7) + for record in self.records: + packet += struct.pack( + ">BHHH", + 0x06, + record.file_number, + record.record_number, + record.record_length, + ) + return packet + + def decode(self, data): + """Decode the incoming request. + + :param data: The data to decode into the address + """ + self.records = [] + byte_count = int(data[0]) + for count in range(1, byte_count, 7): + decoded = struct.unpack(">BHHH", data[count : count + 7]) + record = FileRecord( + file_number=decoded[1], + record_number=decoded[2], + record_length=decoded[3], + ) + if decoded[0] == 0x06: # pragma: no cover + self.records.append(record) + + def update_datastore(self, _context): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + # TODO do some new context operation here # pylint: disable=fixme + # if file number, record number, or address + length + # is too big, return an error. + files: list[FileRecord] = [] + return ReadFileRecordResponse(files) + + +class ReadFileRecordResponse(ModbusPDU): + """Read file record response. + + The normal response is a series of "sub-responses," one for each + "sub-request." The byte count field is the total combined count of + bytes in all "sub-responses." In addition, each "sub-response" + contains a field that shows its own byte count. + """ + + function_code = 0x14 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param records: The requested file records + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.records = records or [] + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + total = sum(record.response_length + 1 for record in self.records) + packet = struct.pack("B", total) + for record in self.records: + packet += struct.pack(">BB", record.response_length, 0x06) + packet += record.record_data + return packet + + def decode(self, data): + """Decode the response. + + :param data: The packet data to decode + """ + count, self.records = 1, [] + byte_count = int(data[0]) + while count < byte_count: + response_length, reference_type = struct.unpack( + ">BB", data[count : count + 2] + ) + count += 2 + + record_length = response_length - 1 # response length includes the type byte + record = FileRecord( + response_length=response_length, + record_data=data[count : count + record_length], + ) + count += record_length + if reference_type == 0x06: # pragma: no cover + self.records.append(record) + + +class WriteFileRecordRequest(ModbusPDU): + """Write file record request. + + This function code is used to perform a file record write. All + request data lengths are provided in terms of number of bytes + and all record lengths are provided in terms of the number of 16 + bit words. + """ + + function_code = 0x15 + function_code_name = "write_file_record" + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param records: The file record requests to be read + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.records = records or [] + + def encode(self): + """Encode the request packet. + + :returns: The byte encoded packet + """ + total_length = sum((record.record_length * 2) + 7 for record in self.records) + packet = struct.pack("B", total_length) + + for record in self.records: + packet += struct.pack( + ">BHHH", + 0x06, + record.file_number, + record.record_number, + record.record_length, + ) + packet += record.record_data + return packet + + def decode(self, data): + """Decode the incoming request. + + :param data: The data to decode into the address + """ + byte_count = int(data[0]) + count, self.records = 1, [] + while count < byte_count: + decoded = struct.unpack(">BHHH", data[count : count + 7]) + response_length = decoded[3] * 2 + count += response_length + 7 + record = FileRecord( + record_length=decoded[3], + file_number=decoded[1], + record_number=decoded[2], + record_data=data[count - response_length : count], + ) + if decoded[0] == 0x06: # pragma: no cover + self.records.append(record) + + def update_datastore(self, _context): # pragma: no cover + """Run the write file record request against the context. + + :returns: The populated response + """ + # TODO do some new context operation here # pylint: disable=fixme + # if file number, record number, or address + length + # is too big, return an error. + return WriteFileRecordResponse(self.records) + + +class WriteFileRecordResponse(ModbusPDU): + """The normal response is an echo of the request.""" + + function_code = 0x15 + _rtu_byte_count_pos = 2 + + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param records: The file record requests to be read + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.records = records or [] + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + total_length = sum((record.record_length * 2) + 7 for record in self.records) + packet = struct.pack("B", total_length) + for record in self.records: + packet += struct.pack( + ">BHHH", + 0x06, + record.file_number, + record.record_number, + record.record_length, + ) + packet += record.record_data + return packet + + def decode(self, data): + """Decode the incoming request. + + :param data: The data to decode into the address + """ + count, self.records = 1, [] + byte_count = int(data[0]) + while count < byte_count: + decoded = struct.unpack(">BHHH", data[count : count + 7]) + response_length = decoded[3] * 2 + count += response_length + 7 + record = FileRecord( + record_length=decoded[3], + file_number=decoded[1], + record_number=decoded[2], + record_data=data[count - response_length : count], + ) + if decoded[0] == 0x06: # pragma: no cover + self.records.append(record) + + +class ReadFifoQueueRequest(ModbusPDU): + """Read fifo queue request. + + This function code allows to read the contents of a First-In-First-Out + (FIFO) queue of register in a remote device. The function returns a + count of the registers in the queue, followed by the queued data. + Up to 32 registers can be read: the count, plus up to 31 queued data + registers. + + The queue count register is returned first, followed by the queued data + registers. The function reads the queue contents, but does not clear + them. + """ + + function_code = 0x18 + function_code_name = "read_fifo_queue" + _rtu_frame_size = 6 + + def __init__(self, address=0x0000, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The fifo pointer address (0x0000 to 0xffff) + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.values = [] # this should be added to the context + + def encode(self): + """Encode the request packet. + + :returns: The byte encoded packet + """ + return struct.pack(">H", self.address) + + def decode(self, data): + """Decode the incoming request. + + :param data: The data to decode into the address + """ + self.address = struct.unpack(">H", data)[0] + + def update_datastore(self, _context): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + if not 0x0000 <= self.address <= 0xFFFF: + return self.doException(merror.IllegalValue) + if len(self.values) > 31: + return self.doException(merror.IllegalValue) + # TODO pull the values from some context # pylint: disable=fixme + return ReadFifoQueueResponse(self.values) + + +class ReadFifoQueueResponse(ModbusPDU): + """Read Fifo queue response. + + In a normal response, the byte count shows the quantity of bytes to + follow, including the queue count bytes and value register bytes + (but not including the error check field). The queue count is the + quantity of data registers in the queue (not including the count register). + + If the queue count exceeds 31, an exception response is returned with an + error code of 03 (Illegal Data Value). + """ + + function_code = 0x18 + + @classmethod + def calculateRtuFrameSize(cls, buffer): # pragma: no cover + """Calculate the size of the message. + + :param buffer: A buffer containing the data that have been received. + :returns: The number of bytes in the response. + """ + hi_byte = int(buffer[2]) + lo_byte = int(buffer[3]) + return (hi_byte << 16) + lo_byte + 6 + + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param values: The list of values of the fifo to return + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.values = values or [] + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + length = len(self.values) * 2 + packet = struct.pack(">HH", 2 + length, length) + for value in self.values: + packet += struct.pack(">H", value) + return packet + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + self.values = [] + _, count = struct.unpack(">HH", data[0:4]) + for index in range(0, count - 4): # pragma: no cover + idx = 4 + index * 2 + self.values.append(struct.unpack(">H", data[idx : idx + 2])[0]) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/mei_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/mei_message.py new file mode 100644 index 00000000..0085f23d --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/mei_message.py @@ -0,0 +1,215 @@ +"""Encapsulated Interface (MEI) Transport Messages.""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.constants import DeviceInformation, MoreData +from pymodbus.device import DeviceInformationFactory, ModbusControlBlock +from pymodbus.pdu.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ModbusPDU + + +_MCB = ModbusControlBlock() + + +class _OutOfSpaceException(Exception): + """Internal out of space exception.""" + + # This exception exists here as a simple, local way to manage response + # length control for the only MODBUS command which requires it under + # standard, non-error conditions. It and the structures associated with + # it should ideally be refactored and applied to all responses, however, + # since a Client can make requests which result in disallowed conditions, + # such as, for instance, requesting a register read of more registers + # than will fit in a single PDU. As per the specification, the PDU is + # restricted to 253 bytes, irrespective of the transport used. + # + # See Page 5/50 of MODBUS Application Protocol Specification V1.1b3. + + def __init__(self, oid): # pragma: no cover + self.oid = oid + super().__init__() + + +# ---------------------------------------------------------------------------# +# Read Device Information +# ---------------------------------------------------------------------------# +class ReadDeviceInformationRequest(ModbusPDU): + """Read device information. + + This function code allows reading the identification and additional + information relative to the physical and functional description of a + remote device, only. + + The Read Device Identification interface is modeled as an address space + composed of a set of addressable data elements. The data elements are + called objects and an object Id identifies them. + """ + + function_code = 0x2B + sub_function_code = 0x0E + function_code_name = "read_device_information" + _rtu_frame_size = 7 + + def __init__(self, read_code=None, object_id=0x00, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param read_code: The device information read code + :param object_id: The object to read from + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.read_code = read_code or DeviceInformation.BASIC + self.object_id = object_id + + def encode(self): + """Encode the request packet. + + :returns: The byte encoded packet + """ + packet = struct.pack( + ">BBB", self.sub_function_code, self.read_code, self.object_id + ) + return packet + + def decode(self, data): + """Decode data part of the message. + + :param data: The incoming data + """ + params = struct.unpack(">BBB", data) + self.sub_function_code, self.read_code, self.object_id = params + + async def update_datastore(self, _context): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + if not 0x00 <= self.object_id <= 0xFF: + return self.doException(merror.IllegalValue) + if not 0x00 <= self.read_code <= 0x04: + return self.doException(merror.IllegalValue) + + information = DeviceInformationFactory.get(_MCB, self.read_code, self.object_id) + return ReadDeviceInformationResponse(self.read_code, information) + + def __str__(self): + """Build a representation of the request. + + :returns: The string representation of the request + """ + params = (self.read_code, self.object_id) + return ( + "ReadDeviceInformationRequest(%d,%d)" # pylint: disable=consider-using-f-string + % params + ) + + +class ReadDeviceInformationResponse(ModbusPDU): + """Read device information response.""" + + function_code = 0x2B + sub_function_code = 0x0E + + @classmethod + def calculateRtuFrameSize(cls, buffer): # pragma: no cover + """Calculate the size of the message. + + :param buffer: A buffer containing the data that have been received. + :returns: The number of bytes in the response. + """ + size = 8 # skip the header information + count = int(buffer[7]) + + try: + while count > 0: + _, object_length = struct.unpack(">BB", buffer[size : size + 2]) + size += object_length + 2 + count -= 1 + return size + 2 + except struct.error as exc: + raise IndexError from exc + + def __init__(self, read_code=None, information=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param read_code: The device information read code + :param information: The requested information request + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.read_code = read_code or DeviceInformation.BASIC + self.information = information or {} + self.number_of_objects = 0 + self.conformity = 0x83 # I support everything right now + self.next_object_id = 0x00 + self.more_follows = MoreData.NOTHING + self.space_left = 253 - 6 + + def _encode_object(self, object_id, data): # pragma: no cover + """Encode object.""" + self.space_left -= 2 + len(data) + if self.space_left <= 0: + raise _OutOfSpaceException(object_id) + encoded_obj = struct.pack(">BB", object_id, len(data)) + if isinstance(data, bytes): + encoded_obj += data + else: + encoded_obj += data.encode() + self.number_of_objects += 1 + return encoded_obj + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + packet = struct.pack( + ">BBB", self.sub_function_code, self.read_code, self.conformity + ) + objects = b"" + try: # pragma: no cover + for object_id, data in iter(self.information.items()): + if isinstance(data, list): + for item in data: + objects += self._encode_object(object_id, item) + else: + objects += self._encode_object(object_id, data) + except _OutOfSpaceException as exc: # pragma: no cover + self.next_object_id = exc.oid + self.more_follows = MoreData.KEEP_READING + + packet += struct.pack( + ">BBB", self.more_follows, self.next_object_id, self.number_of_objects + ) + packet += objects + return packet + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + params = struct.unpack(">BBBBBB", data[0:6]) + self.sub_function_code, self.read_code = params[0:2] + self.conformity, self.more_follows = params[2:4] + self.next_object_id, self.number_of_objects = params[4:6] + self.information, count = {}, 6 # skip the header information + + while count < len(data): + object_id, object_length = struct.unpack(">BB", data[count : count + 2]) + count += object_length + 2 + if object_id not in self.information: # pragma: no cover + self.information[object_id] = data[count - object_length : count] + elif isinstance(self.information[object_id], list): # pragma: no cover + self.information[object_id].append(data[count - object_length : count]) + else: + self.information[object_id] = [ # pragma: no cover + self.information[object_id], + data[count - object_length : count], + ] + + def __str__(self): + """Build a representation of the response.""" + return f"ReadDeviceInformationResponse({self.read_code})" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/other_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/other_message.py new file mode 100644 index 00000000..597a37bd --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/other_message.py @@ -0,0 +1,469 @@ +"""Diagnostic record read/write. + +Currently not all implemented +""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.constants import ModbusStatus +from pymodbus.device import DeviceInformationFactory, ModbusControlBlock +from pymodbus.pdu.pdu import ModbusPDU + + +_MCB = ModbusControlBlock() + + +# ---------------------------------------------------------------------------# +# TODO Make these only work on serial # pylint: disable=fixme +# ---------------------------------------------------------------------------# +class ReadExceptionStatusRequest(ModbusPDU): + """This function code is used to read the contents of eight Exception Status outputs in a remote device. + + The function provides a simple method for + accessing this information, because the Exception Output references are + known (no output reference is needed in the function). + """ + + function_code = 0x07 + function_code_name = "read_exception_status" + _rtu_frame_size = 4 + + def __init__(self, slave=None, transaction=0, skip_encode=0): + """Initialize a new instance.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + + def encode(self): + """Encode the message.""" + return b"" + + def decode(self, data): + """Decode data part of the message. + + :param data: The incoming data + """ + + async def update_datastore(self, _context=None): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + status = _MCB.Counter.summary() + return ReadExceptionStatusResponse(status) + + def __str__(self): + """Build a representation of the request.""" + return f"ReadExceptionStatusRequest({self.function_code})" + + +class ReadExceptionStatusResponse(ModbusPDU): + """The normal response contains the status of the eight Exception Status outputs. + + The outputs are packed into one data byte, with one bit + per output. The status of the lowest output reference is contained + in the least significant bit of the byte. The contents of the eight + Exception Status outputs are device specific. + """ + + function_code = 0x07 + _rtu_frame_size = 5 + + def __init__(self, status=0x00, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param status: The status response to report + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.status = status if status < 256 else 255 + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + return struct.pack(">B", self.status) + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + self.status = int(data[0]) + + def __str__(self): + """Build a representation of the response.""" + arguments = (self.function_code, self.status) + return ( + "ReadExceptionStatusResponse(%d, %s)" # pylint: disable=consider-using-f-string + % arguments + ) + + +# Encapsulate interface transport 43, 14 +# CANopen general reference 43, 13 + + +# ---------------------------------------------------------------------------# +# TODO Make these only work on serial # pylint: disable=fixme +# ---------------------------------------------------------------------------# +class GetCommEventCounterRequest(ModbusPDU): + """This function code is used to get a status word. + + And an event count from the remote device's communication event counter. + + By fetching the current count before and after a series of messages, a + client can determine whether the messages were handled normally by the + remote device. + + The device's event counter is incremented once for each successful + message completion. It is not incremented for exception responses, + poll commands, or fetch event counter commands. + + The event counter can be reset by means of the Diagnostics function + (code 08), with a subfunction of Restart Communications Option + (code 00 01) or Clear Counters and Diagnostic Register (code 00 0A). + """ + + function_code = 0x0B + function_code_name = "get_event_counter" + _rtu_frame_size = 4 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + + def encode(self): + """Encode the message.""" + return b"" + + def decode(self, data): + """Decode data part of the message. + + :param data: The incoming data + """ + + async def update_datastore(self, _context=None): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + status = _MCB.Counter.Event + return GetCommEventCounterResponse(status) + + def __str__(self): + """Build a representation of the request.""" + return f"GetCommEventCounterRequest({self.function_code})" + + +class GetCommEventCounterResponse(ModbusPDU): + """Get comm event counter response. + + The normal response contains a two-byte status word, and a two-byte + event count. The status word will be all ones (FF FF hex) if a + previously-issued program command is still being processed by the + remote device (a busy condition exists). Otherwise, the status word + will be all zeros. + """ + + function_code = 0x0B + _rtu_frame_size = 8 + + def __init__(self, count=0x0000, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param count: The current event counter value + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.count = count + self.status = True # this means we are ready, not waiting + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + if self.status: # pragma: no cover + ready = ModbusStatus.READY + else: + ready = ModbusStatus.WAITING # pragma: no cover + return struct.pack(">HH", ready, self.count) + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + ready, self.count = struct.unpack(">HH", data) + self.status = ready == ModbusStatus.READY + + def __str__(self): + """Build a representation of the response.""" + arguments = (self.function_code, self.count, self.status) + return ( + "GetCommEventCounterResponse(%d, %d, %d)" # pylint: disable=consider-using-f-string + % arguments + ) + + +# ---------------------------------------------------------------------------# +# TODO Make these only work on serial # pylint: disable=fixme +# ---------------------------------------------------------------------------# +class GetCommEventLogRequest(ModbusPDU): + """This function code is used to get a status word. + + Event count, message count, and a field of event bytes from the remote device. + + The status word and event counts are identical to that returned by + the Get Communications Event Counter function (11, 0B hex). + + The message counter contains the quantity of messages processed by the + remote device since its last restart, clear counters operation, or + power-up. This count is identical to that returned by the Diagnostic + function (code 08), sub-function Return Bus Message Count (code 11, + 0B hex). + + The event bytes field contains 0-64 bytes, with each byte corresponding + to the status of one MODBUS send or receive operation for the remote + device. The remote device enters the events into the field in + chronological order. Byte 0 is the most recent event. Each new byte + flushes the oldest byte from the field. + """ + + function_code = 0x0C + function_code_name = "get_event_log" + _rtu_frame_size = 4 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + + def encode(self): + """Encode the message.""" + return b"" + + def decode(self, data): + """Decode data part of the message. + + :param data: The incoming data + """ + + async def update_datastore(self, _context=None): # pragma: no cover + """Run a read exception status request against the store. + + :returns: The populated response + """ + results = { + "status": True, + "message_count": _MCB.Counter.BusMessage, + "event_count": _MCB.Counter.Event, + "events": _MCB.getEvents(), + } + return GetCommEventLogResponse(**results) + + def __str__(self): + """Build a representation of the request. + + :returns: The string representation of the request + """ + return f"GetCommEventLogRequest({self.function_code})" + + +class GetCommEventLogResponse(ModbusPDU): + """Get Comm event log response. + + The normal response contains a two-byte status word field, + a two-byte event count field, a two-byte message count field, + and a field containing 0-64 bytes of events. A byte count + field defines the total length of the data in these four field + """ + + function_code = 0x0C + _rtu_byte_count_pos = 2 + + def __init__(self, status=True, message_count=0, event_count=0, events=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param status: The status response to report + :param message_count: The current message count + :param event_count: The current event count + :param events: The collection of events to send + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.status = status + self.message_count = message_count + self.event_count = event_count + self.events = events if events else [] + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + if self.status: # pragma: no cover + ready = ModbusStatus.READY + else: + ready = ModbusStatus.WAITING # pragma: no cover + packet = struct.pack(">B", 6 + len(self.events)) + packet += struct.pack(">H", ready) + packet += struct.pack(">HH", self.event_count, self.message_count) + packet += b"".join(struct.pack(">B", e) for e in self.events) + return packet + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + length = int(data[0]) + status = struct.unpack(">H", data[1:3])[0] + self.status = status == ModbusStatus.READY + self.event_count = struct.unpack(">H", data[3:5])[0] + self.message_count = struct.unpack(">H", data[5:7])[0] + + self.events = [] + for i in range(7, length + 1): + self.events.append(int(data[i])) + + def __str__(self): + """Build a representation of the response. + + :returns: The string representation of the response + """ + arguments = ( + self.function_code, + self.status, + self.message_count, + self.event_count, + ) + return ( + "GetCommEventLogResponse(%d, %d, %d, %d)" # pylint: disable=consider-using-f-string + % arguments + ) + + +# ---------------------------------------------------------------------------# +# TODO Make these only work on serial # pylint: disable=fixme +# ---------------------------------------------------------------------------# +class ReportSlaveIdRequest(ModbusPDU): + """This function code is used to read the description of the type. + + The current status, and other information specific to a remote device. + """ + + function_code = 0x11 + function_code_name = "report_slave_id" + _rtu_frame_size = 4 + + def __init__(self, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param slave: Modbus slave slave ID + + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + + def encode(self): + """Encode the message.""" + return b"" + + def decode(self, data): + """Decode data part of the message. + + :param data: The incoming data + """ + + async def update_datastore(self, context=None): # pragma: no cover + """Run a report slave id request against the store. + + :returns: The populated response + """ + report_slave_id_data = None + if context: + report_slave_id_data = getattr(context, "reportSlaveIdData", None) + if not report_slave_id_data: + information = DeviceInformationFactory.get(_MCB) + + # Support identity values as bytes data and regular str data + id_data = [] + for v_item in information.values(): + if isinstance(v_item, bytes): + id_data.append(v_item) + else: + id_data.append(v_item.encode()) + + identifier = b"-".join(id_data) + identifier = identifier or b"Pymodbus" + report_slave_id_data = identifier + return ReportSlaveIdResponse(report_slave_id_data) + + def __str__(self): + """Build a representation of the request. + + :returns: The string representation of the request + """ + return f"ReportSlaveIdRequest({self.function_code})" + + +class ReportSlaveIdResponse(ModbusPDU): + """Show response. + + The data contents are specific to each type of device. + """ + + function_code = 0x11 + _rtu_byte_count_pos = 2 + + def __init__(self, identifier=b"\x00", status=True, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param identifier: The identifier of the slave + :param status: The status response to report + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.identifier = identifier + self.status = status + self.byte_count = None + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + if self.status: # pragma: no cover + status = ModbusStatus.SLAVE_ON + else: + status = ModbusStatus.SLAVE_OFF # pragma: no cover + length = len(self.identifier) + 1 + packet = struct.pack(">B", length) + packet += self.identifier # we assume it is already encoded + packet += struct.pack(">B", status) + return packet + + def decode(self, data): + """Decode a the response. + + Since the identifier is device dependent, we just return the + raw value that a user can decode to whatever it should be. + + :param data: The packet data to decode + """ + self.byte_count = int(data[0]) + self.identifier = data[1 : self.byte_count + 1] + status = int(data[-1]) + self.status = status == ModbusStatus.SLAVE_ON + + def __str__(self) -> str: + """Build a representation of the response. + + :returns: The string representation of the response + """ + return f"ReportSlaveIdResponse({self.function_code}, {self.identifier}, {self.status})" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/pdu.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/pdu.py new file mode 100644 index 00000000..5a07014e --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/pdu.py @@ -0,0 +1,128 @@ +"""Contains base classes for modbus request/response/error packets.""" +from __future__ import annotations + +import asyncio +import struct +from abc import abstractmethod + +from pymodbus.exceptions import NotImplementedException +from pymodbus.logging import Log + + +class ModbusPDU: + """Base class for all Modbus messages.""" + + function_code: int = 0 + sub_function_code: int = -1 + _rtu_frame_size: int = 0 + _rtu_byte_count_pos: int = 0 + + def __init__(self) -> None: + """Initialize the base data for a modbus request.""" + self.transaction_id: int + self.slave_id: int + self.skip_encode: bool + self.bits: list[bool] + self.registers: list[int] + self.fut: asyncio.Future + + def setData(self, slave: int, transaction: int, skip_encode: bool) -> None: + """Set data common for all PDU.""" + self.transaction_id = transaction + self.slave_id = slave + self.skip_encode = skip_encode + + def doException(self, exception: int) -> ExceptionResponse: + """Build an error response based on the function.""" + exc = ExceptionResponse(self.function_code, exception) + Log.error("Exception response {}", exc) + return exc + + def isError(self) -> bool: + """Check if the error is a success or failure.""" + return self.function_code > 0x80 + + def get_response_pdu_size(self) -> int: + """Calculate response pdu size.""" + return 0 + + @abstractmethod + def encode(self) -> bytes: + """Encode the message.""" + + @abstractmethod + def decode(self, data: bytes) -> None: + """Decode data part of the message.""" + + + @classmethod + def calculateRtuFrameSize(cls, data: bytes) -> int: + """Calculate the size of a PDU.""" + if cls._rtu_frame_size: + return cls._rtu_frame_size + if cls._rtu_byte_count_pos: + if len(data) < cls._rtu_byte_count_pos +1: + return 0 + return int(data[cls._rtu_byte_count_pos]) + cls._rtu_byte_count_pos + 3 + raise NotImplementedException( + f"Cannot determine RTU frame size for {cls.__name__}" + ) + + +class ModbusExceptions: # pylint: disable=too-few-public-methods + """An enumeration of the valid modbus exceptions.""" + + IllegalFunction = 0x01 + IllegalAddress = 0x02 + IllegalValue = 0x03 + SlaveFailure = 0x04 + Acknowledge = 0x05 + SlaveBusy = 0x06 + NegativeAcknowledge = 0x07 + MemoryParityError = 0x08 + GatewayPathUnavailable = 0x0A + GatewayNoResponse = 0x0B + + @classmethod + def decode(cls, code: int) -> str | None: + """Give an error code, translate it to a string error name.""" + values = { + v: k + for k, v in iter(cls.__dict__.items()) + if not k.startswith("__") and not callable(v) + } + return values.get(code, None) + + +class ExceptionResponse(ModbusPDU): + """Base class for a modbus exception PDU.""" + + _rtu_frame_size = 5 + + def __init__( + self, + function_code: int, + exception_code: int = 0, + slave: int = 1, + transaction: int = 0, + skip_encode: bool = False) -> None: + """Initialize the modbus exception response.""" + super().__init__() + super().setData(slave, transaction, skip_encode) + self.function_code = function_code | 0x80 + self.exception_code = exception_code + + def encode(self) -> bytes: + """Encode a modbus exception response.""" + return struct.pack(">B", self.exception_code) + + def decode(self, data: bytes) -> None: + """Decode a modbus exception response.""" + self.exception_code = int(data[0]) + + def __str__(self) -> str: + """Build a representation of an exception response.""" + message = ModbusExceptions.decode(self.exception_code) + return ( + f"Exception Response({self.function_code}, {self.function_code - 0x80}, {message})" + ) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_read_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_read_message.py new file mode 100644 index 00000000..524f0509 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_read_message.py @@ -0,0 +1,376 @@ +"""Register Reading Request/Response.""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.exceptions import ModbusIOException +from pymodbus.pdu.pdu import ExceptionResponse, ModbusPDU +from pymodbus.pdu.pdu import ModbusExceptions as merror + + +class ReadRegistersRequestBase(ModbusPDU): + """Base class for reading a modbus register.""" + + _rtu_frame_size = 8 + + def __init__(self, address, count, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The address to start the read from + :param count: The number of registers to read + :param slave: Modbus slave slave ID + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.count = count + + def encode(self): + """Encode the request packet. + + :return: The encoded packet + """ + return struct.pack(">HH", self.address, self.count) + + def decode(self, data): + """Decode a register request packet. + + :param data: The request to decode + """ + self.address, self.count = struct.unpack(">HH", data) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes). + """ + return 1 + 1 + 2 * self.count + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + return f"{self.__class__.__name__} ({self.address},{self.count})" + + +class ReadRegistersResponseBase(ModbusPDU): + """Base class for responding to a modbus register read. + + The requested registers can be found in the .registers list. + """ + + _rtu_byte_count_pos = 2 + + def __init__(self, values, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param values: The values to write to + :param slave: Modbus slave slave ID + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + + #: A list of register values + self.registers = values or [] + + def encode(self): + """Encode the response packet. + + :returns: The encoded packet + """ + result = struct.pack(">B", len(self.registers) * 2) + for register in self.registers: + result += struct.pack(">H", register) + return result + + def decode(self, data): + """Decode a register response packet. + + :param data: The request to decode + """ + byte_count = int(data[0]) + if byte_count < 2 or byte_count > 252 or byte_count % 2 == 1 or byte_count != len(data) - 1: # pragma: no cover + raise ModbusIOException(f"Invalid response {data} has byte count of {byte_count}") # pragma: no cover + self.registers = [] + for i in range(1, byte_count + 1, 2): + self.registers.append(struct.unpack(">H", data[i : i + 2])[0]) + + def getRegister(self, index): + """Get the requested register. + + :param index: The indexed register to retrieve + :returns: The request register + """ + return self.registers[index] # pragma: no cover + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + return f"{self.__class__.__name__} ({len(self.registers)})" + + +class ReadHoldingRegistersRequest(ReadRegistersRequestBase): + """Read holding registers. + + This function code is used to read the contents of a contiguous block + of holding registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore registers numbered + 1-16 are addressed as 0-15. + """ + + function_code = 3 + function_code_name = "read_holding_registers" + + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=0): + """Initialize a new instance of the request. + + :param address: The starting address to read from + :param count: The number of registers to read from address + :param slave: Modbus slave slave ID + """ + super().__init__(address, count, slave, transaction, skip_encode) + + async def update_datastore(self, context): # pragma: no cover + """Run a read holding request against a datastore. + + :param context: The datastore to request from + :returns: An initialized :py:class:`~pymodbus.register_read_message.ReadHoldingRegistersResponse` + """ + if not (1 <= self.count <= 0x7D): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = await context.async_getValues( + self.function_code, self.address, self.count + ) + if isinstance(values, ExceptionResponse): + return values + return ReadHoldingRegistersResponse(values) + + +class ReadHoldingRegistersResponse(ReadRegistersResponseBase): + """Read holding registers. + + This function code is used to read the contents of a contiguous block + of holding registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore registers numbered + 1-16 are addressed as 0-15. + + The requested registers can be found in the .registers list. + """ + + function_code = 3 + + def __init__(self, values=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new response instance. + + :param values: The resulting register values + """ + super().__init__(values, slave, transaction, skip_encode) + + +class ReadInputRegistersRequest(ReadRegistersRequestBase): + """Read input registers. + + This function code is used to read from 1 to approx. 125 contiguous + input registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore input registers + numbered 1-16 are addressed as 0-15. + """ + + function_code = 4 + function_code_name = "read_input_registers" + + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=0): + """Initialize a new instance of the request. + + :param address: The starting address to read from + :param count: The number of registers to read from address + :param slave: Modbus slave slave ID + """ + super().__init__(address, count, slave, transaction, skip_encode) + + async def update_datastore(self, context): # pragma: no cover + """Run a read input request against a datastore. + + :param context: The datastore to request from + :returns: An initialized :py:class:`~pymodbus.register_read_message.ReadInputRegistersResponse` + """ + if not (1 <= self.count <= 0x7D): + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + values = await context.async_getValues( + self.function_code, self.address, self.count + ) + if isinstance(values, ExceptionResponse): + return values + return ReadInputRegistersResponse(values) + + +class ReadInputRegistersResponse(ReadRegistersResponseBase): + """Read/write input registers. + + This function code is used to read from 1 to approx. 125 contiguous + input registers in a remote device. The Request PDU specifies the + starting register address and the number of registers. In the PDU + Registers are addressed starting at zero. Therefore input registers + numbered 1-16 are addressed as 0-15. + + The requested registers can be found in the .registers list. + """ + + function_code = 4 + + def __init__(self, values=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new response instance. + + :param values: The resulting register values + """ + super().__init__(values, slave, transaction, skip_encode) + + +class ReadWriteMultipleRegistersRequest(ModbusPDU): + """Read/write multiple registers. + + This function code performs a combination of one read operation and one + write operation in a single MODBUS transaction. The write + operation is performed before the read. + + Holding registers are addressed starting at zero. Therefore holding + registers 1-16 are addressed in the PDU as 0-15. + + The request specifies the starting address and number of holding + registers to be read as well as the starting address, number of holding + registers, and the data to be written. The byte count specifies the + number of bytes to follow in the write data field." + """ + + function_code = 23 + function_code_name = "read_write_multiple_registers" + _rtu_byte_count_pos = 10 + + def __init__(self, read_address=0x00, read_count=0, write_address=0x00, write_registers=None, slave=1, transaction=0, skip_encode=False): + """Initialize a new request message. + + :param read_address: The address to start reading from + :param read_count: The number of registers to read from address + :param write_address: The address to start writing to + :param write_registers: The registers to write to the specified address + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.read_address = read_address + self.read_count = read_count + self.write_address = write_address + self.write_registers = write_registers + if not hasattr(self.write_registers, "__iter__"): + self.write_registers = [self.write_registers] + self.write_count = len(self.write_registers) + self.write_byte_count = self.write_count * 2 + + def encode(self): + """Encode the request packet. + + :returns: The encoded packet + """ + result = struct.pack( + ">HHHHB", + self.read_address, + self.read_count, + self.write_address, + self.write_count, + self.write_byte_count, + ) + for register in self.write_registers: + result += struct.pack(">H", register) + return result + + def decode(self, data): + """Decode the register request packet. + + :param data: The request to decode + """ + ( + self.read_address, + self.read_count, + self.write_address, + self.write_count, + self.write_byte_count, + ) = struct.unpack(">HHHHB", data[:9]) + self.write_registers = [] + for i in range(9, self.write_byte_count + 9, 2): + register = struct.unpack(">H", data[i : i + 2])[0] + self.write_registers.append(register) + + async def update_datastore(self, context): # pragma: no cover + """Run a write single register request against a datastore. + + :param context: The datastore to request from + :returns: An initialized :py:class:`~pymodbus.register_read_message.ReadWriteMultipleRegistersResponse` + """ + if not (1 <= self.read_count <= 0x07D): + return self.doException(merror.IllegalValue) + if not 1 <= self.write_count <= 0x079: + return self.doException(merror.IllegalValue) + if self.write_byte_count != self.write_count * 2: + return self.doException(merror.IllegalValue) + if not context.validate( + self.function_code, self.write_address, self.write_count + ): + return self.doException(merror.IllegalAddress) + if not context.validate(self.function_code, self.read_address, self.read_count): + return self.doException(merror.IllegalAddress) + await context.async_setValues( + self.function_code, self.write_address, self.write_registers + ) + registers = await context.async_getValues( + self.function_code, self.read_address, self.read_count + ) + if isinstance(registers, ExceptionResponse): + return registers + return ReadWriteMultipleRegistersResponse(registers) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Byte Count(1 byte) + 2 * Quantity of Coils (n Bytes) + :return: + """ + return 1 + 1 + 2 * self.read_count + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + params = ( + self.read_address, + self.read_count, + self.write_address, + self.write_count, + ) + return ( + "ReadWriteNRegisterRequest R(%d,%d) W(%d,%d)" # pylint: disable=consider-using-f-string + % params + ) + + +class ReadWriteMultipleRegistersResponse(ReadHoldingRegistersResponse): + """Read/write multiple registers. + + The normal response contains the data from the group of registers that + were read. The byte count field specifies the quantity of bytes to + follow in the read data field. + + The requested registers can be found in the .registers list. + """ + + function_code = 23 diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_write_message.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_write_message.py new file mode 100644 index 00000000..bb94bad1 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/pdu/register_write_message.py @@ -0,0 +1,378 @@ +"""Register Writing Request/Response Messages.""" + + +# pylint: disable=missing-type-doc +import struct + +from pymodbus.pdu.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ModbusPDU + + +class WriteSingleRegisterRequest(ModbusPDU): + """This function code is used to write a single holding register in a remote device. + + The Request PDU specifies the address of the register to + be written. Registers are addressed starting at zero. Therefore register + numbered 1 is addressed as 0. + """ + + function_code = 6 + function_code_name = "write_register" + _rtu_frame_size = 8 + + def __init__(self, address=None, value=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new instance. + + :param address: The address to start writing add + :param value: The values to write + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.value = value + + def encode(self): + """Encode a write single register packet packet request. + + :returns: The encoded packet + """ + packet = struct.pack(">H", self.address) + if self.skip_encode or isinstance(self.value, bytes): # pragma: no cover + packet += self.value # pragma: no cover + else: + packet += struct.pack(">H", self.value) + return packet + + def decode(self, data): + """Decode a write single register packet packet request. + + :param data: The request to decode + """ + self.address, self.value = struct.unpack(">HH", data) + + async def update_datastore(self, context): # pragma: no cover + """Run a write single register request against a datastore. + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + """ + if not 0 <= self.value <= 0xFFFF: + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + + await context.async_setValues( + self.function_code, self.address, [self.value] + ) + values = await context.async_getValues(self.function_code, self.address, 1) + return WriteSingleRegisterResponse(self.address, values[0]) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Register Address(2 byte) + Register Value (2 bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + return f"WriteRegisterRequest {self.address}" + + +class WriteSingleRegisterResponse(ModbusPDU): + """The normal response is an echo of the request. + + Returned after the register contents have been written. + """ + + function_code = 6 + _rtu_frame_size = 8 + + def __init__(self, address=0, value=0, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The address to start writing add + :param value: The values to write + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.value = value + + def encode(self): + """Encode a write single register packet packet request. + + :returns: The encoded packet + """ + return struct.pack(">HH", self.address, self.value) + + def decode(self, data): + """Decode a write single register packet packet request. + + :param data: The request to decode + """ + self.address, self.value = struct.unpack(">HH", data) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Starting Address (2 byte) + And_mask (2 Bytes) + OrMask (2 Bytes) + :return: + """ + return 1 + 2 + 2 + 2 + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + params = (self.address, self.value) + return ( + "WriteRegisterResponse %d => %d" # pylint: disable=consider-using-f-string + % params + ) + + +# ---------------------------------------------------------------------------# +# Write Multiple Registers +# ---------------------------------------------------------------------------# +class WriteMultipleRegistersRequest(ModbusPDU): + """This function code is used to write a block. + + Of contiguous registers (1 to approx. 120 registers) in a remote device. + + The requested written values are specified in the request data field. + Data is packed as two bytes per register. + """ + + function_code = 16 + function_code_name = "write_registers" + _rtu_byte_count_pos = 6 + _pdu_length = 5 # func + adress1 + adress2 + outputQuant1 + outputQuant2 + + def __init__(self, address=0, values=None, slave=None, transaction=0, skip_encode=0): + """Initialize a new instance. + + :param address: The address to start writing to + :param values: The values to write + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + if values is None: + values = [] + elif not hasattr(values, "__iter__"): + values = [values] + self.values = values + self.count = len(self.values) + self.byte_count = self.count * 2 + + def encode(self): + """Encode a write single register packet packet request. + + :returns: The encoded packet + """ + packet = struct.pack(">HHB", self.address, self.count, self.byte_count) + if self.skip_encode: # pragma: no cover + return packet + b"".join(self.values) # pragma: no cover + + for value in self.values: + if isinstance(value, bytes): # pragma: no cover + packet += value # pragma: no cover + else: + packet += struct.pack(">H", value) + + return packet + + def decode(self, data): + """Decode a write single register packet packet request. + + :param data: The request to decode + """ + self.address, self.count, self.byte_count = struct.unpack(">HHB", data[:5]) + self.values = [] # reset + for idx in range(5, (self.count * 2) + 5, 2): + self.values.append(struct.unpack(">H", data[idx : idx + 2])[0]) + + async def update_datastore(self, context): # pragma: no cover + """Run a write single register request against a datastore. + + :param context: The datastore to request from + :returns: An initialized response, exception message otherwise + """ + if not 1 <= self.count <= 0x07B: + return self.doException(merror.IllegalValue) + if self.byte_count != self.count * 2: + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, self.count): + return self.doException(merror.IllegalAddress) + + await context.async_setValues( + self.function_code, self.address, self.values + ) + return WriteMultipleRegistersResponse(self.address, self.count) + + def get_response_pdu_size(self): + """Get response pdu size. + + Func_code (1 byte) + Starting Address (2 byte) + Quantity of Registers (2 Bytes) + :return: + """ + return 1 + 2 + 2 + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + params = (self.address, self.count) + return ( + "WriteMultipleRegisterRequest %d => %d" # pylint: disable=consider-using-f-string + % params + ) + + +class WriteMultipleRegistersResponse(ModbusPDU): + """The normal response returns the function code. + + Starting address, and quantity of registers written. + """ + + function_code = 16 + _rtu_frame_size = 8 + + def __init__(self, address=0, count=0, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The address to start writing to + :param count: The number of registers to write to + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.count = count + + def encode(self): + """Encode a write single register packet packet request. + + :returns: The encoded packet + """ + return struct.pack(">HH", self.address, self.count) + + def decode(self, data): + """Decode a write single register packet packet request. + + :param data: The request to decode + """ + self.address, self.count = struct.unpack(">HH", data) + + def __str__(self): + """Return a string representation of the instance. + + :returns: A string representation of the instance + """ + params = (self.address, self.count) + return ( + "WriteMultipleRegisterResponse (%d,%d)" # pylint: disable=consider-using-f-string + % params + ) + + +class MaskWriteRegisterRequest(ModbusPDU): + """This function code is used to modify the contents. + + Of a specified holding register using a combination of an AND mask, + an OR mask, and the register's current contents. + The function can be used to set or clear individual bits in the register. + """ + + function_code = 0x16 + function_code_name = "mask_write_register" + _rtu_frame_size = 10 + + def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, skip_encode=False): + """Initialize a new instance. + + :param address: The mask pointer address (0x0000 to 0xffff) + :param and_mask: The and bitmask to apply to the register address + :param or_mask: The or bitmask to apply to the register address + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.and_mask = and_mask + self.or_mask = or_mask + + def encode(self): + """Encode the request packet. + + :returns: The byte encoded packet + """ + return struct.pack(">HHH", self.address, self.and_mask, self.or_mask) + + def decode(self, data): + """Decode the incoming request. + + :param data: The data to decode into the address + """ + self.address, self.and_mask, self.or_mask = struct.unpack(">HHH", data) + + async def update_datastore(self, context): # pragma: no cover + """Run a mask write register request against the store. + + :param context: The datastore to request from + :returns: The populated response + """ + if not 0x0000 <= self.and_mask <= 0xFFFF: + return self.doException(merror.IllegalValue) + if not 0x0000 <= self.or_mask <= 0xFFFF: + return self.doException(merror.IllegalValue) + if not context.validate(self.function_code, self.address, 1): + return self.doException(merror.IllegalAddress) + values = (await context.async_getValues(self.function_code, self.address, 1))[0] + values = (values & self.and_mask) | (self.or_mask & ~self.and_mask) + await context.async_setValues( + self.function_code, self.address, [values] + ) + return MaskWriteRegisterResponse(self.address, self.and_mask, self.or_mask) + + +class MaskWriteRegisterResponse(ModbusPDU): + """The normal response is an echo of the request. + + The response is returned after the register has been written. + """ + + function_code = 0x16 + _rtu_frame_size = 10 + + def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, skip_encode=False): + """Initialize new instance. + + :param address: The mask pointer address (0x0000 to 0xffff) + :param and_mask: The and bitmask applied to the register address + :param or_mask: The or bitmask applied to the register address + """ + super().__init__() + super().setData(slave, transaction, skip_encode) + self.address = address + self.and_mask = and_mask + self.or_mask = or_mask + + def encode(self): + """Encode the response. + + :returns: The byte encoded message + """ + return struct.pack(">HHH", self.address, self.and_mask, self.or_mask) + + def decode(self, data): + """Decode a the response. + + :param data: The packet data to decode + """ + self.address, self.and_mask, self.or_mask = struct.unpack(">HHH", data) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/py.typed b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/__init__.py new file mode 100644 index 00000000..2f7f5262 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/__init__.py @@ -0,0 +1,42 @@ +"""Server. + +import external classes, to make them easier to use: +""" + +__all__ = [ + "get_simulator_commandline", + "ModbusSerialServer", + "ModbusSimulatorServer", + "ModbusTcpServer", + "ModbusTlsServer", + "ModbusUdpServer", + "ServerAsyncStop", + "ServerStop", + "StartAsyncSerialServer", + "StartAsyncTcpServer", + "StartAsyncTlsServer", + "StartAsyncUdpServer", + "StartSerialServer", + "StartTcpServer", + "StartTlsServer", + "StartUdpServer", +] + +from pymodbus.server.async_io import ( + ModbusSerialServer, + ModbusTcpServer, + ModbusTlsServer, + ModbusUdpServer, + ServerAsyncStop, + ServerStop, + StartAsyncSerialServer, + StartAsyncTcpServer, + StartAsyncTlsServer, + StartAsyncUdpServer, + StartSerialServer, + StartTcpServer, + StartTlsServer, + StartUdpServer, +) +from pymodbus.server.simulator.http_server import ModbusSimulatorServer +from pymodbus.server.simulator.main import get_commandline as get_simulator_commandline diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/async_io.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/async_io.py new file mode 100644 index 00000000..d0615460 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/async_io.py @@ -0,0 +1,737 @@ +"""Implementation of a Threaded Modbus Server.""" +# pylint: disable=missing-type-doc +from __future__ import annotations + +import asyncio +import os +import traceback +from contextlib import suppress + +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification +from pymodbus.exceptions import ModbusException, NoSuchSlaveException +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerBase, FramerType +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU +from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.pdu.pdu import ExceptionResponse +from pymodbus.transport import CommParams, CommType, ModbusProtocol + + +# --------------------------------------------------------------------------- # +# Protocol Handlers +# --------------------------------------------------------------------------- # + + +class ModbusServerRequestHandler(ModbusProtocol): + """Implements modbus slave wire protocol. + + This uses the asyncio.Protocol to implement the server protocol. + + When a connection is established, a callback is called. + This callback will setup the connection and + create and schedule an asyncio.Task and assign it to running_task. + """ + + def __init__(self, owner): + """Initialize.""" + params = CommParams( + comm_name="server", + comm_type=owner.comm_params.comm_type, + reconnect_delay=0.0, + reconnect_delay_max=0.0, + timeout_connect=0.0, + host=owner.comm_params.source_address[0], + port=owner.comm_params.source_address[1], + ) + super().__init__(params, True) + self.server = owner + self.running = False + self.receive_queue: asyncio.Queue = asyncio.Queue() + self.handler_task = None # coroutine to be run on asyncio loop + self.databuffer = b'' + self.framer: FramerBase + self.loop = asyncio.get_running_loop() + + def _log_exception(self): + """Show log exception.""" + Log.debug( + "Handler for stream [{}] has been canceled", self.comm_params.comm_name + ) + + def callback_new_connection(self) -> ModbusProtocol: + """Call when listener receive new connection request.""" + Log.debug("callback_new_connection called") + return ModbusServerRequestHandler(self) + + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + slaves = self.server.context.slaves() + if self.server.broadcast_enable: + if 0 not in slaves: + slaves.append(0) + try: + self.running = True + self.framer = self.server.framer(self.server.decoder) + + # schedule the connection handler on the event loop + self.handler_task = asyncio.create_task(self.handle()) + self.handler_task.set_name("server connection handler") + except Exception as exc: # pylint: disable=broad-except + Log.error( + "Server callback_connected exception: {}; {}", + exc, + traceback.format_exc(), + ) + + def callback_disconnected(self, call_exc: Exception | None) -> None: + """Call when connection is lost.""" + try: + if self.handler_task: + self.handler_task.cancel() + if hasattr(self.server, "on_connection_lost"): + self.server.on_connection_lost() + if call_exc is None: + self._log_exception() + else: + Log.debug( + "Client Disconnection {} due to {}", + self.comm_params.comm_name, + call_exc, + ) + self.running = False + except Exception as exc: # pylint: disable=broad-except + Log.error( + "Datastore unable to fulfill request: {}; {}", + exc, + traceback.format_exc(), + ) + + async def inner_handle(self): + """Handle handler.""" + # this is an asyncio.Queue await, it will never fail + data = await self._recv_() + if isinstance(data, tuple): + # addr is populated when talking over UDP + data, *addr = data + else: + addr = [None] + + # if broadcast is enabled make sure to + # process requests to address 0 + self.databuffer += data + Log.debug("Handling data: {}", self.databuffer, ":hex") + try: + used_len, pdu = self.framer.processIncomingFrame(self.databuffer) + except ModbusException: + pdu = ExceptionResponse( + 40, + exception_code=merror.IllegalFunction + ) + self.server_send(pdu, 0) + pdu = None + used_len = len(self.databuffer) + self.databuffer = self.databuffer[used_len:] + if pdu: + self.execute(pdu, *addr) + + async def handle(self) -> None: + """Coroutine which represents a single master <=> slave conversation. + + Once the client connection is established, the data chunks will be + fed to this coroutine via the asyncio.Queue object which is fed by + the ModbusServerRequestHandler class's callback Future. + + This callback future gets data from either asyncio.BaseProtocol.data_received + or asyncio.DatagramProtocol.datagram_received. + + This function will execute without blocking in the while-loop and + yield to the asyncio event loop when the frame is exhausted. + As a result, multiple clients can be interleaved without any + interference between them. + """ + while self.running: + try: + await self.inner_handle() + except asyncio.CancelledError: + # catch and ignore cancellation errors + if self.running: + self._log_exception() + self.running = False + except Exception as exc: # pylint: disable=broad-except + # force TCP socket termination as framer + # should handle application layer errors + Log.error( + 'Unknown exception "{}" on stream {} forcing disconnect', + exc, + self.comm_params.comm_name, + ) + self.close() + self.callback_disconnected(exc) + + def execute(self, request, *addr): + """Call with the resulting message. + + :param request: The decoded request message + :param addr: the address + """ + if self.server.request_tracer: + self.server.request_tracer(request, *addr) + + asyncio.run_coroutine_threadsafe(self._async_execute(request, *addr), self.loop) + + async def _async_execute(self, request, *addr): + broadcast = False + try: + if self.server.broadcast_enable and not request.slave_id: + broadcast = True + # if broadcasting then execute on all slave contexts, + # note response will be ignored + for slave_id in self.server.context.slaves(): + response = await request.update_datastore(self.server.context[slave_id]) + else: + context = self.server.context[request.slave_id] + response = await request.update_datastore(context) + + except NoSuchSlaveException: + Log.error("requested slave does not exist: {}", request.slave_id) + if self.server.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = request.doException(merror.GatewayNoResponse) + except Exception as exc: # pylint: disable=broad-except + Log.error( + "Datastore unable to fulfill request: {}; {}", + exc, + traceback.format_exc(), + ) + response = request.doException(merror.SlaveFailure) + # no response when broadcasting + if not broadcast: + response.transaction_id = request.transaction_id + response.slave_id = request.slave_id + skip_encoding = False + if self.server.response_manipulator: + response, skip_encoding = self.server.response_manipulator(response) + self.server_send(response, *addr, skip_encoding=skip_encoding) + + def server_send(self, message, addr, **kwargs): + """Send message.""" + if kwargs.get("skip_encoding", False): + self.send(message, addr=addr) + if not message: + Log.debug("Skipping sending response!!") + else: + pdu = self.framer.buildFrame(message) + self.send(pdu, addr=addr) + + async def _recv_(self): + """Receive data from the network.""" + try: + result = await self.receive_queue.get() + except RuntimeError: + Log.error("Event loop is closed") + result = None + return result + + def callback_data(self, data: bytes, addr: tuple | None = ()) -> int: + """Handle received data.""" + if addr != (): + self.receive_queue.put_nowait((data, addr)) + else: + self.receive_queue.put_nowait(data) + return len(data) + + +# --------------------------------------------------------------------------- # +# Server Implementations +# --------------------------------------------------------------------------- # + + +class ModbusBaseServer(ModbusProtocol): + """Common functionality for all server classes.""" + + def __init__( + self, + params: CommParams, + context, + ignore_missing_slaves, + broadcast_enable, + response_manipulator, + request_tracer, + identity, + framer, + ) -> None: + """Initialize base server.""" + super().__init__( + params, + True, + ) + self.loop = asyncio.get_running_loop() + self.decoder = DecodePDU(True) + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.ignore_missing_slaves = ignore_missing_slaves + self.broadcast_enable = broadcast_enable + self.response_manipulator = response_manipulator + self.request_tracer = request_tracer + self.handle_local_echo = False + if isinstance(identity, ModbusDeviceIdentification): + self.control.Identity.update(identity) + + self.framer = FRAMER_NAME_TO_CLASS[framer] + self.serving: asyncio.Future = asyncio.Future() + + def callback_new_connection(self): + """Handle incoming connect.""" + return ModbusServerRequestHandler(self) + + async def shutdown(self): + """Close server.""" + if not self.serving.done(): + self.serving.set_result(True) + self.close() + + async def serve_forever(self): + """Start endless loop.""" + if self.transport: + raise RuntimeError( + "Can't call serve_forever on an already running server object" + ) + await self.listen() + Log.info("Server listening.") + await self.serving + Log.info("Server graceful shutdown.") + + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + + def callback_disconnected(self, exc: Exception | None) -> None: + """Call when connection is lost.""" + Log.debug("callback_disconnected called: {}", exc) + + def callback_data(self, data: bytes, addr: tuple | None = None) -> int: + """Handle received data.""" + Log.debug("callback_data called: {} addr={}", data, ":hex", addr) + return 0 + +class ModbusTcpServer(ModbusBaseServer): + """A modbus threaded tcp socket server. + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__( + self, + context, + framer=FramerType.SOCKET, + identity=None, + address=("", 502), + ignore_missing_slaves=False, + broadcast_enable=False, + response_manipulator=None, + request_tracer=None, + ): + """Initialize the socket server. + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat slave_id 0 as broadcast address, + False to treat 0 as any other slave_id + :param response_manipulator: Callback method for manipulating the + response + :param request_tracer: Callback method for tracing + """ + params = getattr( + self, + "tls_setup", + CommParams( + comm_type=CommType.TCP, + comm_name="server_listener", + reconnect_delay=0.0, + reconnect_delay_max=0.0, + timeout_connect=0.0, + ), + ) + params.source_address = address + super().__init__( + params, + context, + ignore_missing_slaves, + broadcast_enable, + response_manipulator, + request_tracer, + identity, + framer, + ) + + +class ModbusTlsServer(ModbusTcpServer): + """A modbus threaded tls socket server. + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + context, + framer=FramerType.TLS, + identity=None, + address=("", 502), + sslctx=None, + certfile=None, + keyfile=None, + password=None, + ignore_missing_slaves=False, + broadcast_enable=False, + response_manipulator=None, + request_tracer=None, + ): + """Overloaded initializer for the socket server. + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param sslctx: The SSLContext to use for TLS (default None and auto + create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param password: The password for for decrypting the private key file + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat slave_id 0 as broadcast address, + False to treat 0 as any other slave_id + :param response_manipulator: Callback method for + manipulating the response + """ + self.tls_setup = CommParams( + comm_type=CommType.TLS, + comm_name="server_listener", + reconnect_delay=0.0, + reconnect_delay_max=0.0, + timeout_connect=0.0, + sslctx=CommParams.generate_ssl( + True, certfile, keyfile, password, sslctx=sslctx + ), + ) + super().__init__( + context, + framer=framer, + identity=identity, + address=address, + ignore_missing_slaves=ignore_missing_slaves, + broadcast_enable=broadcast_enable, + response_manipulator=response_manipulator, + request_tracer=request_tracer, + ) + + +class ModbusUdpServer(ModbusBaseServer): + """A modbus threaded udp socket server. + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__( + self, + context, + framer=FramerType.SOCKET, + identity=None, + address=("", 502), + ignore_missing_slaves=False, + broadcast_enable=False, + response_manipulator=None, + request_tracer=None, + ): + """Overloaded initializer for the socket server. + + If the identify structure is not passed in, the ModbusControlBlock + uses its own empty structure. + + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat slave_id 0 as broadcast address, + False to treat 0 as any other slave_id + :param response_manipulator: Callback method for + manipulating the response + :param request_tracer: Callback method for tracing + """ + # ---------------- + super().__init__( + CommParams( + comm_type=CommType.UDP, + comm_name="server_listener", + source_address=address, + reconnect_delay=0.0, + reconnect_delay_max=0.0, + timeout_connect=0.0, + ), + context, + ignore_missing_slaves, + broadcast_enable, + response_manipulator, + request_tracer, + identity, + framer, + ) + + +class ModbusSerialServer(ModbusBaseServer): + """A modbus threaded serial socket server. + + We inherit and overload the socket server so that we + can control the client threads as well as have a single + server context instance. + """ + + def __init__( + self, context, framer=FramerType.RTU, identity=None, **kwargs + ): + """Initialize the socket server. + + If the identity structure is not passed in, the ModbusControlBlock + uses its own empty structure. + :param context: The ModbusServerContext datastore + :param framer: The framer strategy to use, default FramerType.RTU + :param identity: An optional identify structure + :param port: The serial port to attach to + :param stopbits: The number of stop bits to use + :param bytesize: The bytesize of the serial messages + :param parity: Which kind of parity to use + :param baudrate: The baud rate to use for the serial device + :param timeout: The timeout to use for the serial device + :param handle_local_echo: (optional) Discard local echo from dongle. + :param ignore_missing_slaves: True to not send errors on a request + to a missing slave + :param broadcast_enable: True to treat slave_id 0 as broadcast address, + False to treat 0 as any other slave_id + :param reconnect_delay: reconnect delay in seconds + :param response_manipulator: Callback method for + manipulating the response + :param request_tracer: Callback method for tracing + """ + super().__init__( + params=CommParams( + comm_type=CommType.SERIAL, + comm_name="server_listener", + reconnect_delay=kwargs.get("reconnect_delay", 2), + reconnect_delay_max=0.0, + timeout_connect=kwargs.get("timeout", 3), + source_address=(kwargs.get("port", 0), 0), + bytesize=kwargs.get("bytesize", 8), + parity=kwargs.get("parity", "N"), + baudrate=kwargs.get("baudrate", 19200), + stopbits=kwargs.get("stopbits", 1), + ), + context=context, + ignore_missing_slaves=kwargs.get("ignore_missing_slaves", False), + broadcast_enable=kwargs.get("broadcast_enable", False), + response_manipulator=kwargs.get("response_manipulator", None), + request_tracer=kwargs.get("request_tracer", None), + identity=kwargs.get("identity", None), + framer=framer, + ) + self.handle_local_echo = kwargs.get("handle_local_echo", False) + + +# --------------------------------------------------------------------------- # +# Creation Factories +# --------------------------------------------------------------------------- # + + +class _serverList: + """Maintains information about the active server. + + :meta private: + """ + + active_server: ModbusTcpServer | ModbusUdpServer | ModbusSerialServer + + def __init__(self, server): + """Register new server.""" + self.server = server + self.loop = asyncio.get_event_loop() + + @classmethod + async def run(cls, server, custom_functions) -> None: + """Help starting/stopping server.""" + for func in custom_functions: + server.decoder.register(func) + cls.active_server = _serverList(server) # type: ignore[assignment] + with suppress(asyncio.exceptions.CancelledError): + await server.serve_forever() + + @classmethod + async def async_stop(cls) -> None: + """Wait for server stop.""" + if not cls.active_server: + raise RuntimeError("ServerAsyncStop called without server task active.") + await cls.active_server.server.shutdown() # type: ignore[union-attr] + cls.active_server = None # type: ignore[assignment] + + @classmethod + def stop(cls): + """Wait for server stop.""" + if not cls.active_server: + Log.info("ServerStop called without server task active.") + return + if not cls.active_server.loop.is_running(): + Log.info("ServerStop called with loop stopped.") + return + future = asyncio.run_coroutine_threadsafe(cls.async_stop(), cls.active_server.loop) + future.result(timeout=10 if os.name == 'nt' else 0.1) + + +async def StartAsyncTcpServer( # pylint: disable=invalid-name,dangerous-default-value + context=None, + identity=None, + address=None, + custom_functions=[], + **kwargs, +): + """Start and run a tcp modbus server. + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param kwargs: The rest + """ + kwargs.pop("host", None) + server = ModbusTcpServer( + context, kwargs.pop("framer", FramerType.SOCKET), identity, address, **kwargs + ) + await _serverList.run(server, custom_functions) + + +async def StartAsyncTlsServer( # pylint: disable=invalid-name,dangerous-default-value + context=None, + identity=None, + address=None, + sslctx=None, + certfile=None, + keyfile=None, + password=None, + custom_functions=[], + **kwargs, +): + """Start and run a tls modbus server. + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param sslctx: The SSLContext to use for TLS (default None and auto create) + :param certfile: The cert file path for TLS (used if sslctx is None) + :param keyfile: The key file path for TLS (used if sslctx is None) + :param password: The password for for decrypting the private key file + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param kwargs: The rest + """ + kwargs.pop("host", None) + server = ModbusTlsServer( + context, + kwargs.pop("framer", FramerType.TLS), + identity, + address, + sslctx, + certfile, + keyfile, + password, + **kwargs, + ) + await _serverList.run(server, custom_functions) + + +async def StartAsyncUdpServer( # pylint: disable=invalid-name,dangerous-default-value + context=None, + identity=None, + address=None, + custom_functions=[], + **kwargs, +): + """Start and run a udp modbus server. + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param address: An optional (interface, port) to bind to. + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param kwargs: + """ + kwargs.pop("host", None) + server = ModbusUdpServer( + context, kwargs.pop("framer", FramerType.SOCKET), identity, address, **kwargs + ) + await _serverList.run(server, custom_functions) + + +async def StartAsyncSerialServer( # pylint: disable=invalid-name,dangerous-default-value + context=None, + identity=None, + custom_functions=[], + **kwargs, +): + """Start and run a serial modbus server. + + :param context: The ModbusServerContext datastore + :param identity: An optional identify structure + :param custom_functions: An optional list of custom function classes + supported by server instance. + :param kwargs: The rest + """ + server = ModbusSerialServer( + context, kwargs.pop("framer", FramerType.RTU), identity=identity, **kwargs + ) + await _serverList.run(server, custom_functions) + + +def StartSerialServer(**kwargs): # pylint: disable=invalid-name + """Start and run a serial modbus server.""" + return asyncio.run(StartAsyncSerialServer(**kwargs)) + + +def StartTcpServer(**kwargs): # pylint: disable=invalid-name + """Start and run a serial modbus server.""" + return asyncio.run(StartAsyncTcpServer(**kwargs)) + + +def StartTlsServer(**kwargs): # pylint: disable=invalid-name + """Start and run a serial modbus server.""" + return asyncio.run(StartAsyncTlsServer(**kwargs)) + + +def StartUdpServer(**kwargs): # pylint: disable=invalid-name + """Start and run a serial modbus server.""" + return asyncio.run(StartAsyncUdpServer(**kwargs)) + + +async def ServerAsyncStop(): # pylint: disable=invalid-name + """Terminate server.""" + await _serverList.async_stop() + + +def ServerStop(): # pylint: disable=invalid-name + """Terminate server.""" + _serverList.stop() diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/__init__.py new file mode 100644 index 00000000..8d16f202 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/__init__.py @@ -0,0 +1 @@ +"""Initialize.""" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/custom_actions.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/custom_actions.py new file mode 100644 index 00000000..50c0a93f --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/custom_actions.py @@ -0,0 +1,10 @@ +"""Datastore simulator, custom actions.""" + + +def device_reset(_registers, _inx, _cell): + """Use example custom action.""" + + +custom_actions_dict = { + "umg804_reset": device_reset, +} diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/http_server.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/http_server.py new file mode 100644 index 00000000..aba2f343 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/http_server.py @@ -0,0 +1,809 @@ +"""HTTP server for modbus simulator.""" +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import importlib +import json +import os +from time import sleep +from typing import TYPE_CHECKING + + +try: + from aiohttp import web + + AIOHTTP_MISSING = False +except ImportError: + AIOHTTP_MISSING = True + if TYPE_CHECKING: # always False at runtime + # type checkers do not understand the Raise RuntimeError in __init__() + from aiohttp import web + +from pymodbus.datastore import ModbusServerContext, ModbusSimulatorContext +from pymodbus.datastore.simulator import Label +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU, ExceptionResponse +from pymodbus.server.async_io import ( + ModbusSerialServer, + ModbusTcpServer, + ModbusTlsServer, + ModbusUdpServer, +) + + +MAX_FILTER = 1000 + +RESPONSE_INACTIVE = -1 +RESPONSE_NORMAL = 0 +RESPONSE_ERROR = 1 +RESPONSE_EMPTY = 2 +RESPONSE_JUNK = 3 + + +@dataclasses.dataclass() +class CallTracer: + """Define call/response traces.""" + + call: bool = False + fc: int = -1 + address: int = -1 + count: int = -1 + data: bytes = b"" + + +@dataclasses.dataclass() +class CallTypeMonitor: + """Define Request/Response monitor.""" + + active: bool = False + trace_response: bool = False + range_start: int = -1 + range_stop: int = -1 + function: int = -1 + hex: bool = False + decode: bool = False + + +@dataclasses.dataclass() +class CallTypeResponse: + """Define Response manipulation.""" + + active: int = RESPONSE_INACTIVE + split: int = 0 + delay: int = 0 + junk_len: int = 10 + error_response: int = 0 + change_rate: int = 0 + clear_after: int = 1 + + +class ModbusSimulatorServer: + """**ModbusSimulatorServer**. + + :param modbus_server: Server name in json file (default: "server") + :param modbus_device: Device name in json file (default: "client") + :param http_host: TCP host for HTTP (default: "localhost") + :param http_port: TCP port for HTTP (default: 8080) + :param json_file: setup file (default: "setup.json") + :param custom_actions_module: python module with custom actions (default: none) + + if either http_port or http_host is none, HTTP will not be started. + This class starts a http server, that serves a couple of endpoints: + + - **"<addr>/"** static files + - **"<addr>/api/log"** log handling, HTML with GET, REST-API with post + - **"<addr>/api/registers"** register handling, HTML with GET, REST-API with post + - **"<addr>/api/calls"** call (function code / message) handling, HTML with GET, REST-API with post + - **"<addr>/api/server"** server handling, HTML with GET, REST-API with post + + Example:: + + from pymodbus.server import ModbusSimulatorServer + + async def run(): + simulator = ModbusSimulatorServer( + modbus_server="my server", + modbus_device="my device", + http_host="localhost", + http_port=8080) + await simulator.run_forever(only_start=True) + ... + await simulator.stop() + """ + + def __init__( + self, + modbus_server: str = "server", + modbus_device: str = "device", + http_host: str = "0.0.0.0", + http_port: int = 8080, + log_file: str = "server.log", + json_file: str = "setup.json", + custom_actions_module: str | None = None, + ): + """Initialize http interface.""" + if AIOHTTP_MISSING: + raise RuntimeError( + "Simulator server requires aiohttp. " + 'Please install with "pip install aiohttp" and try again.' + ) + with open(json_file, encoding="utf-8") as file: + setup = json.load(file) + + comm_class = { + "serial": ModbusSerialServer, + "tcp": ModbusTcpServer, + "tls": ModbusTlsServer, + "udp": ModbusUdpServer, + } + if custom_actions_module: + actions_module = importlib.import_module(custom_actions_module) + custom_actions_dict = actions_module.custom_actions_dict + else: + custom_actions_dict = {} + server = setup["server_list"][modbus_server] + if server["comm"] != "serial": + server["address"] = (server["host"], server["port"]) + del server["host"] + del server["port"] + device = setup["device_list"][modbus_device] + self.datastore_context = ModbusSimulatorContext( + device, custom_actions_dict or {} + ) + datastore = None + if "device_id" in server: + # Designated ModBus unit address. Will only serve data if the address matches + datastore = ModbusServerContext(slaves={int(server["device_id"]): self.datastore_context}, single=False) + else: + # Will server any request regardless of addressing + datastore = ModbusServerContext(slaves=self.datastore_context, single=True) + + comm = comm_class[server.pop("comm")] + framer = server.pop("framer") + if "identity" in server: + server["identity"] = ModbusDeviceIdentification( + info_name=server["identity"] + ) + self.modbus_server = comm(framer=framer, context=datastore, **server) + self.serving: asyncio.Future = asyncio.Future() + self.log_file = log_file + self.site: web.TCPSite | None = None + self.runner: web.AppRunner + self.http_host = http_host + self.http_port = http_port + self.web_path = os.path.join(os.path.dirname(__file__), "web") + self.web_app = web.Application() + self.web_app.add_routes( + [ + web.get("/api/{tail:[a-z]*}", self.handle_html), + web.post("/restapi/{tail:[a-z]*}", self.handle_json), + web.get("/{tail:[a-z0-9.]*}", self.handle_html_static), + web.get("/", self.handle_html_static), + ] + ) + self.web_app.on_startup.append(self.start_modbus_server) + self.web_app.on_shutdown.append(self.stop_modbus_server) + self.generator_html: dict[str, list] = { + "log": ["", self.build_html_log], + "registers": ["", self.build_html_registers], + "calls": ["", self.build_html_calls], + "server": ["", self.build_html_server], + } + self.generator_json = { + "log": self.build_json_log, + "registers": self.build_json_registers, + "calls": self.build_json_calls, + "server": self.build_json_server, + } + self.submit_html = { + "Clear": self.action_clear, + "Stop": self.action_stop, + "Reset": self.action_reset, + "Add": self.action_add, + "Monitor": self.action_monitor, + "Set": self.action_set, + "Simulate": self.action_simulate, + } + for entry in self.generator_html: # pylint: disable=consider-using-dict-items + html_file = os.path.join(self.web_path, "generator", entry) + with open(html_file, encoding="utf-8") as handle: + self.generator_html[entry][0] = handle.read() + self.refresh_rate = 0 + self.register_filter: list[int] = [] + self.call_list: list[CallTracer] = [] + self.request_lookup = DecodePDU(True).lookup + self.call_monitor = CallTypeMonitor() + self.call_response = CallTypeResponse() + app_key = getattr(web, 'AppKey', str) # fall back to str for aiohttp < 3.9.0 + self.api_key = app_key("modbus_server") + + async def start_modbus_server(self, app): + """Start Modbus server as asyncio task.""" + try: + if getattr(self.modbus_server, "start", None): + await self.modbus_server.start() + app[self.api_key] = asyncio.create_task( + self.modbus_server.serve_forever() + ) + app[self.api_key].set_name("simulator modbus server") + except Exception as exc: + Log.error("Error starting modbus server, reason: {}", exc) + raise exc + Log.info( + "Modbus server started on {}", self.modbus_server.comm_params.source_address + ) + + async def stop_modbus_server(self, app): + """Stop modbus server.""" + Log.info("Stopping modbus server") + await self.modbus_server.shutdown() + app[self.api_key].cancel() + with contextlib.suppress(asyncio.exceptions.CancelledError): + await app[self.api_key] + + Log.info("Modbus server Stopped") + + async def run_forever(self, only_start=False): + """Start modbus and http servers.""" + try: + self.runner = web.AppRunner(self.web_app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.http_host, self.http_port) + await self.site.start() + except Exception as exc: + Log.error("Error starting http server, reason: {}", exc) + raise exc + Log.info("HTTP server started on ({}:{})", self.http_host, self.http_port) + if only_start: + return + await self.serving + + async def stop(self): + """Stop modbus and http servers.""" + await self.runner.cleanup() + self.site = None + if not self.serving.done(): + self.serving.set_result(True) + await asyncio.sleep(0) + + async def handle_html_static(self, request): + """Handle static html.""" + if not (page := request.path[1:]): + page = "index.html" + file = os.path.normpath(os.path.join(self.web_path, page)) + if not file.startswith(self.web_path): + raise ValueError(f"File access outside {self.web_path} not permitted.") + try: + with open(file, encoding="utf-8"): + return web.FileResponse(file) + except (FileNotFoundError, IsADirectoryError) as exc: + raise web.HTTPNotFound(reason="File not found") from exc + + async def handle_html(self, request): + """Handle html.""" + page_type = request.path.split("/")[-1] + params = dict(request.query) + if refresh := params.pop("refresh", None): + self.refresh_rate = int(refresh) + if self.refresh_rate > 0: + html = self.generator_html[page_type][0].replace( + "<!--REFRESH-->", + f'<meta http-equiv="refresh" content="{self.refresh_rate}">', + ) + else: + html = self.generator_html[page_type][0].replace("<!--REFRESH-->", "") + new_page = self.generator_html[page_type][1](params, html) + return web.Response(text=new_page, content_type="text/html") + + async def handle_json(self, request): + """Handle api registers.""" + command = request.path.split("/")[-1] + params = await request.json() + try: + result = self.generator_json[command](params) + except (KeyError, ValueError, TypeError, IndexError) as exc: + Log.error("Unhandled error during json request: {}", exc) + return web.json_response({"result": "error", "error": f"Unhandled error Error: {exc}"}) + return web.json_response(result) + + def build_html_registers(self, params, html): + """Build html registers page.""" + result_txt, foot = self.helper_handle_submit(params, self.submit_html) + if not result_txt: + result_txt = "ok" + if not foot: + if self.register_filter: + foot = f"{len(self.register_filter)} register(s) monitored" + else: + foot = "Nothing selected" + register_types = "".join( + f"<option value={reg_id}>{name}</option>" + for name, reg_id in self.datastore_context.registerType_name_to_id.items() + ) + register_actions = "".join( + f"<option value={action_id}>{name}</option>" + for name, action_id in self.datastore_context.action_name_to_id.items() + ) + rows = "" + for i in self.register_filter: + inx, reg = self.datastore_context.get_text_register(i) + if reg.type == Label.next: + continue + row = "".join( + f"<td>{entry}</td>" + for entry in ( + inx, + reg.type, + reg.access, + reg.action, + reg.value, + reg.count_read, + reg.count_write, + ) + ) + rows += f"<tr>{row}</tr>" + new_html = ( + html.replace("<!--REGISTER_ACTIONS-->", register_actions) + .replace("<!--REGISTER_TYPES-->", register_types) + .replace("<!--REGISTER_FOOT-->", foot) + .replace("<!--REGISTER_ROWS-->", rows) + .replace("<!--RESULT-->", result_txt) + ) + return new_html + + def build_html_calls(self, params: dict, html: str) -> str: + """Build html calls page.""" + result_txt, foot = self.helper_handle_submit(params, self.submit_html) + if not foot: + foot = "Montitoring active" if self.call_monitor.active else "not active" + if not result_txt: + result_txt = "ok" + + function_error = "" + for i, txt in ( + (1, "IllegalFunction"), + (2, "IllegalAddress"), + (3, "IllegalValue"), + (4, "SlaveFailure"), + (5, "Acknowledge"), + (6, "SlaveBusy"), + (7, "MemoryParityError"), + (10, "GatewayPathUnavailable"), + (11, "GatewayNoResponse"), + ): + selected = "selected" if i == self.call_response.error_response else "" + function_error += f"<option value={i} {selected}>{txt}</option>" + range_start_html = ( + str(self.call_monitor.range_start) + if self.call_monitor.range_start != -1 + else "" + ) + range_stop_html = ( + str(self.call_monitor.range_stop) + if self.call_monitor.range_stop != -1 + else "" + ) + function_codes = "" + for function in self.request_lookup.values(): + selected = ( + "selected" + if function.function_code == self.call_monitor.function + else "" + ) + function_codes += f"<option value={function.function_code} {selected}>{function.function_code_name}</option>" #type: ignore[attr-defined] + simulation_action = ( + "ACTIVE" if self.call_response.active != RESPONSE_INACTIVE else "" + ) + + max_len = MAX_FILTER if self.call_monitor.active else 0 + while len(self.call_list) > max_len: + del self.call_list[0] + call_rows = "" + for entry in reversed(self.call_list): + # req_obj = self.request_lookup[entry[1]] + call_rows += f"<tr><td>{entry.call} - {entry.fc}</td><td>{entry.address}</td><td>{entry.count}</td><td>{entry.data.decode()}</td></tr>" + # line += req_obj.funcion_code_name + new_html = ( + html.replace("<!--SIMULATION_ACTIVE-->", simulation_action) + .replace("FUNCTION_RANGE_START", range_start_html) + .replace("FUNCTION_RANGE_STOP", range_stop_html) + .replace("<!--FUNCTION_CODES-->", function_codes) + .replace( + "FUNCTION_SHOW_HEX_CHECKED", "checked" if self.call_monitor.hex else "" + ) + .replace( + "FUNCTION_SHOW_DECODED_CHECKED", + "checked" if self.call_monitor.decode else "", + ) + .replace( + "FUNCTION_RESPONSE_NORMAL_CHECKED", + "checked" if self.call_response.active == RESPONSE_NORMAL else "", + ) + .replace( + "FUNCTION_RESPONSE_ERROR_CHECKED", + "checked" if self.call_response.active == RESPONSE_ERROR else "", + ) + .replace( + "FUNCTION_RESPONSE_EMPTY_CHECKED", + "checked" if self.call_response.active == RESPONSE_EMPTY else "", + ) + .replace( + "FUNCTION_RESPONSE_JUNK_CHECKED", + "checked" if self.call_response.active == RESPONSE_JUNK else "", + ) + .replace( + "FUNCTION_RESPONSE_SPLIT_CHECKED", + "checked" if self.call_response.split > 0 else "", + ) + .replace("FUNCTION_RESPONSE_SPLIT_DELAY", str(self.call_response.split)) + .replace( + "FUNCTION_RESPONSE_CR_CHECKED", + "checked" if self.call_response.change_rate > 0 else "", + ) + .replace("FUNCTION_RESPONSE_CR_PCT", str(self.call_response.change_rate)) + .replace("FUNCTION_RESPONSE_DELAY", str(self.call_response.delay)) + .replace("FUNCTION_RESPONSE_JUNK", str(self.call_response.junk_len)) + .replace("<!--FUNCTION_ERROR-->", function_error) + .replace( + "FUNCTION_RESPONSE_CLEAR_AFTER", str(self.call_response.clear_after) + ) + .replace("<!--FC_ROWS-->", call_rows) + .replace("<!--FC_FOOT-->", foot) + ) + return new_html + + def build_html_log(self, _params, html): + """Build html log page.""" + return html + + def build_html_server(self, _params, html): + """Build html server page.""" + return html + + def build_json_registers(self, params): + """Build json registers response.""" + # Process params using the helper function + result_txt, foot = self.helper_handle_submit(params, { + "Set": self.action_set, + }) + + if not result_txt: + result_txt = "ok" + if not foot: + foot = "Operation completed successfully" + + # Extract necessary parameters + try: + range_start = int(params.get("range_start", 0)) + range_stop = int(params.get("range_stop", range_start)) + except ValueError: + return {"result": "error", "error": "Invalid range parameters"} + + # Retrieve register details + register_rows = [] + for i in range(range_start, range_stop + 1): + inx, reg = self.datastore_context.get_text_register(i) + row = { + "index": inx, + "type": reg.type, + "access": reg.access, + "action": reg.action, + "value": reg.value, + "count_read": reg.count_read, + "count_write": reg.count_write + } + register_rows.append(row) + + # Generate register types and actions (assume these are predefined mappings) + register_types = dict(self.datastore_context.registerType_name_to_id) + register_actions = dict(self.datastore_context.action_name_to_id) + + # Build the JSON response + json_response = { + "result": result_txt, + "footer": foot, + "register_types": register_types, + "register_actions": register_actions, + "register_rows": register_rows, + } + + return json_response + + def build_json_calls(self, params: dict) -> dict: + """Build json calls response.""" + result_txt, foot = self.helper_handle_submit(params, { + "Reset": self.action_reset, + "Add": self.action_add, + "Simulate": self.action_simulate, + }) + if not foot: + foot = "Monitoring active" if self.call_monitor.active else "not active" + if not result_txt: + result_txt = "ok" + + function_error = [] + for i, txt in ( + (1, "IllegalFunction"), + (2, "IllegalAddress"), + (3, "IllegalValue"), + (4, "SlaveFailure"), + (5, "Acknowledge"), + (6, "SlaveBusy"), + (7, "MemoryParityError"), + (10, "GatewayPathUnavailable"), + (11, "GatewayNoResponse"), + ): + function_error.append({ + "value": i, + "text": txt, + "selected": i == self.call_response.error_response + }) + + range_start = ( + self.call_monitor.range_start + if self.call_monitor.range_start != -1 + else None + ) + range_stop = ( + self.call_monitor.range_stop + if self.call_monitor.range_stop != -1 + else None + ) + + function_codes = [] + for function in self.request_lookup.values(): + function_codes.append({ + "value": function.function_code, + "text": function.function_code_name, # type: ignore[attr-defined] + "selected": function.function_code == self.call_monitor.function + }) + + simulation_action = "ACTIVE" if self.call_response.active != RESPONSE_INACTIVE else "" + + max_len = MAX_FILTER if self.call_monitor.active else 0 + while len(self.call_list) > max_len: + del self.call_list[0] + call_rows = [] + for entry in reversed(self.call_list): + call_rows.append({ + "call": entry.call, + "fc": entry.fc, + "address": entry.address, + "count": entry.count, + "data": entry.data.decode() + }) + + json_response = { + "simulation_action": simulation_action, + "range_start": range_start, + "range_stop": range_stop, + "function_codes": function_codes, + "function_show_hex_checked": self.call_monitor.hex, + "function_show_decoded_checked": self.call_monitor.decode, + "function_response_normal_checked": self.call_response.active == RESPONSE_NORMAL, + "function_response_error_checked": self.call_response.active == RESPONSE_ERROR, + "function_response_empty_checked": self.call_response.active == RESPONSE_EMPTY, + "function_response_junk_checked": self.call_response.active == RESPONSE_JUNK, + "function_response_split_checked": self.call_response.split > 0, + "function_response_split_delay": self.call_response.split, + "function_response_cr_checked": self.call_response.change_rate > 0, + "function_response_cr_pct": self.call_response.change_rate, + "function_response_delay": self.call_response.delay, + "function_response_junk": self.call_response.junk_len, + "function_error": function_error, + "function_response_clear_after": self.call_response.clear_after, + "call_rows": call_rows, + "foot": foot, + "result": result_txt + } + + return json_response + + def build_json_log(self, params): + """Build json log page.""" + return {"result": "error", "error": "log endpoint not implemented", "params": params} + + def build_json_server(self, params): + """Build html server page.""" + return {"result": "error", "error": "server endpoint not implemented", "params": params} + + def helper_handle_submit(self, params, submit_actions): + """Build html register submit.""" + try: + range_start = int(params.get("range_start", -1)) + except ValueError: + range_start = -1 + try: + range_stop = int(params.get("range_stop", range_start)) + except ValueError: + range_stop = -1 + if (submit := params["submit"]) not in submit_actions: + return None, None + return submit_actions[submit](params, range_start, range_stop) + + def action_clear(self, _params, _range_start, _range_stop): + """Clear register filter.""" + self.register_filter = [] + return None, None + + def action_stop(self, _params, _range_start, _range_stop): + """Stop call monitoring.""" + self.call_monitor = CallTypeMonitor() + self.modbus_server.response_manipulator = None + self.modbus_server.request_tracer = None + return None, "Stopped monitoring" + + def action_reset(self, _params, _range_start, _range_stop): + """Reset call simulation.""" + self.call_response = CallTypeResponse() + if not self.call_monitor.active: + self.modbus_server.response_manipulator = self.server_response_manipulator + return None, None + + def action_add(self, params, range_start, range_stop): + """Build list of registers matching filter.""" + reg_action = int(params.get("action", -1)) + reg_writeable = "writeable" in params + reg_type = int(params.get("type", -1)) + filter_updated = 0 + if range_start != -1: + steps = range(range_start, range_stop + 1) + else: + steps = range(1, self.datastore_context.register_count) + for i in steps: + if range_start != -1 and (i < range_start or i > range_stop): + continue + reg = self.datastore_context.registers[i] + skip_filter = reg_writeable and not reg.access + skip_filter |= reg_type not in (-1, reg.type) + skip_filter |= reg_action not in (-1, reg.action) + skip_filter |= i in self.register_filter + if skip_filter: + continue + self.register_filter.append(i) + filter_updated += 1 + if len(self.register_filter) >= MAX_FILTER: + self.register_filter.sort() + return None, f"Max. filter size {MAX_FILTER} exceeded!" + self.register_filter.sort() + return None, None + + def action_monitor(self, params, range_start, range_stop): + """Start monitoring calls.""" + self.call_monitor.range_start = range_start + self.call_monitor.range_stop = range_stop + self.call_monitor.function = ( + int(params["function"]) if params["function"] else -1 + ) + self.call_monitor.hex = "show_hex" in params + self.call_monitor.decode = "show_decode" in params + self.call_monitor.active = True + self.modbus_server.response_manipulator = self.server_response_manipulator + self.modbus_server.request_tracer = self.server_request_tracer + return None, None + + def action_set(self, params, _range_start, _range_stop): + """Set register value.""" + if not (register := params["register"]): + return "Missing register", None + register = int(register) + if value := params["value"]: + self.datastore_context.registers[register].value = int(value) + if bool(params.get("writeable", False)): + self.datastore_context.registers[register].access = True + return None, None + + def action_simulate(self, params, _range_start, _range_stop): + """Simulate responses.""" + self.call_response.active = int(params["response_type"]) + if "response_split" in params: + if params["split_delay"]: + self.call_response.split = int(params["split_delay"]) + else: + self.call_response.split = 1 + else: + self.call_response.split = 0 + if "response_cr" in params: + if params["response_cr_pct"]: + self.call_response.change_rate = int(params["response_cr_pct"]) + else: + self.call_response.change_rate = 0 + else: + self.call_response.change_rate = 0 + if params["response_delay"]: + self.call_response.delay = int(params["response_delay"]) + else: + self.call_response.delay = 0 + if params["response_junk_datalen"]: + self.call_response.junk_len = int(params["response_junk_datalen"]) + else: + self.call_response.junk_len = 0 + self.call_response.error_response = int(params["response_error"]) + if params["response_clear_after"]: + self.call_response.clear_after = int(params["response_clear_after"]) + else: + self.call_response.clear_after = 1 + self.modbus_server.response_manipulator = self.server_response_manipulator + return None, None + + def server_response_manipulator(self, response): + """Manipulate responses. + + All server responses passes this filter before being sent. + The filter returns: + + - response, either original or modified + - skip_encoding, signals whether or not to encode the response + """ + if self.call_monitor.trace_response: + tracer = CallTracer( + call=False, + fc=response.function_code, + address=response.address if hasattr(response, "address") else -1, + count=response.count if hasattr(response, "count") else -1, + data=b"-", + ) + self.call_list.append(tracer) + self.call_monitor.trace_response = False + + if self.call_response.active != RESPONSE_INACTIVE: + return response, False + + skip_encoding = False + if self.call_response.active == RESPONSE_EMPTY: + Log.warning("Sending empty response") + return None, False + if self.call_response.active == RESPONSE_NORMAL: + if self.call_response.delay: + Log.warning( + "Delaying response by {}s for all incoming requests", + self.call_response.delay, + ) + sleep(self.call_response.delay) # change to async + else: + pass + # self.call_response.change_rate + # self.call_response.split + elif self.call_response.active == RESPONSE_ERROR: + Log.warning("Sending error response for all incoming requests") + err_response = ExceptionResponse( + response.function_code, self.call_response.error_response + ) + err_response.transaction_id = response.transaction_id + err_response.slave_id = response.slave_id + elif self.call_response.active == RESPONSE_JUNK: + response = os.urandom(self.call_response.junk_len) + skip_encoding = True + + self.call_response.clear_after -= 1 + if self.call_response.clear_after <= 0: + Log.info("Resetting manipulator due to clear_after") + self.call_response.active = RESPONSE_EMPTY + return response, skip_encoding + + def server_request_tracer(self, request, *_addr): + """Trace requests. + + All server requests passes this filter before being handled. + """ + if self.call_monitor.function not in {-1, request.function_code}: + return + address = request.address if hasattr(request, "address") else -1 + if self.call_monitor.range_start != -1 and address != -1: + if ( + self.call_monitor.range_start > address + or self.call_monitor.range_stop < address + ): + return + tracer = CallTracer( + call=True, + fc=request.function_code, + address=address, + count=request.count if hasattr(request, "count") else -1, + data=b"-", + ) + self.call_list.append(tracer) + self.call_monitor.trace_response = True diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/main.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/main.py new file mode 100644 index 00000000..bc7e230e --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/main.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""HTTP server for modbus simulator. + +The modbus simulator contain 3 distinct parts: + +- Datastore simulator, to define registers and their behaviour including actions: (simulator)(../../datastore/simulator.py) +- Modbus server: (server)(./http_server.py) +- HTTP server with REST API and web pages providing an online console in your browser + +Multiple setups for different server types and/or devices are prepared in a (json file)(./setup.json), the detailed configuration is explained in (doc)(README.rst) + +The command line parameters are kept to a minimum: + +usage: main.py [-h] [--modbus_server MODBUS_SERVER] + [--modbus_device MODBUS_DEVICE] [--http_host HTTP_HOST] + [--http_port HTTP_PORT] + [--log {critical,error,warning,info,debug}] + [--json_file JSON_FILE] + [--custom_actions_module CUSTOM_ACTIONS_MODULE] + +Modbus server with REST-API and web server + +options: + -h, --help show this help message and exit + --modbus_server MODBUS_SERVER + use <modbus_server> from server_list in json file + --modbus_device MODBUS_DEVICE + use <modbus_device> from device_list in json file + --http_host HTTP_HOST + use <http_host> as host to bind http listen + --http_port HTTP_PORT + use <http_port> as port to bind http listen + --log {critical,error,warning,info,debug} + set log level, default is info + --log_file LOG_FILE + name of server log file, default is "server.log" + --json_file JSON_FILE + name of json_file, default is "setup.json" + --custom_actions_module CUSTOM_ACTIONS_MODULE + python file with custom actions, default is none +""" +import argparse +import asyncio +import os + +from pymodbus import pymodbus_apply_logging_config +from pymodbus.logging import Log +from pymodbus.server.simulator.http_server import ModbusSimulatorServer + + +def get_commandline(extras=None, cmdline=None): + """Get command line arguments.""" + parser = argparse.ArgumentParser( + description="Modbus server with REST-API and web server" + ) + parser.add_argument( + "--modbus_server", + help="use <modbus_server> from server_list in json file", + type=str, + ) + parser.add_argument( + "--modbus_device", + help="use <modbus_device> from device_list in json file", + type=str, + ) + parser.add_argument( + "--http_host", + help="use <http_host> as host to bind http listen", + type=str, + ) + parser.add_argument( + "--http_port", + help="use <http_port> as port to bind http listen", + type=str, + default=8081, + ) + parser.add_argument( + "--log", + choices=["critical", "error", "warning", "info", "debug"], + help="set log level, default is info", + default="info", + type=str, + ) + parser.add_argument( + "--json_file", + help='name of json file, default is "setup.json"', + type=str, + default=os.path.join(os.path.dirname(__file__), "setup.json"), + ) + parser.add_argument( + "--log_file", + help='name of server log file, default is "server.log"', + type=str, + ) + parser.add_argument( + "--custom_actions_module", + help="python file with custom actions, default is none", + type=str, + ) + if extras: + for extra in extras: + parser.add_argument(extra[0], **extra[1]) + args = parser.parse_args(cmdline) + pymodbus_apply_logging_config(args.log.upper()) + Log.info("Start simulator") + cmd_args = {} + for argument in args.__dict__: + if argument == "log": + continue + if args.__dict__[argument] is not None: + cmd_args[argument] = args.__dict__[argument] + return cmd_args + + +async def run_main(): + """Run server async.""" + cmd_args = get_commandline() + task = ModbusSimulatorServer(**cmd_args) + await task.run_forever() + + +def main(): + """Run server.""" + asyncio.run(run_main(), debug=True) + + +if __name__ == "__main__": + main() diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/setup.json b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/setup.json new file mode 100644 index 00000000..94c63f4f --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/setup.json @@ -0,0 +1,228 @@ +{ + "server_list": { + "server": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "ignore_missing_slaves": false, + "framer": "socket", + "identity": { + "VendorName": "pymodbus", + "ProductCode": "PM", + "VendorUrl": "https://github.com/pymodbus-dev/pymodbus/", + "ProductName": "pymodbus Server", + "ModelName": "pymodbus Server", + "MajorMinorRevision": "3.1.0" + } + }, + "server_try_serial": { + "comm": "serial", + "port": "/dev/tty0", + "stopbits": 1, + "bytesize": 8, + "parity": "N", + "baudrate": 9600, + "timeout": 3, + "reconnect_delay": 2, + "framer": "rtu", + "identity": { + "VendorName": "pymodbus", + "ProductCode": "PM", + "VendorUrl": "https://github.com/pymodbus-dev/pymodbus/", + "ProductName": "pymodbus Server", + "ModelName": "pymodbus Server", + "MajorMinorRevision": "3.1.0" + } + }, + "server_try_tls": { + "comm": "tls", + "host": "0.0.0.0", + "port": 5020, + "certfile": "certificates/pymodbus.crt", + "keyfile": "certificates/pymodbus.key", + "ignore_missing_slaves": false, + "framer": "tls", + "identity": { + "VendorName": "pymodbus", + "ProductCode": "PM", + "VendorUrl": "https://github.com/pymodbus-dev/pymodbus/", + "ProductName": "pymodbus Server", + "ModelName": "pymodbus Server", + "MajorMinorRevision": "3.1.0" + } + }, + "server_test_try_udp": { + "comm": "udp", + "host": "0.0.0.0", + "port": 5020, + "ignore_missing_slaves": false, + "framer": "socket", + "identity": { + "VendorName": "pymodbus", + "ProductCode": "PM", + "VendorUrl": "https://github.com/pymodbus-dev/pymodbus/", + "ProductName": "pymodbus Server", + "ModelName": "pymodbus Server", + "MajorMinorRevision": "3.1.0" + } + } + }, + "device_list": { + "device": { + "setup": { + "co size": 63000, + "di size": 63000, + "hr size": 63000, + "ir size": 63000, + "shared blocks": true, + "type exception": true, + "defaults": { + "value": { + "bits": 0, + "uint16": 0, + "uint32": 0, + "float32": 0.0, + "string": " " + }, + "action": { + "bits": null, + "uint16": "increment", + "uint32": "increment", + "float32": "increment", + "string": null + } + } + }, + "invalid": [ + 1 + ], + "write": [ + 3 + ], + "bits": [ + {"addr": 2, "value": 7} + ], + "uint16": [ + {"addr": 3, "value": 17001, "action": null}, + 2100 + ], + "uint32": [ + {"addr": [4, 5], "value": 617001, "action": null}, + [3037, 3038] + ], + "float32": [ + {"addr": [6, 7], "value": 404.17}, + [4100, 4101] + ], + "string": [ + 5047, + {"addr": [16, 20], "value": "A_B_C_D_E_"} + ], + "repeat": [ + ] + }, + "device_try": { + "setup": { + "co size": 63000, + "di size": 63000, + "hr size": 63000, + "ir size": 63000, + "shared blocks": true, + "type exception": true, + "defaults": { + "value": { + "bits": 0, + "uint16": 0, + "uint32": 0, + "float32": 0.0, + "string": " " + }, + "action": { + "bits": null, + "uint16": null, + "uint32": null, + "float32": null, + "string": null + } + } + }, + "invalid": [ + [0, 5], + 77 + ], + "write": [ + 10 + ], + "bits": [ + 10, + 1009, + [1116, 1119], + {"addr": 1144, "value": 1}, + {"addr": [1148,1149], "value": 32117}, + {"addr": [1208, 1306], "action": "random"} + ], + "uint16": [ + 11, + 2027, + [2126, 2129], + {"addr": 2164, "value": 1}, + {"addr": [2168,2169], "value": 32117}, + {"addr": [2208, 2304], "action": "increment"}, + {"addr": 2305, + "value": 50, + "action": "increment", + "parameters": {"minval": 45, "maxval": 155} + }, + {"addr": 2306, + "value": 50, + "action": "random", + "parameters": {"minval": 45, "maxval": 55} + } + ], + "uint32": [ + [12, 13], + [3037, 3038], + [3136, 3139], + {"addr": [3174, 3175], "value": 1}, + {"addr": [3188,3189], "value": 32514}, + {"addr": [3308, 3407], "action": null}, + {"addr": [3688, 3875], "value": 115, "action": "increment"}, + {"addr": [3876, 3877], + "value": 50000, + "action": "increment", + "parameters": {"minval": 45000, "maxval": 55000} + }, + {"addr": [3878, 3879], + "value": 50000, + "action": "random", + "parameters": {"minval": 45000, "maxval": 55000} + } + ], + "float32": [ + [14, 15], + [4047, 4048], + [4146, 4149], + {"addr": [4184, 4185], "value": 1}, + {"addr": [4188, 4191], "value": 32514.2}, + {"addr": [4308, 4407], "action": null}, + {"addr": [4688, 4875], "value": 115.7, "action": "increment"}, + {"addr": [4876, 4877], + "value": 50000.0, + "action": "increment", + "parameters": {"minval": 45000.0, "maxval": 55000.0} + }, + {"addr": [4878, 48779], + "value": 50000.0, + "action": "random", + "parameters": {"minval": 45000.0, "maxval": 55000.0} + } + ], + "string": [ + {"addr": [16, 20], "value": "A_B_C_D_E_"}, + {"addr": [529, 544], "value": "Brand name, 32 bytes...........X"} + ], + "repeat": [ + ] + } + } +} diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple120.png b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple120.png new file mode 100644 index 00000000..e14c3e3d Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple120.png differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple152.png b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple152.png new file mode 100644 index 00000000..ff01154f Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple152.png differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple60.png b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple60.png new file mode 100644 index 00000000..165b54d5 Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple60.png differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple76.png b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple76.png new file mode 100644 index 00000000..582a03a0 Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/apple76.png differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/favicon.ico b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/favicon.ico new file mode 100644 index 00000000..24cf2c39 Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/favicon.ico differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/calls b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/calls new file mode 100644 index 00000000..d12c50ff --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/calls @@ -0,0 +1,127 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/apple60.png"> + <link rel="apple-touch-icon" sizes="76x76" href="/apple76.png"> + <link rel="apple-touch-icon" sizes="120x120" href="/apple120.png"> + <link rel="apple-touch-icon" sizes="152x152" href="/apple152.png"> + <link rel="stylesheet" type="text/css" href="/pymodbus.css"> + <!--REFRESH--> +</head> +<body> + <h1><center>Calls</center></h1> + <table width="80%" class="listbox"> + <thead> + <tr> + <th width="20%">Call/Response</th> + <th width="10%">Address</th> + <th width="10%">Count</th> + <th width="60%">Data</th> + </tr> + </thead> + <tbody> + <!--FC_ROWS--> + </tbody> + <tfoot> + <tr> + <th colspan="4"><!--FC_FOOT--></th> + </tr> + </tfoot> + </table> + <fieldset class="tools_fieldset"> + <legend>Monitor</legend> + <form action="/api/calls" method="get"> + <table> + <tr> + <td><label>Register range</label></td> + <td> + <input type="number" value="FUNCTION_RANGE_START" name="range_start" /> + <input type="number" value="FUNCTION_RANGE_STOP" name="range_stop" /> + </td> + </tr> + <tr> + <td><label>Function</label></td> + <td> + <select name="function"> + <option value=-1 selected>Any</option> + <!--FUNCTION_CODES--> + </select> + </td> + </tr> + <tr> + <td><label>Show as</label></td> + <td> + <input type="checkbox" FUNCTION_SHOW_HEX_CHECKED name="show_hex">Hex</input> + <input type="checkbox" FUNCTION_SHOW_DECODED_CHECKED name="show_decode">Decoded</input> + </td> + </tr> + </table> + <input type="submit" value="Monitor" name="submit" /> + <input type="submit" value="Stop" name="submit" /> + </form> + </fieldset> +<fieldset class="tools_fieldset"> + <legend>Simulate <b><!--SIMULATION_ACTIVE--></b></legend> + <form action="/api/calls" method="get"> + <table> + <tr> + <td> + <input type="radio" name="response_type" value="2" FUNCTION_RESPONSE_EMPTY_CHECKED>Empty</input> + </td> + <td></td> + <td></td> + </tr> + <tr> + <td> + <input type="radio" name="response_type" value="0" FUNCTION_RESPONSE_NORMAL_CHECKED>Normal</input> + </td> + <td><Label>split response</Label></td> + <td> + <input type="checkbox" name="response_split" FUNCTION_RESPONSE_SPLIT_CHECKED/> + <input type="number" name="split_delay" value="FUNCTION_RESPONSE_SPLIT_DELAY"/>seconds delay + </td> + </tr> + <tr> + <td></td> + <td><Label>Change rate</Label></td> + <td> + <input type="checkbox" name="response_cr" FUNCTION_RESPONSE_CR_CHECKED/> + <input type="number" name="response_cr_pct" value="FUNCTION_RESPONSE_CR_PCT"/>% + </td> + </tr> + <tr> + <td></td> + <td><Label>Delay response</Label></td> + <td><input type="number" name="response_delay" value="FUNCTION_RESPONSE_DELAY"/>seconds</td> + </tr> + <tr> + <td> + <input type="radio" name="response_type" value="1" FUNCTION_RESPONSE_ERROR_CHECKED>Error</input> + </td> + <td></td> + <td> + <select name="response_error"> + <!--FUNCTION_ERROR--> + </select> + </td> + </tr> + <tr> + <td> + <input type="radio" name="response_type" value="3" FUNCTION_RESPONSE_JUNK_CHECKED>Junk</input> + </td> + <td><Label>Datalength</Label></td> + <td><input type="number" name="response_junk_datalen" value="FUNCTION_RESPONSE_JUNK" />bytes</td> + </tr> + <tr> + <td colspan="2"><Label>Clear after</Label></td> + <td><input type="number" name="response_clear_after" value="FUNCTION_RESPONSE_CLEAR_AFTER" />requests</td> + </tr> + </table> + <input type="submit" value="Simulate" name="submit" /> + <input type="submit" value="Reset" name="submit" /> + </form> + </fieldset> +</body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/log b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/log new file mode 100644 index 00000000..db903a5c --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/log @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator.</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/apple60.png"> + <link rel="apple-touch-icon" sizes="76x76" href="/apple76.png"> + <link rel="apple-touch-icon" sizes="120x120" href="/apple120.png"> + <link rel="apple-touch-icon" sizes="152x152" href="/apple152.png"> + <link rel="stylesheet" type="text/css" href="/pymodbus.css"> + <!--REFRESH--> +</head> +<body> + <center><h1>Log</h1></center> + <table width="80%" class="listbox"> + <thead> + <tr> + <th width="10%">Log entries</th> + </tr> + </thead> + <tbody> + <!--LOG_ROWS--> + </tbody> + <tfoot> + <tr> + <th colspan="7"><!--LOG_FOOT--></th> + </tr> + </tfoot> + </table> + <fieldset class="tools_fieldset" width="30%"> + <legend>Log</legend> + <form action="/api/log" method="get"> + <input type="submit" value="Download" name="submit" /> + <input type="submit" value="Monitor" name="submit" /> + <input type="submit" value="Clear" name="submit" /> + </form> + </fieldset> +</body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/pymodbus_icon_original.png b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/pymodbus_icon_original.png new file mode 100644 index 00000000..dfb60cd5 Binary files /dev/null and b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/pymodbus_icon_original.png differ diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/registers b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/registers new file mode 100644 index 00000000..be147550 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/registers @@ -0,0 +1,93 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/apple60.png"> + <link rel="apple-touch-icon" sizes="76x76" href="/apple76.png"> + <link rel="apple-touch-icon" sizes="120x120" href="/apple120.png"> + <link rel="apple-touch-icon" sizes="152x152" href="/apple152.png"> + <link rel="stylesheet" type="text/css" href="/pymodbus.css"> + <!--REFRESH--> +</head> +<body> + <h1><center>Registers</center></h1> + <table width="80%" class="listbox"> + <thead> + <tr> + <th width="10%">Register</th> + <th width="10%">Type</th> + <th width="10%">Write</th> + <th width="10%">Action</th> + <th width="10%">Value</th> + <th width="10%"># read</th> + <th width="10%"># write</th> + </tr> + </thead> + <tbody> + <!--REGISTER_ROWS--> + </tbody> + <tfoot> + <tr> + <th colspan="7"><!--REGISTER_FOOT--></th> + </tr> + </tfoot> + </table> + <fieldset class="tools_fieldset" width="40%"> + <legend>Filter registers</legend> + <form action="/api/registers" method="get"> + <table> + <tr> + <td><label>Start/end</label></td> + <td> + <input type="number" name="range_start" /> + <input type="number" name="range_stop" /> + </td> + </tr> + <tr> + <td><label>Type</label></td> + <td> + <select name="type"> + <option value=-1 selected>Any</option> + <!--REGISTER_TYPES--> + </select> + </td> + </tr> + <tr> + <td><label>Action</label></td> + <td> + <select name="action"> + <option value=-1 selected>Any</option> + <!--REGISTER_ACTIONS--> + </select> + </td> + </tr> + <tr> + <td><label>Writeable</label></td> + <td><input type="checkbox" name="writeable" /><br></td> + </tr> + </table> + <input type="submit" value="Add" name="submit" /> + <input type="submit" value="Clear" name="submit" /> + </form> + </fieldset> + <fieldset class="tools_fieldset" width="20%"> + <legend>Set</legend> + <form action="/api/registers" method="get"> + <table> + <tr> + <td><label>Register</label></td> + <td><input type="number" name="register" /></td> + </tr> + <tr> + <td><label>Value</label></td> + <td><input type="text" name="value" /></td> + </tr> + + </table> + <input type="submit" value="Set" name="submit" /> + </form> + </fieldset><br> + <p>Result of last command: <!--RESULT--></p> +</body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/server b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/server new file mode 100644 index 00000000..b628e787 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/generator/server @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator.</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/apple60.png"> + <link rel="apple-touch-icon" sizes="76x76" href="/apple76.png"> + <link rel="apple-touch-icon" sizes="120x120" href="/apple120.png"> + <link rel="apple-touch-icon" sizes="152x152" href="/apple152.png"> + <link rel="stylesheet" type="text/css" href="/pymodbus.css"> + <!--REFRESH--> +</head> +<body> + <body> + <center><h1>Server</h1></center> + <fieldset class="tools_fieldset" width="30%"> + <legend>Status</legend> + Uptime: <!--UPTIME--> + </fieldset> + <fieldset class="tools_fieldset" width="30%"> + <legend>Status</legend> + <form action="/api/server" method="get"> + <input type="submit" value="Restart" name="submit" /> + </form> + </fieldset> + </body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/index.html b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/index.html new file mode 100644 index 00000000..f0984420 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/index.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator.</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + <link rel="apple-touch-icon" href="/apple60.png"> + <link rel="apple-touch-icon" sizes="76x76" href="/apple76.png"> + <link rel="apple-touch-icon" sizes="120x120" href="/apple120.png"> + <link rel="apple-touch-icon" sizes="152x152" href="/apple152.png"> + <link rel="stylesheet" type="text/css" href="/pymodbus.css"> + <style rel="stylesheet" type="text/css" media="screen"> + .sidenav { + height: 100%; + width: 160px; + position: fixed; + z-index: 1; + top: 0; + left: 0; + background-color: gray; + overflow-x: hidden; + padding-top: 5px; + } + .main { + margin-left: 160px; + top: 0; + left: 0; + font-size: 28px; + padding: 0px 0px; + width: 100% - 160px; + height: 100%; + } + .sidenav legend { + color: white + } + </style> +</head> +<body> + <div class="sidenav"> + <a href="welcome.html" target="editor">Welcome</a> + <form action="/api" method="get" target="editor"> + <fieldset> + <legend>Refresh rate</legend> + <input type="number" style="width: 60%;" value=0 name="refresh"> + </fieldset> + <fieldset> + <legend>View</legend> + <input type="submit" formaction="/api/registers" value="Registers" name="submit" /><br> + <input type="submit" formaction="/api/calls" value="Calls" name="submit" /><br> + <input type="submit" formaction="/api/log" value="Log" name="submit" /><br> + <input type="submit" formaction="/api/server" value="Server" name="submit" /> + </fieldset> + </form> + <p>Powered by: + <a href="https://github.com/pymodbus-dev/pymodbus"><b>pymodbus</b></a> an open source project, patches are welcome. + </p> + </div> + <div class="main"> + <iframe name="editor" title="Simulator" src="welcome.html"/> + </div> +</body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/pymodbus.css b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/pymodbus.css new file mode 100644 index 00000000..bac637dc --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/pymodbus.css @@ -0,0 +1,62 @@ +html { + height: 100%; + width: 100%; +} +body { + height: 100%; + width: 100%; + background-color: bisque; +} +table.listbox { + border-collapse: collapse; + border: 1px solid black; +} +table.listbox th { + background-color: lightgray; + border: 1px solid black; + padding: 5px +} +table.listbox td { + border: 1px solid black; + text-align: right; + background-color: #f1f1f1; + padding: 5px +} +legend { + font-size: 18px; + font-weight: bold; + position: relative; + color: black; + padding: 5px 5px; +} +a { + padding: 2px 4px 2px 4px; + text-decoration: none; + font-size: 18px; + display: block; +} +a:hover { + color: #f1f1f1; +} +p { + padding: 2px 4px 6px 4px; + display: block; +} +iframe { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + border: 0; +} +input[type="submit"] { + font-size: 14px; + background-color: lightblue; + margin-top: 10px; +} + + +.tools_fieldset { + display: inline; + vertical-align:top +} diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/welcome.html b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/welcome.html new file mode 100644 index 00000000..46e18398 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/server/simulator/web/welcome.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Modbus simulator.</title> + <link rel="icon" type="image/x-icon" href="/favicon.ico"> + </style> +</head> +<body> + <center><h1>Welcome to the pymodbus simulator</h1></center> + <p>Thanks for using pymodbus.</p> + <p>the pymodbus development team</p> + <br><br> + The <b>View</b> to the left, are used to control the simulator. + <ul> + <li><b>Registers</b> are used to monitor and/or change registers in the configuration (non-resistent),</li> + <li><b>Calls</b> are used to show and/or modify call from clients,</li> + <li><b>Log</b> are used to show the server log,</li> + <li><b>Server</b> are used to control the server.</li> + </ul> +</body> +</html> diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transaction.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transaction.py new file mode 100644 index 00000000..1b2f9e61 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transaction.py @@ -0,0 +1,435 @@ +"""Collection of transaction based abstractions.""" +from __future__ import annotations + + +__all__ = [ + "ModbusTransactionManager", + "SyncModbusTransactionManager", +] + +import struct +import time +from contextlib import suppress +from threading import RLock +from typing import TYPE_CHECKING + +from pymodbus.exceptions import ( + ConnectionException, + InvalidMessageReceivedException, + ModbusIOException, +) +from pymodbus.framer import ( + FramerAscii, + FramerRTU, + FramerSocket, + FramerTLS, +) +from pymodbus.logging import Log +from pymodbus.pdu import ModbusPDU +from pymodbus.transport import CommType +from pymodbus.utilities import ModbusTransactionState, hexlify_packets + + +if TYPE_CHECKING: + from pymodbus.client.base import ModbusBaseSyncClient + + +# --------------------------------------------------------------------------- # +# The Global Transaction Manager +# --------------------------------------------------------------------------- # +class ModbusTransactionManager: + """Implement a transaction for a manager. + + Results are keyed based on the supplied transaction id. + """ + + def __init__(self): + """Initialize an instance of the ModbusTransactionManager.""" + self.tid = 0 + self.transactions: dict[int, ModbusPDU] = {} + + def __iter__(self): + """Iterate over the current managed transactions. + + :returns: An iterator of the managed transactions + """ + return iter(self.transactions.keys()) + + def addTransaction(self, request: ModbusPDU): + """Add a transaction to the handler. + + This holds the request in case it needs to be resent. + After being sent, the request is removed. + + :param request: The request to hold on to + """ + tid = request.transaction_id + Log.debug("Adding transaction {}", tid) + self.transactions[tid] = request + + def getTransaction(self, tid: int): + """Return a transaction matching the referenced tid. + + If the transaction does not exist, None is returned + + :param tid: The transaction to retrieve + + """ + Log.debug("Getting transaction {}", tid) + if not tid: + if self.transactions: + ret = self.transactions.popitem()[1] + self.transactions.clear() + return ret + return None + return self.transactions.pop(tid, None) + + def delTransaction(self, tid: int): + """Remove a transaction matching the referenced tid. + + :param tid: The transaction to remove + """ + Log.debug("deleting transaction {}", tid) + self.transactions.pop(tid, None) + + def getNextTID(self) -> int: + """Retrieve the next unique transaction identifier. + + This handles incrementing the identifier after + retrieval + + :returns: The next unique transaction identifier + """ + if self.tid < 65000: + self.tid += 1 + else: + self.tid = 1 + return self.tid + + def reset(self): + """Reset the transaction identifier.""" + self.tid = 0 + self.transactions = {} + + +class SyncModbusTransactionManager(ModbusTransactionManager): + """Implement a transaction for a manager. + + The transaction protocol can be represented by the following pseudo code:: + + count = 0 + do + result = send(message) + if (timeout or result == bad) + count++ + else break + while (count < 3) + + This module helps to abstract this away from the framer and protocol. + + Results are keyed based on the supplied transaction id. + """ + + def __init__(self, client: ModbusBaseSyncClient, retries): + """Initialize an instance of the ModbusTransactionManager.""" + super().__init__() + self.client: ModbusBaseSyncClient = client + self.retries = retries + self._transaction_lock = RLock() + self._no_response_devices: list[int] = [] + self.databuffer = b'' + if client: + self._set_adu_size() + + def _set_adu_size(self): + """Set adu size.""" + # base ADU size of modbus frame in bytes + if isinstance(self.client.framer, FramerSocket): + self.base_adu_size = 7 # tid(2), pid(2), length(2), uid(1) + elif isinstance(self.client.framer, FramerRTU): + self.base_adu_size = 3 # address(1), CRC(2) + elif isinstance(self.client.framer, FramerAscii): + self.base_adu_size = 7 # start(1)+ Address(2), LRC(2) + end(2) + elif isinstance(self.client.framer, FramerTLS): + self.base_adu_size = 0 # no header and footer + else: + self.base_adu_size = -1 + + def _calculate_response_length(self, expected_pdu_size): + """Calculate response length.""" + if self.base_adu_size == -1: + return None + return self.base_adu_size + expected_pdu_size + + def _calculate_exception_length(self): + """Return the length of the Modbus Exception Response according to the type of Framer.""" + if isinstance(self.client.framer, (FramerSocket, FramerTLS)): + return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) + if isinstance(self.client.framer, FramerAscii): + return self.base_adu_size + 4 # Fcode(2), ExceptionCode(2) + if isinstance(self.client.framer, FramerRTU): + return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) + return None + + def _validate_response(self, response): + """Validate Incoming response against request.""" + if not response: + return False + return True + + def execute(self, no_response_expected: bool, request: ModbusPDU): # noqa: C901 + """Start the producer to send the next request to consumer.write(Frame(request)).""" + with self._transaction_lock: + try: + Log.debug( + "Current transaction state - {}", + ModbusTransactionState.to_string(self.client.state), + ) + retries = self.retries + if isinstance(self.client.framer, FramerSocket): + request.transaction_id = self.getNextTID() + else: + request.transaction_id = 0 + Log.debug("Running transaction {}", request.transaction_id) + if _buffer := hexlify_packets( + self.client.framer.databuffer + ): + Log.debug("Clearing current Frame: - {}", _buffer) + self.client.framer.databuffer = b'' + expected_response_length = None + if not isinstance(self.client.framer, FramerSocket): + response_pdu_size = request.get_response_pdu_size() + if isinstance(self.client.framer, FramerAscii): + response_pdu_size *= 2 + if response_pdu_size: + expected_response_length = ( + self._calculate_response_length(response_pdu_size) + ) + if ( # pylint: disable=simplifiable-if-statement + request.slave_id in self._no_response_devices + ): + full = True + else: + full = False + if self.client.comm_params.comm_type == CommType.UDP: + full = True + if not expected_response_length: + expected_response_length = 1024 + response, last_exception = self._transact( + no_response_expected, + request, + expected_response_length, + full=full, + ) + if no_response_expected: + return None + while retries > 0: + if self._validate_response(response): + if ( + request.slave_id in self._no_response_devices + and response + ): + self._no_response_devices.remove(request.slave_id) + Log.debug("Got response!!!") + break + if not response: + if request.slave_id not in self._no_response_devices: + self._no_response_devices.append(request.slave_id) + # No response received and retries not enabled + break + self.databuffer += response + used_len, pdu = self.client.framer.processIncomingFrame(self.databuffer) + self.databuffer = self.databuffer[used_len:] + if pdu: + self.addTransaction(pdu) + if not (result := self.getTransaction(request.transaction_id)): + if len(self.transactions): + result = self.getTransaction(0) + else: + last_exception = last_exception or ( + "No Response received from the remote slave" + "/Unable to decode response" + ) + result = ModbusIOException( + last_exception, request.function_code + ) + self.client.close() + if hasattr(self.client, "state"): + Log.debug( + "Changing transaction state from " + '"PROCESSING REPLY" to ' + '"TRANSACTION_COMPLETE"' + ) + self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE + return result + except ModbusIOException as exc: + # Handle decode errors method + Log.error("Modbus IO exception {}", exc) + self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE + self.client.close() + return exc + + def _retry_transaction(self, no_response_expected, retries, reason, packet, response_length, full=False): + """Retry transaction.""" + Log.debug("Retry on {} response - {}", reason, retries) + Log.debug('Changing transaction state from "WAITING_FOR_REPLY" to "RETRYING"') + self.client.state = ModbusTransactionState.RETRYING + self.client.connect() + if hasattr(self.client, "_in_waiting"): + if ( + in_waiting := self.client._in_waiting() # pylint: disable=protected-access + ): + if response_length == in_waiting: + result = self._recv(response_length, full) + return result, None + return self._transact(no_response_expected, packet, response_length, full=full) + + def _transact(self, no_response_expected: bool, request: ModbusPDU, response_length, full=False): + """Do a Write and Read transaction.""" + last_exception = None + try: + self.client.connect() + packet = self.client.framer.buildFrame(request) + Log.debug("SEND: {}", packet, ":hex") + size = self._send(packet) + if ( + isinstance(size, bytes) + and self.client.state == ModbusTransactionState.RETRYING + ): + Log.debug( + "Changing transaction state from " + '"RETRYING" to "PROCESSING REPLY"' + ) + self.client.state = ModbusTransactionState.PROCESSING_REPLY + return size, None + if self.client.comm_params.handle_local_echo is True: + if self._recv(size, full) != packet: + return b"", "Wrong local echo" + if no_response_expected: + if size: + Log.debug( + 'Changing transaction state from "SENDING" ' + 'to "TRANSACTION_COMPLETE"' + ) + self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE + return b"", None + if size: + Log.debug( + 'Changing transaction state from "SENDING" ' + 'to "WAITING FOR REPLY"' + ) + self.client.state = ModbusTransactionState.WAITING_FOR_REPLY + result = self._recv(response_length, full) + # result2 = self._recv(response_length, full) + Log.debug("RECV: {}", result, ":hex") + except (OSError, ModbusIOException, InvalidMessageReceivedException, ConnectionException) as msg: + self.client.close() + Log.debug("Transaction failed. ({}) ", msg) + last_exception = msg + result = b"" + return result, last_exception + + def _send(self, packet: bytes, _retrying=False): + """Send.""" + return self.client.send(packet) + + def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 + """Receive.""" + total = None + if not full: + exception_length = self._calculate_exception_length() + if isinstance(self.client.framer, FramerSocket): + min_size = 8 + elif isinstance(self.client.framer, FramerRTU): + min_size = 4 + elif isinstance(self.client.framer, FramerAscii): + min_size = 5 + else: + min_size = expected_response_length + + read_min = self.client.recv(min_size) + if min_size and len(read_min) != min_size: + msg_start = "Incomplete message" if read_min else "No response" + raise InvalidMessageReceivedException( + f"{msg_start} received, expected at least {min_size} bytes " + f"({len(read_min)} received)" + ) + if read_min: + if isinstance(self.client.framer, FramerSocket): + func_code = int(read_min[-1]) + elif isinstance(self.client.framer, FramerRTU): + func_code = int(read_min[1]) + elif isinstance(self.client.framer, FramerAscii): + func_code = int(read_min[3:5], 16) + else: + func_code = -1 + + if func_code < 0x80: # Not an error + if isinstance(self.client.framer, FramerSocket): + length = struct.unpack(">H", read_min[4:6])[0] - 1 + expected_response_length = 7 + length + elif expected_response_length is None and isinstance( + self.client.framer, FramerRTU + ): + with suppress( + IndexError # response length indeterminate with available bytes + ): + expected_response_length = ( + self._get_expected_response_length( + read_min + ) + ) + if expected_response_length is not None: + expected_response_length -= min_size + total = expected_response_length + min_size + else: + expected_response_length = exception_length - min_size + total = expected_response_length + min_size + else: + total = expected_response_length + retries = 0 + missing_len = expected_response_length + result = read_min + while missing_len and retries < self.retries: + if retries: + time.sleep(0.1) + data = self.client.recv(expected_response_length) + result += data + missing_len -= len(data) + retries += 1 + else: + read_min = b"" + total = expected_response_length + result = self.client.recv(expected_response_length) + result = read_min + result + actual = len(result) + if total is not None and actual != total: + msg_start = "Incomplete message" if actual else "No response" + Log.debug( + "{} received, Expected {} bytes Received {} bytes !!!!", + msg_start, + total, + actual, + ) + elif not actual: + # If actual == 0 and total is not None then the above + # should be triggered, so total must be None here + Log.debug("No response received to unbounded read !!!!") + if self.client.state != ModbusTransactionState.PROCESSING_REPLY: + Log.debug( + "Changing transaction state from " + '"WAITING FOR REPLY" to "PROCESSING REPLY"' + ) + self.client.state = ModbusTransactionState.PROCESSING_REPLY + return result + + def _get_expected_response_length(self, data) -> int: + """Get the expected response length. + + :param data: Message data read so far + :raises IndexError: If not enough data to read byte count + :return: Total frame size + """ + func_code = int(data[1]) + pdu_class = self.client.framer.decoder.lookupPduClass(func_code) + return pdu_class.calculateRtuFrameSize(data) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/__init__.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/__init__.py new file mode 100644 index 00000000..dd8ce04f --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/__init__.py @@ -0,0 +1,14 @@ +"""Transport.""" +__all__ = [ + "CommParams", + "CommType", + "ModbusProtocol", + "NULLMODEM_HOST", +] + +from pymodbus.transport.transport import ( + NULLMODEM_HOST, + CommParams, + CommType, + ModbusProtocol, +) diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/serialtransport.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/serialtransport.py new file mode 100644 index 00000000..11759c2a --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/serialtransport.py @@ -0,0 +1,181 @@ +"""asyncio serial support for modbus (based on pyserial).""" +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys + + +with contextlib.suppress(ImportError): + import serial + + +class SerialTransport(asyncio.Transport): + """An asyncio serial transport.""" + + force_poll: bool = os.name == "nt" + + def __init__(self, loop, protocol, url, baudrate, bytesize, parity, stopbits, timeout) -> None: + """Initialize.""" + super().__init__() + if "serial" not in sys.modules: + raise RuntimeError( + "Serial client requires pyserial " + 'Please install with "pip install pyserial" and try again.' + ) + self.async_loop = loop + self.intern_protocol: asyncio.BaseProtocol = protocol + self.sync_serial = serial.serial_for_url(url, exclusive=True, + baudrate=baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=timeout +) + self.intern_write_buffer: list[bytes] = [] + self.poll_task: asyncio.Task | None = None + self._poll_wait_time = 0.0005 + self.sync_serial.timeout = 0 + self.sync_serial.write_timeout = 0 + + def setup(self) -> None: + """Prepare to read/write.""" + if self.force_poll: + self.poll_task = asyncio.create_task(self.polling_task()) + self.poll_task.set_name("SerialTransport poll") + else: + self.async_loop.add_reader(self.sync_serial.fileno(), self.intern_read_ready) + self.async_loop.call_soon(self.intern_protocol.connection_made, self) + + def close(self, exc: Exception | None = None) -> None: + """Close the transport gracefully.""" + if not self.sync_serial: + return + self.flush() + if self.poll_task: + self.poll_task.cancel() + self.poll_task = None + else: + self.async_loop.remove_reader(self.sync_serial.fileno()) + self.async_loop.remove_writer(self.sync_serial.fileno()) + self.sync_serial.close() + self.sync_serial = None # type: ignore[assignment] + if exc: + with contextlib.suppress(Exception): + self.intern_protocol.connection_lost(exc) + + def write(self, data) -> None: + """Write some data to the transport.""" + self.intern_write_buffer.append(data) + if not self.force_poll: + self.async_loop.add_writer(self.sync_serial.fileno(), self.intern_write_ready) + + def flush(self) -> None: + """Clear output buffer and stops any more data being written.""" + if not self.poll_task: + self.async_loop.remove_writer(self.sync_serial.fileno()) + self.intern_write_buffer.clear() + + # ------------------------------------------------ + # Dummy methods needed to please asyncio.Transport. + # ------------------------------------------------ + @property + def loop(self): + """Return asyncio event loop.""" + return self.async_loop + + def get_protocol(self) -> asyncio.BaseProtocol: + """Return protocol.""" + return self.intern_protocol + + def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: + """Set protocol.""" + self.intern_protocol = protocol + + def get_write_buffer_limits(self) -> tuple[int, int]: + """Return buffer sizes.""" + return (1, 1024) + + def can_write_eof(self): + """Return Serial do not support end-of-file.""" + return False + + def write_eof(self): + """Write end of file marker.""" + + def set_write_buffer_limits(self, high=None, low=None): + """Set the high- and low-water limits for write flow control.""" + + def get_write_buffer_size(self): + """Return The number of bytes in the write buffer.""" + return len(self.intern_write_buffer) + + def is_reading(self) -> bool: + """Return true if read is active.""" + return True + + def pause_reading(self): + """Pause receiver.""" + + def resume_reading(self): + """Resume receiver.""" + + def is_closing(self): + """Return True if the transport is closing or closed.""" + return False + + def abort(self) -> None: + """Alias for closing the connection.""" + self.close() + + # ------------------------------------------------ + + def intern_read_ready(self) -> None: + """Test if there are data waiting.""" + try: + if data := self.sync_serial.read(1024): + self.intern_protocol.data_received(data) # type: ignore[attr-defined] + except serial.SerialException as exc: + self.close(exc=exc) + + def intern_write_ready(self) -> None: + """Asynchronously write buffered data.""" + data = b"".join(self.intern_write_buffer) + try: + if (nlen := self.sync_serial.write(data)) and nlen < len(data): + self.intern_write_buffer = [data[nlen:]] + if not self.poll_task: + self.async_loop.add_writer( + self.sync_serial.fileno(), self.intern_write_ready + ) + return + self.flush() + except (BlockingIOError, InterruptedError): + return + except serial.SerialException as exc: + self.close(exc=exc) + + async def polling_task(self): + """Poll and try to read/write.""" + while self.sync_serial: + await asyncio.sleep(self._poll_wait_time) + while self.intern_write_buffer: + self.intern_write_ready() + if self.sync_serial.in_waiting: + self.intern_read_ready() + +async def create_serial_connection( + loop, protocol_factory, url, + baudrate=None, + bytesize=None, + parity=None, + stopbits=None, + timeout=None, +) -> tuple[asyncio.Transport, asyncio.BaseProtocol]: + """Create a connection to a new serial port instance.""" + protocol = protocol_factory() + transport = SerialTransport(loop, protocol, url, + baudrate, + bytesize, + parity, + stopbits, + timeout) + loop.call_soon(transport.setup) + return transport, protocol diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/transport.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/transport.py new file mode 100644 index 00000000..7fa1a92d --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/transport/transport.py @@ -0,0 +1,660 @@ +"""ModbusProtocol layer. + +Contains pure transport methods needed to +- connect/listen, +- send/receive +- close/abort connections +for unix socket, tcp, tls and serial communications as well as a special +null modem option. + +Contains high level methods like reconnect. + +All transport differences are handled in transport, providing a unified +interface to upper layers. + +Host/Port/SourceAddress explanation: +- SourceAddress (host, port): +- server (host, port): Listen on host:port +- server serial (comm_port, _): comm_port is device string +- client (host, port): Bind host:port to interface +- client serial: not used +- Host +- server: not used +- client: remote host to connect to (as host:port) +- client serial: host is comm_port device string +- Port +- server: not used +- client: remote port to connect to (as host:port) +- client serial: no used + +Pyserial allow the comm_port to be a socket e.g. "socket://localhost:502", +this allows serial clients to connect to a tcp server with RTU framer. + +Pymodbus allows this format for both server and client. +For clients the string is passed to pyserial, +but for servers it is used to start a modbus tcp server. +This allows for serial testing, without a serial cable. + +Pymodbus offers nullmodem for clients/servers running in the same process +if <host> is set to NULLMODEM_HOST it will be automatically invoked. +This allows testing without actual network traffic and is a lot faster. + +Class NullModem is a asyncio transport class, +that replaces the socket class or pyserial. + +The class is designed to take care of differences between the different +transport mediums, and provide a neutral interface for the upper layers. +It basically provides a pipe, without caring about the actual data content. +""" +from __future__ import annotations + +import asyncio +import dataclasses +import ssl +from abc import abstractmethod +from collections.abc import Callable, Coroutine +from contextlib import suppress +from enum import Enum +from functools import partial +from typing import Any + +from pymodbus.logging import Log +from pymodbus.transport.serialtransport import create_serial_connection + + +NULLMODEM_HOST = "__pymodbus_nullmodem" + + +class CommType(Enum): + """Type of transport.""" + + TCP = 1 + TLS = 2 + UDP = 3 + SERIAL = 4 + + +@dataclasses.dataclass +class CommParams: + """Parameter class.""" + + # generic + comm_name: str | None = None + comm_type: CommType | None = None + reconnect_delay: float | None = None + reconnect_delay_max: float = 0.0 + timeout_connect: float = 0.0 + host: str = "localhost" # On some machines this will now be ::1 + port: int = 0 + source_address: tuple[str, int] | None = None + handle_local_echo: bool = False + + # tls + sslctx: ssl.SSLContext | None = None + + # serial + baudrate: int = -1 + bytesize: int = -1 + parity: str = '' + stopbits: int = -1 + + @classmethod + def generate_ssl( + cls, + is_server: bool, + certfile: str | None = None, + keyfile: str | None = None, + password: str | None = None, + sslctx: ssl.SSLContext | None = None, + ) -> ssl.SSLContext: + """Generate sslctx from cert/key/password. + + MODBUS/TCP Security Protocol Specification demands TLSv2 at least + """ + if sslctx: + return sslctx + new_sslctx = ssl.SSLContext( + ssl.PROTOCOL_TLS_SERVER if is_server else ssl.PROTOCOL_TLS_CLIENT + ) + new_sslctx.check_hostname = False + new_sslctx.verify_mode = ssl.CERT_NONE + new_sslctx.minimum_version = ssl.TLSVersion.TLSv1_2 + new_sslctx.maximum_version = ssl.TLSVersion.TLSv1_3 + if certfile: + new_sslctx.load_cert_chain( + certfile=certfile, keyfile=keyfile, password=password + ) + return new_sslctx + + def copy(self) -> CommParams: + """Create a copy.""" + return dataclasses.replace(self) + + +class ModbusProtocol(asyncio.BaseProtocol): + """Protocol layer including transport.""" + + def __init__( + self, + params: CommParams, + is_server: bool, + ) -> None: + """Initialize a transport instance. + + :param params: parameter dataclass + :param is_server: true if object act as a server (listen/connect) + """ + self.comm_params = params.copy() + self.is_server = is_server + self.is_closing = False + + self.transport: asyncio.BaseTransport = None # type: ignore[assignment] + self.loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self.recv_buffer: bytes = b"" + self.call_create: Callable[[], Coroutine[Any, Any, Any]] = None # type: ignore[assignment] + self.reconnect_task: asyncio.Task | None = None + self.listener: ModbusProtocol | None = None + self.active_connections: dict[str, ModbusProtocol] = {} + self.unique_id: str = str(id(self)) + self.reconnect_delay_current = 0.0 + self.sent_buffer: bytes = b"" + if self.is_server: + if self.comm_params.source_address is not None: + host = self.comm_params.source_address[0] + port = int(self.comm_params.source_address[1]) + else: + # This behaviour isn't quite right. + # It listens on any IPv4 address rather than the more natural default of any address (v6 or v4). + host = "0.0.0.0" # Any IPv4 host + port = 0 # Server will select an ephemeral port for itself + else: + host = self.comm_params.host + port = int(self.comm_params.port) + if self.comm_params.comm_type == CommType.SERIAL and NULLMODEM_HOST in host: + host, port = NULLMODEM_HOST, int(host[9:].split(":")[1]) + if host == NULLMODEM_HOST: + self.call_create = partial(self.create_nullmodem, port) + return + if ( + self.comm_params.comm_type == CommType.SERIAL + and self.is_server + and host.startswith("socket") + ): + # format is "socket://<host>:port" + self.comm_params.comm_type = CommType.TCP + parts = host.split(":") + host, port = parts[1][2:], int(parts[2]) + self.init_setup_connect_listen(host, port) + + def init_setup_connect_listen(self, host: str, port: int) -> None: + """Handle connect/listen handler.""" + if self.comm_params.comm_type == CommType.SERIAL: + self.call_create = partial(create_serial_connection, + self.loop, + self.handle_new_connection, + host, + baudrate=self.comm_params.baudrate, + bytesize=self.comm_params.bytesize, + parity=self.comm_params.parity, + stopbits=self.comm_params.stopbits, + timeout=self.comm_params.timeout_connect, + ) + return + if self.comm_params.comm_type == CommType.UDP: + if self.is_server: + self.call_create = partial(self.loop.create_datagram_endpoint, + self.handle_new_connection, + local_addr=(host, port), + ) + else: + self.call_create = partial(self.loop.create_datagram_endpoint, + self.handle_new_connection, + remote_addr=(host, port), + ) + return + # TLS and TCP + if self.is_server: + self.call_create = partial(self.loop.create_server, + self.handle_new_connection, + host, + port, + ssl=self.comm_params.sslctx, + reuse_address=True, + start_serving=True, + ) + else: + self.call_create = partial(self.loop.create_connection, + self.handle_new_connection, + host, + port, + local_addr=self.comm_params.source_address, + ssl=self.comm_params.sslctx, + ) + + async def connect(self) -> bool: + """Handle generic connect and call on to specific transport connect.""" + Log.debug("Connecting {}", self.comm_params.comm_name) + self.is_closing = False + try: + self.transport, _protocol = await asyncio.wait_for( + self.call_create(), + timeout=self.comm_params.timeout_connect, + ) + except (asyncio.TimeoutError, OSError) as exc: # pylint: disable=overlapping-except + Log.warning("Failed to connect {}", exc) + return False + return bool(self.transport) + + async def listen(self) -> bool: + """Handle generic listen and call on to specific transport listen.""" + Log.debug("Awaiting connections {}", self.comm_params.comm_name) + self.is_closing = False + try: + self.transport = await self.call_create() + if isinstance(self.transport, tuple): + self.transport = self.transport[0] + except OSError as exc: + Log.warning("Failed to start server {}", exc) + self.__close() + return False + return True + + # --------------------------------------- # + # ModbusProtocol asyncio standard methods # + # --------------------------------------- # + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Call from asyncio, when a connection is made. + + :param transport: socket etc. representing the connection. + """ + Log.debug("Connected to {}", self.comm_params.comm_name) + self.transport = transport + self.reset_delay() + self.callback_connected() + + def connection_lost(self, reason: Exception | None) -> None: + """Call from asyncio, when the connection is lost or closed. + + :param reason: None or an exception object + """ + if not self.transport or self.is_closing: + return + Log.debug("Connection lost {} due to {}", self.comm_params.comm_name, reason) + self.__close() + if self.is_server: + self.reconnect_task = asyncio.create_task(self.do_relisten()) + self.reconnect_task.set_name("transport relisten") + elif not self.listener and self.comm_params.reconnect_delay: + self.reconnect_task = asyncio.create_task(self.do_reconnect()) + self.reconnect_task.set_name("transport reconnect") + self.callback_disconnected(reason) + + def data_received(self, data: bytes) -> None: + """Call when some data is received. + + :param data: non-empty bytes object with incoming data. + """ + self.datagram_received(data, None) + + def datagram_received(self, data: bytes, addr: tuple | None) -> None: + """Receive datagram (UDP connections).""" + if self.comm_params.handle_local_echo and self.sent_buffer: + if data.startswith(self.sent_buffer): + Log.debug( + "recv skipping (local_echo): {} addr={}", + self.sent_buffer, + ":hex", + addr, + ) + data = data[len(self.sent_buffer) :] + self.sent_buffer = b"" + elif self.sent_buffer.startswith(data): + Log.debug( + "recv skipping (partial local_echo): {} addr={}", data, ":hex", addr + ) + self.sent_buffer = self.sent_buffer[len(data) :] + return + else: + Log.debug("did not receive local echo: {} addr={}", data, ":hex", addr) + self.sent_buffer = b"" + if not data: + return + Log.debug( + "recv: {} old_data: {} addr={}", + data, + ":hex", + self.recv_buffer, + ":hex", + addr, + ) + self.recv_buffer += data + cut = self.callback_data(self.recv_buffer, addr=addr) + self.recv_buffer = self.recv_buffer[cut:] + if self.recv_buffer: + Log.debug( + "recv, unused data waiting for next packet: {}", + self.recv_buffer, + ":hex", + ) + + def eof_received(self) -> None: + """Accept other end terminates connection.""" + Log.debug("-> transport: received eof") + + def error_received(self, exc): + """Get error detected in UDP.""" + Log.debug("-> error_received {}", exc) + + # --------- # + # callbacks # + # --------- # + @abstractmethod + def callback_new_connection(self) -> ModbusProtocol: + """Call when listener receive new connection request.""" + + @abstractmethod + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + + @abstractmethod + def callback_disconnected(self, exc: Exception | None) -> None: + """Call when connection is lost.""" + + @abstractmethod + def callback_data(self, data: bytes, addr: tuple | None = None) -> int: + """Handle received data.""" + + # ----------------------------------- # + # Helper methods for external classes # + # ----------------------------------- # + def send(self, data: bytes, addr: tuple | None = None) -> None: + """Send modbus message. + + :param data: non-empty bytes object with data to send. + :param addr: optional addr, only used for UDP server. + """ + if not self.transport: + Log.error("Cancel send, because not connected!") + return + Log.debug("send: {}", data, ":hex") + self.recv_buffer = b"" + if self.comm_params.handle_local_echo: + self.sent_buffer += data + if self.comm_params.comm_type == CommType.UDP: + if addr: + self.transport.sendto(data, addr=addr) # type: ignore[attr-defined] + else: + self.transport.sendto(data) # type: ignore[attr-defined] + else: + self.transport.write(data) # type: ignore[attr-defined] + + def __close(self, reconnect: bool = False) -> None: + """Close connection (internal). + + :param reconnect: (default false), try to reconnect + """ + if self.transport: + self.transport.close() + self.transport = None # type: ignore[assignment] + self.recv_buffer = b"" + if self.is_server: + for _key, value in self.active_connections.items(): + value.listener = None + value.callback_disconnected(None) + value.close() + self.active_connections = {} + return + if not reconnect and self.reconnect_task: + self.reconnect_task.cancel() + self.reconnect_task = None + self.reconnect_delay_current = 0.0 + if self.listener: + self.listener.active_connections.pop(self.unique_id) + + def close(self, reconnect: bool = False) -> None: + """Close connection (external). + + :param reconnect: (default false), try to reconnect + """ + if self.is_closing: + return + self.is_closing = True + self.__close(reconnect=reconnect) + + def reset_delay(self) -> None: + """Reset wait time before next reconnect to minimal period.""" + self.reconnect_delay_current = self.comm_params.reconnect_delay or 0.0 + + def is_active(self) -> bool: + """Return true if connected/listening.""" + return bool(self.transport) + + # ---------------- # + # Internal methods # + # ---------------- # + async def create_nullmodem( + self, port + ) -> tuple[asyncio.Transport, asyncio.BaseProtocol]: + """Bypass create_ and use null modem.""" + if self.is_server: + # Listener object + self.transport = NullModem.set_listener(port, self) + return self.transport, self + + # connect object + return NullModem.set_connection(port, self) + + def handle_new_connection(self) -> ModbusProtocol: + """Handle incoming connect.""" + if not self.is_server: + # Clients reuse the same object. + return self + + new_protocol = self.callback_new_connection() + self.active_connections[new_protocol.unique_id] = new_protocol + new_protocol.listener = self + return new_protocol + + async def do_relisten(self) -> None: + """Handle reconnect as a task.""" + try: + Log.debug("Wait 1s before reopening listener.") + await asyncio.sleep(1) + await self.listen() + except asyncio.CancelledError: + pass + self.reconnect_task = None + + async def do_reconnect(self) -> None: + """Handle reconnect as a task.""" + try: + self.reconnect_delay_current = self.comm_params.reconnect_delay or 0.0 + while True: + Log.debug( + "Wait {} {} ms before reconnecting.", + self.comm_params.comm_name, + self.reconnect_delay_current * 1000, + ) + await asyncio.sleep(self.reconnect_delay_current) + if await self.connect(): + break + self.reconnect_delay_current = min( + 2 * self.reconnect_delay_current, + self.comm_params.reconnect_delay_max, + ) + except asyncio.CancelledError: + pass + self.reconnect_task = None + + # ----------------- # + # The magic methods # + # ----------------- # + async def __aenter__(self) -> ModbusProtocol: + """Implement the client with async enter block.""" + return self + + async def __aexit__(self, _class, _value, _traceback) -> None: + """Implement the client with async exit block.""" + self.close() + + def __str__(self) -> str: + """Build a string representation of the connection.""" + return f"{self.__class__.__name__}({self.comm_params.comm_name})" + + +class NullModem(asyncio.DatagramTransport, asyncio.Transport): + """ModbusProtocol layer. + + Contains methods to act as a null modem between 2 objects. + (Allowing tests to be shortcut without actual network calls) + """ + + listeners: dict[int, ModbusProtocol] = {} + connections: dict[NullModem, int] = {} + + def __init__(self, protocol: ModbusProtocol, listen: int | None = None) -> None: + """Create half part of null modem.""" + asyncio.DatagramTransport.__init__(self) + asyncio.Transport.__init__(self) + self.protocol: ModbusProtocol = protocol + self.other_modem: NullModem = None # type: ignore[assignment] + self.listen = listen + self.manipulator: Callable[[bytes], list[bytes]] | None = None + self._is_closing = False + + # -------------------------- # + # external nullmodem methods # + # -------------------------- # + @classmethod + def set_listener(cls, port: int, parent: ModbusProtocol) -> NullModem: + """Register listener.""" + if port in cls.listeners: + raise AssertionError(f"Port {port} already listening !") + cls.listeners[port] = parent + return NullModem(parent, listen=port) + + @classmethod + def set_connection( + cls, port: int, parent: ModbusProtocol + ) -> tuple[NullModem, ModbusProtocol]: + """Connect to listener.""" + if port not in cls.listeners: + raise asyncio.TimeoutError(f"Port {port} not being listened on !") + + client_protocol = parent.handle_new_connection() + server_protocol = NullModem.listeners[port].handle_new_connection() + client_transport = NullModem(client_protocol) + server_transport = NullModem(server_protocol) + cls.connections[client_transport] = port + cls.connections[server_transport] = -port + client_transport.other_modem = server_transport + server_transport.other_modem = client_transport + client_protocol.connection_made(client_transport) + server_protocol.connection_made(server_transport) + return client_transport, client_protocol + + def set_manipulator(self, function: Callable[[bytes], list[bytes]]) -> None: + """Register a manipulator.""" + self.manipulator = function + + @classmethod + def is_dirty(cls): + """Check if everything is closed.""" + dirty = False + if cls.connections: + Log.error( + "NullModem_FATAL missing close on port {} connect()", + [str(key) for key in cls.connections.values()], + ) + dirty = True + if cls.listeners: + Log.error( + "NullModem_FATAL missing close on port {} listen()", + [str(value) for value in cls.listeners], + ) + dirty = True + return dirty + + # ---------------- # + # external methods # + # ---------------- # + + def close(self) -> None: + """Close null modem.""" + if self._is_closing: + return + self._is_closing = True + if self.listen: + del self.listeners[self.listen] + return + if self.connections: + with suppress(KeyError): + del self.connections[self] + if self.other_modem: + self.other_modem.other_modem = None # type: ignore[assignment] + self.other_modem.close() + self.other_modem = None # type: ignore[assignment] + if self.protocol: + self.protocol.connection_lost(None) + + def sendto(self, data: bytes, _addr: Any = None) -> None: + """Send datagrame.""" + self.write(data) + + def write(self, data: bytes) -> None: + """Send data.""" + if not self.manipulator: + if self.other_modem: + self.other_modem.protocol.data_received(data) + return + data_manipulated = self.manipulator(data) + for part in data_manipulated: + self.other_modem.protocol.data_received(part) + + # ------------- # + # Dummy methods # + # ------------- # + def abort(self) -> None: + """Alias for closing the connection.""" + self.close() + + def can_write_eof(self) -> bool: + """Allow to write eof.""" + return False + + def get_write_buffer_size(self) -> int: + """Set write limit.""" + return 1024 + + def get_write_buffer_limits(self) -> tuple[int, int]: + """Set flush limits.""" + return (1, 1024) + + def set_write_buffer_limits( + self, high: int | None = None, low: int | None = None + ) -> None: + """Set flush limits.""" + + def write_eof(self) -> None: + """Write eof.""" + + def get_protocol(self) -> ModbusProtocol | asyncio.BaseProtocol: + """Return current protocol.""" + return self.protocol + + def set_protocol(self, protocol: asyncio.BaseProtocol) -> None: + """Set current protocol.""" + + def is_closing(self) -> bool: + """Return true if closing.""" + return self._is_closing + + def is_reading(self) -> bool: + """Return true if read is active.""" + return True + + def pause_reading(self): + """Pause receiver.""" + + def resume_reading(self): + """Resume receiver.""" diff --git a/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/utilities.py b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/utilities.py new file mode 100644 index 00000000..1bbbc8f3 --- /dev/null +++ b/custom_components/foxess_modbus/vendor/pymodbus/pymodbus-3.7.4/pymodbus/utilities.py @@ -0,0 +1,160 @@ +"""Modbus Utilities. + +A collection of utilities for packing data, unpacking +data computing checksums, and decode checksums. +""" +from __future__ import annotations + + +__all__ = [ + "pack_bitstring", + "unpack_bitstring", + "default", +] + +# pylint: disable=missing-type-doc +import struct + + +class ModbusTransactionState: # pylint: disable=too-few-public-methods + """Modbus Client States.""" + + IDLE = 0 + SENDING = 1 + WAITING_FOR_REPLY = 2 + WAITING_TURNAROUND_DELAY = 3 + PROCESSING_REPLY = 4 + PROCESSING_ERROR = 5 + TRANSACTION_COMPLETE = 6 + RETRYING = 7 + NO_RESPONSE_STATE = 8 + + @classmethod + def to_string(cls, state): + """Convert to string.""" + states = { + ModbusTransactionState.IDLE: "IDLE", + ModbusTransactionState.SENDING: "SENDING", + ModbusTransactionState.WAITING_FOR_REPLY: "WAITING_FOR_REPLY", + ModbusTransactionState.WAITING_TURNAROUND_DELAY: "WAITING_TURNAROUND_DELAY", + ModbusTransactionState.PROCESSING_REPLY: "PROCESSING_REPLY", + ModbusTransactionState.PROCESSING_ERROR: "PROCESSING_ERROR", + ModbusTransactionState.TRANSACTION_COMPLETE: "TRANSACTION_COMPLETE", + ModbusTransactionState.RETRYING: "RETRYING TRANSACTION", + } + return states.get(state, None) + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def default(value): + """Return the default value of object. + + :param value: The value to get the default of + :returns: The default value + """ + return type(value)() + + +def dict_property(store, index): + """Create class properties from a dictionary. + + Basically this allows you to remove a lot of possible + boilerplate code. + + :param store: The store store to pull from + :param index: The index into the store to close over + :returns: An initialized property set + """ + if hasattr(store, "__call__"): + getter = lambda self: store( # pylint: disable=unnecessary-lambda-assignment + self + )[index] + setter = lambda self, value: store( # pylint: disable=unnecessary-lambda-assignment + self + ).__setitem__(index, value) + elif isinstance(store, str): + getter = lambda self: self.__getattribute__( # pylint: disable=unnecessary-dunder-call,unnecessary-lambda-assignment + store + )[index] + setter = lambda self, value: self.__getattribute__( # pylint: disable=unnecessary-dunder-call,unnecessary-lambda-assignment + store + ).__setitem__(index, value) + else: + getter = ( + lambda self: store[index] # pylint: disable=unnecessary-lambda-assignment + ) + setter = lambda self, value: store.__setitem__( # pylint: disable=unnecessary-lambda-assignment + index, value + ) + + return property(getter, setter) + + +# --------------------------------------------------------------------------- # +# Bit packing functions +# --------------------------------------------------------------------------- # +def pack_bitstring(bits: list[bool]) -> bytes: + """Create a bytestring out of a list of bits. + + :param bits: A list of bits + + example:: + + bits = [False, True, False, True] + result = pack_bitstring(bits) + """ + ret = b"" + i = packed = 0 + for bit in bits: + if bit: + packed += 128 + i += 1 + if i == 8: + ret += struct.pack(">B", packed) + i = packed = 0 + else: + packed >>= 1 + if 0 < i < 8: + packed >>= 7 - i + ret += struct.pack(">B", packed) + return ret + + +def unpack_bitstring(data: bytes) -> list[bool]: + """Create bit list out of a bytestring. + + :param data: The modbus data packet to decode + + example:: + + bytes = "bytes to decode" + result = unpack_bitstring(bytes) + """ + byte_count = len(data) + bits = [] + for byte in range(byte_count): + value = int(data[byte]) + for _ in range(8): + bits.append((value & 1) == 1) + value >>= 1 + return bits + + +# --------------------------------------------------------------------------- # +# Error Detection Functions +# --------------------------------------------------------------------------- # + + +def hexlify_packets(packet): + """Return hex representation of bytestring received. + + :param packet: + :return: + """ + if not packet: + return "" + return " ".join([hex(int(x)) for x in packet]) diff --git a/pyproject.toml b/pyproject.toml index 604a87cb..aba73930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ warn_unused_configs = true warn_unused_ignores = true namespace_packages = true explicit_package_bases = true +exclude = [ + 'custom_components/foxess_modbus/vendor', +] [[tool.mypy.overrides]] module = 'pymodbus.*' @@ -30,6 +33,9 @@ unfixable = [ "F841", # unused-variable ] line-length = 120 +extend-exclude = [ + 'custom_components/foxess_modbus/vendor', +] [tool.ruff.isort] force-single-line = true diff --git a/requirements.txt b/requirements.txt index 6c9ffa51..0af8e484 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ pytest-asyncio colorlog==6.7.0 pre-commit==3.3.3 -black==23.9.0 ruff==0.0.275 # These are duplicated in .pre-commit-config.yaml reorder-python-imports==3.10.0 @@ -19,6 +18,4 @@ mypy==1.5.1 types-python-slugify==8.0.0.2 voluptuous-stubs==0.1.1 # For mypy. Keep in sync with manifest.json and https://github.com/home-assistant/core/blob/master/requirements_all.txt. -# If changed, make sure subclasses in modbus_client are still valid! -pymodbus==3.7.4 pyserial==3.5