diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 44aeaa2e0..8d5ccea7a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,15 @@ helps make pymodbus a better product. :ref:`Authors`: contains a complete list of volunteers have contributed to each major version. +Version 3.8.3 +------------- +* Remove deprecate from payload. (#2532) +* Add background parameter to servers. (#2529) +* Split async_io.py and simplify server start/stop. (#2528) +* Update custom_msg example to include server. (#2527) +* Move repl doc to repl repo. (#2522) +* Add API to set max until disconnect. (#2521) + Version 3.8.2 ------------- * Asyncio future removed from sync client. (#2514) @@ -31,7 +40,7 @@ Version 3.8.0 * Add trace API to server. (#2479) * Add trace API for client. (#2478) * Integrate TransactionManager in server. (#2475) -* Rename test/sub_. (#2473) +* Rename test/sub. (#2473) * Check server closes file descriptors. (#2472) * Update http_server.py (#2471) * Restrict write_registers etc to list[int]. (#2469) diff --git a/MAKE_RELEASE.rst b/MAKE_RELEASE.rst index 6f7942651..0cbc90c85 100644 --- a/MAKE_RELEASE.rst +++ b/MAKE_RELEASE.rst @@ -15,8 +15,8 @@ Prepare/make release on dev. * Control / Update API_changes.rst * Update CHANGELOG.rst * Add commits from last release, but selectively ! - git log --oneline v3.8.0..HEAD > commit.log - git log --pretty="%an" v3.8.0..HEAD | sort -uf > authors.log + git log --oneline v3.8.3..HEAD > commit.log + git log --pretty="%an" v3.8.3..HEAD | sort -uf > authors.log update AUTHORS.rst and CHANGELOG.rst cd doc; ./build_html * rm -rf build/* dist/* diff --git a/README.rst b/README.rst index e124c6d3e..900217cb1 100644 --- a/README.rst +++ b/README.rst @@ -22,11 +22,11 @@ Our releases is defined as X.Y.Z, and we have strict rules what to release when: 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 +- 3.8.1 -> 3.8.3: just plugin the new version, no changes needed. +- 3.7.1 -> 3.8.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.8.2 `_. +Current release is `3.8.3 `_. Bleeding edge (not released) is `dev `_. diff --git a/doc/conf.py b/doc/conf.py index 3654bfbe0..f51b0338a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,7 +21,7 @@ "sphinx_rtd_theme", "sphinx.ext.autosectionlabel" ] -source_suffix = [".rst"] +source_suffix = {'.rst': 'restructuredtext'} root_doc = "index" project = "PyModbus" copyright = "See license" diff --git a/doc/source/_static/examples.tgz b/doc/source/_static/examples.tgz index b05e4bb4d..67da30b8c 100644 Binary files a/doc/source/_static/examples.tgz and b/doc/source/_static/examples.tgz differ diff --git a/doc/source/_static/examples.zip b/doc/source/_static/examples.zip index edae1dde5..2df66121e 100644 Binary files a/doc/source/_static/examples.zip and b/doc/source/_static/examples.zip differ diff --git a/doc/source/examples.rst b/doc/source/examples.rst index 2be001006..d7f249aa9 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -79,11 +79,11 @@ Source: :github:`examples/client_calls.py` :noindex: -Client custom message -^^^^^^^^^^^^^^^^^^^^^ -Source: :github:`examples/client_custom_msg.py` +Custom message +^^^^^^^^^^^^^^ +Source: :github:`examples/custom_msg.py` -.. automodule:: examples.client_custom_msg +.. automodule:: examples.custom_msg :undoc-members: :noindex: diff --git a/doc/source/repl.rst b/doc/source/repl.rst index 28ad16284..78b54be13 100644 --- a/doc/source/repl.rst +++ b/doc/source/repl.rst @@ -1,15 +1,9 @@ Pymodbus REPL (Read Evaluate Print Loop) ========================================= -.. raw:: html +Pymodbus provides a simple UI to maniplute server/client, this is handled +by a separate repo `pymodbus-repl `__ -

Warning: The Pymodbus REPL documentation is not updated, - because it lives in a different repo.

- -Installation ------------- - -Project repo `pymodbus-repl `__ Install as pymodbus optional dependency ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -24,300 +18,3 @@ Install directly from the github repo :: $ pip install "git+https://github.com/pymodbus-dev/repl" - -Usage Instructions ------------------- - -RTU and TCP are supported as of now - -:: - - bash-3.2$ pymodbus.console - Usage: pymodbus.console [OPTIONS] COMMAND [ARGS]... - - Options: - --version Show the version and exit. - --verbose Verbose logs - --support-diag Support Diagnostic messages - --help Show this message and exit. - - Commands: - serial - tcp - -TCP Options - -:: - - bash-3.2$ pymodbus.console tcp --help - Usage: pymodbus.console tcp [OPTIONS] - - Options: - --host TEXT Modbus TCP IP - --port INTEGER Modbus TCP port - --help Show this message and exit. - - -SERIAL Options - -:: - - bash-3.2$ pymodbus.console serial --help - Usage: pymodbus.console serial [OPTIONS] - - Options: - --method TEXT Modbus Serial Mode (rtu/ascii) - --port TEXT Modbus RTU port - --baudrate INTEGER Modbus RTU serial baudrate to use. - --bytesize [5|6|7|8] Modbus RTU serial Number of data bits. Possible - values: FIVEBITS, SIXBITS, SEVENBITS, EIGHTBITS. - --parity [N|E|O|M|S] Modbus RTU serial parity. Enable parity checking. - Possible values: PARITY_NONE, PARITY_EVEN, PARITY_ODD - PARITY_MARK, PARITY_SPACE. Default to 'N' - --stopbits [1|1.5|2] Modbus RTU serial stop bits. Number of stop bits. - Possible values: STOPBITS_ONE, - STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO. Default to '1' - --xonxoff INTEGER Modbus RTU serial xonxoff. Enable software flow - control. - --rtscts INTEGER Modbus RTU serial rtscts. Enable hardware (RTS/CTS) - flow control. - --dsrdtr INTEGER Modbus RTU serial dsrdtr. Enable hardware (DSR/DTR) - flow control. - --timeout FLOAT Modbus RTU serial read timeout. - --write-timeout FLOAT Modbus RTU serial write timeout. - --help Show this message and exit. - -To view all available commands type ``help`` - -TCP - -:: - - $ pymodbus.console tcp --host 192.168.128.126 --port 5020 - - > help - Available commands: - client.change_ascii_input_delimiter Diagnostic sub command, Change message delimiter for future requests. - client.clear_counters Diagnostic sub command, Clear all counters and diag registers. - client.clear_overrun_count Diagnostic sub command, Clear over run counter. - client.close Closes the underlying socket connection - client.connect Connect to the modbus tcp server - client.debug_enabled Returns a boolean indicating if debug is enabled. - client.force_listen_only_mode Diagnostic sub command, Forces the addressed remote device to its Listen Only Mode. - client.get_clear_modbus_plus Diagnostic sub command, Get or clear stats of remote modbus plus device. - client.get_com_event_counter Read status word and an event count from the remote device's communication event counter. - client.get_com_event_log Read status word, event count, message count, and a field of event bytes from the remote device. - client.host Read Only! - client.idle_time Bus Idle Time to initiate next transaction - client.is_socket_open Check whether the underlying socket/serial is open or not. - client.last_frame_end Read Only! - client.mask_write_register Mask content of holding register at `address` with `and_mask` and `or_mask`. - client.port Read Only! - client.read_coils Reads `count` coils from a given slave starting at `address`. - client.read_device_information Read the identification and additional information of remote slave. - client.read_discrete_inputs Reads `count` number of discrete inputs starting at offset `address`. - client.read_exception_status Read the contents of eight Exception Status outputs in a remote device. - client.read_holding_registers Read `count` number of holding registers starting at `address`. - client.read_input_registers Read `count` number of input registers starting at `address`. - client.readwrite_registers Read `read_count` number of holding registers starting at `read_address` and write `write_registers` starting at `write_address`. - client.report_slave_id Report information about remote slave ID. - client.restart_comm_option Diagnostic sub command, initialize and restart remote devices serial interface and clear all of its communications event counters . - client.return_bus_com_error_count Diagnostic sub command, Return count of CRC errors received by remote slave. - client.return_bus_exception_error_count Diagnostic sub command, Return count of Modbus exceptions returned by remote slave. - client.return_bus_message_count Diagnostic sub command, Return count of message detected on bus by remote slave. - client.return_diagnostic_register Diagnostic sub command, Read 16-bit diagnostic register. - client.return_iop_overrun_count Diagnostic sub command, Return count of iop overrun errors by remote slave. - client.return_query_data Diagnostic sub command , Loop back data sent in response. - client.return_slave_bus_char_overrun_count Diagnostic sub command, Return count of messages not handled by remote slave due to character overrun condition. - client.return_slave_busy_count Diagnostic sub command, Return count of server busy exceptions sent by remote slave. - client.return_slave_message_count Diagnostic sub command, Return count of messages addressed to remote slave. - client.return_slave_no_ack_count Diagnostic sub command, Return count of NO ACK exceptions sent by remote slave. - client.return_slave_no_response_count Diagnostic sub command, Return count of No responses by remote slave. - client.silent_interval Read Only! - client.state Read Only! - client.timeout Read Only! - client.write_coil Write `value` to coil at `address`. - client.write_coils Write `value` to coil at `address`. - client.write_register Write `value` to register at `address`. - client.write_registers Write list of `values` to registers starting at `address`. - -SERIAL - -:: - - $ pymodbus.console serial --port /dev/ttyUSB0 --baudrate 19200 --timeout 2 - > help - Available commands: - client.baudrate Read Only! - client.bytesize Read Only! - client.change_ascii_input_delimiter Diagnostic sub command, Change message delimiter for future requests. - client.clear_counters Diagnostic sub command, Clear all counters and diag registers. - client.clear_overrun_count Diagnostic sub command, Clear over run counter. - client.close Closes the underlying socket connection - client.connect Connect to the modbus serial server - client.debug_enabled Returns a boolean indicating if debug is enabled. - client.force_listen_only_mode Diagnostic sub command, Forces the addressed remote device to its Listen Only Mode. - client.get_baudrate Serial Port baudrate. - client.get_bytesize Number of data bits. - client.get_clear_modbus_plus Diagnostic sub command, Get or clear stats of remote modbus plus device. - client.get_com_event_counter Read status word and an event count from the remote device's communication event counter. - client.get_com_event_log Read status word, event count, message count, and a field of event bytes from the remote device. - client.get_parity Enable Parity Checking. - client.get_port Serial Port. - client.get_serial_settings Gets Current Serial port settings. - client.get_stopbits Number of stop bits. - client.get_timeout Serial Port Read timeout. - client.idle_time Bus Idle Time to initiate next transaction - client.inter_byte_timeout Read Only! - client.is_socket_open c l i e n t . i s s o c k e t o p e n - client.mask_write_register Mask content of holding register at `address` with `and_mask` and `or_mask`. - client.method Read Only! - client.parity Read Only! - client.port Read Only! - client.read_coils Reads `count` coils from a given slave starting at `address`. - client.read_device_information Read the identification and additional information of remote slave. - client.read_discrete_inputs Reads `count` number of discrete inputs starting at offset `address`. - client.read_exception_status Read the contents of eight Exception Status outputs in a remote device. - client.read_holding_registers Read `count` number of holding registers starting at `address`. - client.read_input_registers Read `count` number of input registers starting at `address`. - client.readwrite_registers Read `read_count` number of holding registers starting at `read_address` and write `write_registers` starting at `write_address`. - client.report_slave_id Report information about remote slave ID. - client.restart_comm_option Diagnostic sub command, initialize and restart remote devices serial interface and clear all of its communications event counters . - client.return_bus_com_error_count Diagnostic sub command, Return count of CRC errors received by remote slave. - client.return_bus_exception_error_count Diagnostic sub command, Return count of Modbus exceptions returned by remote slave. - client.return_bus_message_count Diagnostic sub command, Return count of message detected on bus by remote slave. - client.return_diagnostic_register Diagnostic sub command, Read 16-bit diagnostic register. - client.return_iop_overrun_count Diagnostic sub command, Return count of iop overrun errors by remote slave. - client.return_query_data Diagnostic sub command , Loop back data sent in response. - client.return_slave_bus_char_overrun_count Diagnostic sub command, Return count of messages not handled by remote slave due to character overrun condition. - client.return_slave_busy_count Diagnostic sub command, Return count of server busy exceptions sent by remote slave. - client.return_slave_message_count Diagnostic sub command, Return count of messages addressed to remote slave. - client.return_slave_no_ack_count Diagnostic sub command, Return count of NO ACK exceptions sent by remote slave. - client.return_slave_no_response_count Diagnostic sub command, Return count of No responses by remote slave. - client.set_baudrate Baudrate setter. - client.set_bytesize Byte size setter. - client.set_parity Parity Setter. - client.set_port Serial Port setter. - client.set_stopbits Stop bit setter. - client.set_timeout Read timeout setter. - client.silent_interval Read Only! - client.state Read Only! - client.stopbits Read Only! - client.timeout Read Only! - client.write_coil Write `value` to coil at `address`. - client.write_coils Write `value` to coil at `address`. - client.write_register Write `value` to register at `address`. - client.write_registers Write list of `values` to registers starting at `address`. - result.decode Decode the register response to known formatters. - result.raw Return raw result dict. - -Every command has auto suggestion on the arguments supported, arg and -value are to be supplied in ``arg=val`` format. - -:: - - - > client.read_holding_registers count=4 address=9 slave=1 - { - "registers": [ - 60497, - 47134, - 34091, - 15424 - ] - } - -The last result could be accessed with ``result.raw`` command - -:: - - > result.raw - { - "registers": [ - 15626, - 55203, - 28733, - 18368 - ] - } - -For Holding and Input register reads, the decoded value could be viewed -with ``result.decode`` - -:: - - > result.decode word_order=little byte_order=little formatters=float64 - 28.17 - - > - -Client settings could be retrieved and altered as well. - -:: - - > # For serial settings - - > # Check the serial mode - > client.method - "rtu" - - > client.get_serial_settings - { - "t1.5": 0.00171875, - "baudrate": 9600, - "read timeout": 0.5, - "port": "/dev/ptyp0", - "t3.5": 0.00401, - "bytesize": 8, - "parity": "N", - "stopbits": 1.0 - } - > client.set_timeout value=1 - null - - > client.get_timeout - 1.0 - - > client.get_serial_settings - { - "t1.5": 0.00171875, - "baudrate": 9600, - "read timeout": 1.0, - "port": "/dev/ptyp0", - "t3.5": 0.00401, - "bytesize": 8, - "parity": "N", - "stopbits": 1.0 - } - -Demo ----- - -.. |asciicast| image:: https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o.png - :target: https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o -.. |asciicast2| image:: https://asciinema.org/a/edUqZN77fdjxL2toisiilJNwI.png - :target: https://asciinema.org/a/edUqZN77fdjxL2toisiilJNwI - - -Pymodbus REPL Client -^^^^^^^^^^^^^^^^^^^^ - -Pymodbus REPL comes with many handy features such as payload decoder -to directly retrieve the values in desired format and supports all -the diagnostic function codes directly . - -For more info on REPL Client refer `pymodbus repl client `__ - -.. image:: https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o.png - :target: https://asciinema.org/a/y1xOk7lm59U1bRBE2N1pDIj2o - - -Pymodbus REPL Server -^^^^^^^^^^^^^^^^^^^^ - -Pymodbus also comes with a REPL server to quickly run an asynchronous server with additional capabilities out of the box like simulating errors, delay, mangled messages etc. - -For more info on REPL Server refer `pymodbus repl server `__ - -.. image:: https://img.youtube.com/vi/OutaVz0JkWg/maxresdefault.jpg - :target: https://youtu.be/OutaVz0JkWg diff --git a/doc/source/roadmap.rst b/doc/source/roadmap.rst index 5f6c494dd..aee8ab96b 100644 --- a/doc/source/roadmap.rst +++ b/doc/source/roadmap.rst @@ -15,19 +15,20 @@ It is the community that decides how pymodbus evolves NOT the maintainers ! The following bullet points are what the maintainers focus on: -- 3.8.3 bug fix release, with: +- 3.8.4 bug fix release, with: - Currently not planned - 3.9.0, with: - All of branch wait_next_api - ModbusControlBlock pr slave - New custom PDU (function codes) + - Simulator datastore, with simple configuration - Remove remote_datastore - - Remove BinaryPayload - 4.0.0, with: + - Remove BinaryPayload + - Server becomes Simulator - New serial forwarder - client async with sync/async API - Only one datastore, but with different API`s - - Simulator standard in server - GUI client, to analyze devices - GUI server, to simulate devices diff --git a/examples/contrib/serial_forwarder.py b/examples/contrib/serial_forwarder.py index da2b153b9..e7448f07d 100644 --- a/examples/contrib/serial_forwarder.py +++ b/examples/contrib/serial_forwarder.py @@ -11,7 +11,7 @@ from pymodbus.client import ModbusSerialClient from pymodbus.datastore import ModbusServerContext from pymodbus.datastore.remote import RemoteSlaveContext -from pymodbus.server.async_io import ModbusTcpServer +from pymodbus.server import ModbusTcpServer _logger = logging.getLogger(__file__) diff --git a/examples/client_custom_msg.py b/examples/custom_msg.py similarity index 79% rename from examples/client_custom_msg.py rename to examples/custom_msg.py index a3dbf97a9..df64a3474 100755 --- a/examples/client_custom_msg.py +++ b/examples/custom_msg.py @@ -15,9 +15,15 @@ from pymodbus import FramerType from pymodbus.client import AsyncModbusTcpClient as ModbusClient +from pymodbus.datastore import ( + ModbusSequentialDataBlock, + ModbusServerContext, + ModbusSlaveContext, +) from pymodbus.exceptions import ModbusIOException -from pymodbus.pdu import ExceptionResponse, ModbusPDU +from pymodbus.pdu import ModbusPDU from pymodbus.pdu.bit_message import ReadCoilsRequest +from pymodbus.server import ServerAsyncStop, StartAsyncTcpServer # --------------------------------------------------------------------------- # @@ -31,7 +37,7 @@ # --------------------------------------------------------------------------- # -class CustomModbusPDU(ModbusPDU): +class CustomModbusResponse(ModbusPDU): """Custom modbus response.""" function_code = 55 @@ -73,7 +79,7 @@ def __init__(self, address=None, slave=1, transaction=0): """Initialize.""" super().__init__(dev_id=slave, transaction_id=transaction) self.address = address - self.count = 16 + self.count = 2 def encode(self): """Encode.""" @@ -83,14 +89,10 @@ def decode(self, data): """Decode.""" self.address, self.count = struct.unpack(">HH", data) - def execute(self, context): + async def update_datastore(self, context: ModbusSlaveContext) -> ModbusPDU: """Execute.""" - if not 1 <= self.count <= 0x7D0: - return ExceptionResponse(self.function_code, ExceptionResponse.ILLEGAL_VALUE) - if not context.validate(self.function_code, self.address, self.count): - return ExceptionResponse(self.function_code, ExceptionResponse.ILLEGAL_ADDRESS) - values = context.getValues(self.function_code, self.address, self.count) - return CustomModbusPDU(values) + _ = context + return CustomModbusResponse() # --------------------------------------------------------------------------- # @@ -119,14 +121,25 @@ def __init__(self, address, slave=1, transaction=0): async def main(host="localhost", port=5020): """Run versions of read coil.""" + store = ModbusServerContext(slaves=ModbusSlaveContext( + di=ModbusSequentialDataBlock(0, [17] * 100), + co=ModbusSequentialDataBlock(0, [17] * 100), + hr=ModbusSequentialDataBlock(0, [17] * 100), + ir=ModbusSequentialDataBlock(0, [17] * 100), + ), + single=True + ) + task = asyncio.create_task(StartAsyncTcpServer( + context=store, + address=(host, port), + custom_functions=[CustomRequest]) + ) + await asyncio.sleep(0.1) async with ModbusClient(host=host, port=port, framer=FramerType.SOCKET) as client: await client.connect() - # create a response object to control it works - CustomModbusPDU() - - # new modbus function code. - client.register(CustomModbusPDU) + # add new modbus function code. + client.register(CustomModbusResponse) slave=1 request1 = CustomRequest(32, slave=slave) try: @@ -140,6 +153,9 @@ async def main(host="localhost", port=5020): request2 = Read16CoilsRequest(32, slave) result = await client.execute(False, request2) print(result) + await ServerAsyncStop() + task.cancel() + await task if __name__ == "__main__": diff --git a/examples/server_async.py b/examples/server_async.py index d205be648..bec0135da 100755 --- a/examples/server_async.py +++ b/examples/server_async.py @@ -136,18 +136,15 @@ def setup_server(description=None, context=None, cmdline=None): return args -async def run_async_server(args): +async def run_async_server(args) -> None: """Run server.""" txt = f"### start ASYNC server, listening on {args.port} - {args.comm}" _logger.info(txt) - server = None if args.comm == "tcp": address = (args.host if args.host else "", args.port if args.port else None) - server = await StartAsyncTcpServer( + await StartAsyncTcpServer( context=args.context, # Data storage identity=args.identity, # server identify - # TBD host= - # TBD port= address=address, # listen address # custom_functions=[], # allow custom handling framer=args.framer, # The framer strategy to use @@ -160,7 +157,7 @@ async def run_async_server(args): args.host if args.host else "127.0.0.1", args.port if args.port else None, ) - server = await StartAsyncUdpServer( + await StartAsyncUdpServer( context=args.context, # Data storage identity=args.identity, # server identify address=address, # listen address @@ -173,7 +170,7 @@ async def run_async_server(args): elif args.comm == "serial": # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600 # PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600 - server = await StartAsyncSerialServer( + await StartAsyncSerialServer( context=args.context, # Data storage identity=args.identity, # server identify # timeout=1, # waiting time for request to complete @@ -190,9 +187,8 @@ async def run_async_server(args): ) elif args.comm == "tls": address = (args.host if args.host else "", args.port if args.port else None) - server = await StartAsyncTlsServer( + await StartAsyncTlsServer( context=args.context, # Data storage - host="localhost", # define tcp address where to connect to. # port=port, # on which port identity=args.identity, # server identify # custom_functions=[], # allow custom handling @@ -210,10 +206,9 @@ async def run_async_server(args): # broadcast_enable=False, # treat slave 0 as broadcast address, # timeout=1, # waiting time for request to complete ) - return server -async def async_helper(): +async def async_helper() -> None: """Combine setup and run.""" _logger.info("Starting...") run_args = setup_server(description="Run asynchronous server.") diff --git a/examples/server_sync.py b/examples/server_sync.py index 7c735b2d2..c85a92483 100755 --- a/examples/server_sync.py +++ b/examples/server_sync.py @@ -62,18 +62,15 @@ _logger.setLevel("DEBUG") -def run_sync_server(args): +def run_sync_server(args) -> None: """Run server.""" txt = f"### start SYNC server, listening on {args.port} - {args.comm}" _logger.info(txt) - server = None if args.comm == "tcp": address = ("", args.port) if args.port else None - server = StartTcpServer( + StartTcpServer( context=args.context, # Data storage identity=args.identity, # server identify - # TBD host= - # TBD port= address=address, # listen address # custom_functions=[], # allow custom handling framer=args.framer, # The framer strategy to use @@ -83,7 +80,7 @@ def run_sync_server(args): ) elif args.comm == "udp": address = ("127.0.0.1", args.port) if args.port else None - server = StartUdpServer( + StartUdpServer( context=args.context, # Data storage identity=args.identity, # server identify address=address, # listen address @@ -96,7 +93,7 @@ def run_sync_server(args): elif args.comm == "serial": # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600 # PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600 - server = StartSerialServer( + StartSerialServer( context=args.context, # Data storage identity=args.identity, # server identify # timeout=1, # waiting time for request to complete @@ -113,9 +110,8 @@ def run_sync_server(args): ) elif args.comm == "tls": address = ("", args.port) if args.port else None - server = StartTlsServer( + StartTlsServer( context=args.context, # Data storage - host="localhost", # define tcp address where to connect to. # port=port, # on which port identity=args.identity, # server identify # custom_functions=[], # allow custom handling @@ -133,14 +129,13 @@ def run_sync_server(args): # broadcast_enable=False, # treat slave 0 as broadcast address, # timeout=1, # waiting time for request to complete ) - return server -def sync_helper(): +def sync_helper() -> None: """Combine setup and run.""" run_args = server_async.setup_server(description="Run synchronous server.") - server = run_sync_server(run_args) - server.shutdown() + run_sync_server(run_args) + # server.shutdown() if __name__ == "__main__": diff --git a/pymodbus/__init__.py b/pymodbus/__init__.py index 2bdc5cda8..a6ac5428f 100644 --- a/pymodbus/__init__.py +++ b/pymodbus/__init__.py @@ -18,5 +18,5 @@ from pymodbus.pdu import ExceptionResponse -__version__ = "3.8.2" +__version__ = "3.8.3" __version_full__ = f"[pymodbus, version {__version__}]" diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 58ec94439..3cdd45cb1 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -1,6 +1,7 @@ """Base for all clients.""" from __future__ import annotations +import asyncio from abc import abstractmethod from collections.abc import Awaitable, Callable @@ -57,7 +58,9 @@ async def connect(self) -> bool: self.ctx.comm_params.host, self.ctx.comm_params.port, ) - return await self.ctx.connect() + rc = await self.ctx.connect() + await asyncio.sleep(0.1) + return rc def register(self, custom_response_class: type[ModbusPDU]) -> None: """Register a custom response class with the decoder (call **sync**). @@ -83,6 +86,20 @@ def execute(self, no_response_expected: bool, request: ModbusPDU): raise ConnectionException(f"Not connected[{self!s}]") return self.ctx.execute(no_response_expected, request) + def set_max_no_responses(self, max_count: int) -> None: + """Override default max no request responses. + + :param max_count: Max aborted requests before disconnecting. + + The parameter retries defines how many times a request is retried + before being aborted. Once aborted a counter is incremented, and when + this counter is greater than max_count the connection is terminated. + + .. tip:: + When a request is successful the count is reset. + """ + self.ctx.max_until_disconnect = max_count + async def __aenter__(self): """Implement the client with enter block. @@ -185,6 +202,20 @@ def execute(self, no_response_expected: bool, request: ModbusPDU) -> ModbusPDU: raise ConnectionException(f"Failed to connect[{self!s}]") return self.transaction.sync_execute(no_response_expected, request) + def set_max_no_responses(self, max_count: int) -> None: + """Override default max no request responses. + + :param max_count: Max aborted requests before disconnecting. + + The parameter retries defines how many times a request is retried + before being aborted. Once aborted a counter is incremented, and when + this counter is greater than max_count the connection is terminated. + + .. tip:: + When a request is successful the count is reset. + """ + self.transaction.max_until_disconnect = max_count + # ----------------------------------------------------------------------- # # Internal methods # ----------------------------------------------------------------------- # diff --git a/pymodbus/payload.py b/pymodbus/payload.py index 81d6a9939..958907362 100644 --- a/pymodbus/payload.py +++ b/pymodbus/payload.py @@ -57,7 +57,7 @@ def __init__( :param wordorder: The endianness of the word (when wordcount is >= 2) :param repack: Repack the provided payload based on BO """ - self.deprecate() + # self.deprecate() self._payload = payload or [] self._byteorder = byteorder self._wordorder = wordorder @@ -89,7 +89,7 @@ def _pack_words(self, fstring: str, value) -> bytes: def encode(self) -> bytes: """Get the payload buffer encoded in bytes.""" - self.deprecate() + # self.deprecate() return b"".join(self._payload) def __str__(self) -> str: @@ -101,7 +101,7 @@ def __str__(self) -> str: def reset(self) -> None: """Reset the payload buffer.""" - self.deprecate() + # self.deprecate() self._payload = [] def to_registers(self): @@ -109,7 +109,7 @@ def to_registers(self): :returns: The register layout to use as a block """ - self.deprecate() + # self.deprecate() # fstring = self._byteorder+"H" fstring = "!H" payload = self.build() @@ -125,7 +125,7 @@ def to_coils(self) -> list[bool]: :returns: The coil layout to use as a block """ - self.deprecate() + # self.deprecate() payload = self.to_registers() coils = [bool(int(bit)) for reg in payload for bit in format(reg, "016b")] return coils @@ -138,7 +138,7 @@ def build(self) -> list[bytes]: :returns: The payload buffer as a list """ - self.deprecate() + # self.deprecate() buffer = self.encode() length = len(buffer) buffer += b"\x00" * (length % 2) @@ -153,7 +153,7 @@ def add_bits(self, values: list[bool]) -> None: :param values: The value to add to the buffer """ - self.deprecate() + # self.deprecate() value = pack_bitstring(values) self._payload.append(value) @@ -162,7 +162,7 @@ def add_8bit_uint(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = self._byteorder + "B" self._payload.append(pack(fstring, value)) @@ -171,7 +171,7 @@ def add_16bit_uint(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = self._byteorder + "H" self._payload.append(pack(fstring, value)) @@ -180,7 +180,7 @@ def add_32bit_uint(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "I" # fstring = self._byteorder + "I" p_string = self._pack_words(fstring, value) @@ -191,7 +191,7 @@ def add_64bit_uint(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "Q" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -201,7 +201,7 @@ def add_8bit_int(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = self._byteorder + "b" self._payload.append(pack(fstring, value)) @@ -210,7 +210,7 @@ def add_16bit_int(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = self._byteorder + "h" self._payload.append(pack(fstring, value)) @@ -219,7 +219,7 @@ def add_32bit_int(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "i" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -229,7 +229,7 @@ def add_64bit_int(self, value: int) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "q" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -239,7 +239,7 @@ def add_16bit_float(self, value: float) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "e" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -249,7 +249,7 @@ def add_32bit_float(self, value: float) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "f" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -259,7 +259,7 @@ def add_64bit_float(self, value: float) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = "d" p_string = self._pack_words(fstring, value) self._payload.append(p_string) @@ -269,7 +269,7 @@ def add_string(self, value: str) -> None: :param value: The value to add to the buffer """ - self.deprecate() + # self.deprecate() fstring = self._byteorder + str(len(value)) + "s" self._payload.append(pack(fstring, value.encode())) @@ -302,7 +302,7 @@ def __init__(self, payload, byteorder=Endian.LITTLE, wordorder=Endian.BIG): :param byteorder: The endianness of the payload :param wordorder: The endianness of the word (when wordcount is >= 2) """ - self.deprecate() + # self.deprecate() self._payload = payload self._pointer = 0x00 self._byteorder = byteorder @@ -385,12 +385,12 @@ def _unpack_words(self, handle) -> bytes: def reset(self): """Reset the decoder pointer back to the start.""" - self.deprecate() + # self.deprecate() self._pointer = 0x00 def decode_8bit_uint(self): """Decode a 8 bit unsigned int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 1 fstring = self._byteorder + "B" handle = self._payload[self._pointer - 1 : self._pointer] @@ -398,7 +398,7 @@ def decode_8bit_uint(self): def decode_bits(self, package_len=1): """Decode a byte worth of bits from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += package_len # fstring = self._endian + "B" handle = self._payload[self._pointer - 1 : self._pointer] @@ -406,7 +406,7 @@ def decode_bits(self, package_len=1): def decode_16bit_uint(self): """Decode a 16 bit unsigned int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 2 fstring = self._byteorder + "H" handle = self._payload[self._pointer - 2 : self._pointer] @@ -414,7 +414,7 @@ def decode_16bit_uint(self): def decode_32bit_uint(self): """Decode a 32 bit unsigned int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 4 fstring = "I" handle = self._payload[self._pointer - 4 : self._pointer] @@ -423,7 +423,7 @@ def decode_32bit_uint(self): def decode_64bit_uint(self): """Decode a 64 bit unsigned int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 8 fstring = "Q" handle = self._payload[self._pointer - 8 : self._pointer] @@ -432,7 +432,7 @@ def decode_64bit_uint(self): def decode_8bit_int(self): """Decode a 8 bit signed int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 1 fstring = self._byteorder + "b" handle = self._payload[self._pointer - 1 : self._pointer] @@ -440,7 +440,7 @@ def decode_8bit_int(self): def decode_16bit_int(self): """Decode a 16 bit signed int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 2 fstring = self._byteorder + "h" handle = self._payload[self._pointer - 2 : self._pointer] @@ -448,7 +448,7 @@ def decode_16bit_int(self): def decode_32bit_int(self): """Decode a 32 bit signed int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 4 fstring = "i" handle = self._payload[self._pointer - 4 : self._pointer] @@ -457,7 +457,7 @@ def decode_32bit_int(self): def decode_64bit_int(self): """Decode a 64 bit signed int from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 8 fstring = "q" handle = self._payload[self._pointer - 8 : self._pointer] @@ -466,7 +466,7 @@ def decode_64bit_int(self): def decode_16bit_float(self): """Decode a 16 bit float from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 2 fstring = "e" handle = self._payload[self._pointer - 2 : self._pointer] @@ -475,7 +475,7 @@ def decode_16bit_float(self): def decode_32bit_float(self): """Decode a 32 bit float from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 4 fstring = "f" handle = self._payload[self._pointer - 4 : self._pointer] @@ -484,7 +484,7 @@ def decode_32bit_float(self): def decode_64bit_float(self): """Decode a 64 bit float(double) from the buffer.""" - self.deprecate() + # self.deprecate() self._pointer += 8 fstring = "d" handle = self._payload[self._pointer - 8 : self._pointer] @@ -496,7 +496,7 @@ def decode_string(self, size=1): :param size: The size of the string to decode """ - self.deprecate() + # self.deprecate() self._pointer += size return self._payload[self._pointer - size : self._pointer] @@ -505,5 +505,5 @@ def skip_bytes(self, nbytes): :param nbytes: The number of bytes to skip """ - self.deprecate() + # self.deprecate() self._pointer += nbytes diff --git a/pymodbus/pdu/pdu.py b/pymodbus/pdu/pdu.py index 792017c02..de1072555 100644 --- a/pymodbus/pdu/pdu.py +++ b/pymodbus/pdu/pdu.py @@ -5,6 +5,7 @@ import struct from abc import abstractmethod +from pymodbus.datastore import ModbusSlaveContext from pymodbus.exceptions import NotImplementedException from pymodbus.logging import Log @@ -79,6 +80,11 @@ def encode(self) -> bytes: def decode(self, data: bytes) -> None: """Decode data part of the message.""" + async def update_datastore(self, context: ModbusSlaveContext) -> ModbusPDU: + """Run request against a datastore.""" + _ = context + return ExceptionResponse(0, 0) + @classmethod def calculateRtuFrameSize(cls, data: bytes) -> int: """Calculate the size of a PDU.""" diff --git a/pymodbus/server/__init__.py b/pymodbus/server/__init__.py index 937f02386..6f35a4736 100644 --- a/pymodbus/server/__init__.py +++ b/pymodbus/server/__init__.py @@ -22,11 +22,15 @@ "get_simulator_commandline", ] -from pymodbus.server.async_io import ( +from pymodbus.server.server import ( ModbusSerialServer, ModbusTcpServer, ModbusTlsServer, ModbusUdpServer, +) +from pymodbus.server.simulator.http_server import ModbusSimulatorServer +from pymodbus.server.simulator.main import get_commandline as get_simulator_commandline +from pymodbus.server.startstop import ( ServerAsyncStop, ServerStop, StartAsyncSerialServer, @@ -38,5 +42,3 @@ 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/pymodbus/server/async_io.py b/pymodbus/server/async_io.py deleted file mode 100644 index 79e52a9b3..000000000 --- a/pymodbus/server/async_io.py +++ /dev/null @@ -1,684 +0,0 @@ -"""Implementation of a Threaded Modbus Server.""" -from __future__ import annotations - -import asyncio -import os -import traceback -from collections.abc import Callable -from contextlib import suppress - -from pymodbus.datastore import ModbusServerContext -from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification -from pymodbus.exceptions import NoSuchSlaveException -from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType -from pymodbus.logging import Log -from pymodbus.pdu import DecodePDU, ModbusPDU -from pymodbus.pdu.pdu import ExceptionResponse -from pymodbus.transaction import TransactionManager -from pymodbus.transport import CommParams, CommType, ModbusProtocol - - -class ModbusServerRequestHandler(TransactionManager): - """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], - handle_local_echo=owner.comm_params.handle_local_echo, - ) - self.server = owner - self.framer = self.server.framer(self.server.decoder) - self.running = False - self.handler_task = None # coroutine to be run on asyncio loop - self.databuffer = b'' - self.loop = asyncio.get_running_loop() - super().__init__( - params, - self.framer, - 0, - True, - None, - None, - None, - ) - - 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.""" - super().callback_connected() - slaves = self.server.context.slaves() - if self.server.broadcast_enable: - if 0 not in slaves: - slaves.append(0) - try: - self.running = True - - # 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.""" - super().callback_disconnected(call_exc) - 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: - Log.debug( - "Handler for stream [{}] has been canceled", self.comm_params.comm_name - ) - 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 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: - pdu, *addr, exc = await self.server_execute() - if exc: - pdu = ExceptionResponse( - 40, - exception_code=ExceptionResponse.ILLEGAL_FUNCTION - ) - self.server_send(pdu, 0) - continue - await self.server_async_execute(pdu, *addr) - except asyncio.CancelledError: - # catch and ignore cancellation errors - if self.running: - Log.debug( - "Handler for stream [{}] has been canceled", self.comm_params.comm_name - ) - 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) - - async def server_async_execute(self, request, *addr): - """Handle request.""" - broadcast = False - try: - if self.server.broadcast_enable and not request.dev_id: - broadcast = True - # if broadcasting then execute on all slave contexts, - # note response will be ignored - for dev_id in self.server.context.slaves(): - response = await request.update_datastore(self.server.context[dev_id]) - else: - context = self.server.context[request.dev_id] - response = await request.update_datastore(context) - - except NoSuchSlaveException: - Log.error("requested slave does not exist: {}", request.dev_id) - if self.server.ignore_missing_slaves: - return # the client will simply timeout waiting for a response - response = ExceptionResponse(0x00, ExceptionResponse.GATEWAY_NO_RESPONSE) - except Exception as exc: # pylint: disable=broad-except - Log.error( - "Datastore unable to fulfill request: {}; {}", - exc, - traceback.format_exc(), - ) - response = ExceptionResponse(0x00, ExceptionResponse.SLAVE_FAILURE) - # no response when broadcasting - if not broadcast: - response.transaction_id = request.transaction_id - response.dev_id = request.dev_id - self.server_send(response, *addr) - - def server_send(self, pdu, addr): - """Send message.""" - if not pdu: - Log.debug("Skipping sending response!!") - else: - self.pdu_send(pdu, addr=addr) - - -class ModbusBaseServer(ModbusProtocol): - """Common functionality for all server classes.""" - - def __init__( - self, - params: CommParams, - context: ModbusServerContext | None, - ignore_missing_slaves: bool, - broadcast_enable: bool, - identity: ModbusDeviceIdentification | None, - framer: FramerType, - trace_packet: Callable[[bool, bytes], bytes] | None, - trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None, - trace_connect: Callable[[bool], None] | None, - ) -> 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.trace_packet = trace_packet - self.trace_pdu = trace_pdu - self.trace_connect = trace_connect - 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: ModbusServerContext, - *, - framer=FramerType.SOCKET, - identity: ModbusDeviceIdentification | None = None, - address: tuple[str, int] = ("", 502), - ignore_missing_slaves: bool = False, - broadcast_enable: bool = False, - trace_packet: Callable[[bool, bytes], bytes] | None = None, - trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, - trace_connect: Callable[[bool], None] | None = 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 dev_id 0 as broadcast address, - False to treat 0 as any other dev_id - :param trace_packet: Called with bytestream received/to be sent - :param trace_pdu: Called with PDU received/to be sent - :param trace_connect: Called when connected/disconnected - """ - 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, - identity, - framer, - trace_packet, - trace_pdu, - trace_connect, - ) - - -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: ModbusServerContext, - *, - framer=FramerType.TLS, - identity: ModbusDeviceIdentification | None = None, - address: tuple[str, int] = ("", 502), - sslctx=None, - certfile=None, - keyfile=None, - password=None, - ignore_missing_slaves=False, - broadcast_enable=False, - trace_packet: Callable[[bool, bytes], bytes] | None = None, - trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, - trace_connect: Callable[[bool], None] | None = 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 dev_id 0 as broadcast address, - False to treat 0 as any other dev_id - """ - 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, - trace_packet=trace_packet, - trace_pdu=trace_pdu, - trace_connect=trace_connect - ) - - -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: ModbusServerContext, - *, - framer=FramerType.SOCKET, - identity: ModbusDeviceIdentification | None = None, - address: tuple[str, int] = ("", 502), - ignore_missing_slaves: bool = False, - broadcast_enable: bool = False, - trace_packet: Callable[[bool, bytes], bytes] | None = None, - trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, - trace_connect: Callable[[bool], None] | None = 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 dev_id 0 as broadcast address, - False to treat 0 as any other dev_id - :param trace_packet: Called with bytestream received/to be sent - :param trace_pdu: Called with PDU received/to be sent - :param trace_connect: Called when connected/disconnected - """ - # ---------------- - params = 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, - ) - super().__init__( - params, - context, - ignore_missing_slaves, - broadcast_enable, - identity, - framer, - trace_packet, - trace_pdu, - trace_connect, - ) - - -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: ModbusServerContext, - *, - framer: FramerType = FramerType.RTU, - ignore_missing_slaves: bool = False, - identity: ModbusDeviceIdentification | None = None, - broadcast_enable: bool = False, - trace_packet: Callable[[bool, bytes], bytes] | None = None, - trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, - trace_connect: Callable[[bool], None] | None = 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 dev_id 0 as broadcast address, - False to treat 0 as any other dev_id - :param reconnect_delay: reconnect delay in seconds - :param trace_packet: Called with bytestream received/to be sent - :param trace_pdu: Called with PDU received/to be sent - :param trace_connect: Called when connected/disconnected - """ - 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), - handle_local_echo=kwargs.get("handle_local_echo", False) - ) - super().__init__( - params, - context, - ignore_missing_slaves, - broadcast_enable, - identity, - framer, - trace_packet, - trace_pdu, - trace_connect, - ) - self.handle_local_echo = kwargs.get("handle_local_echo", False) - - -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, - custom_functions=[], - **kwargs, -): - """Start and run a tcp modbus server. - - For parameter explanation see ModbusTcpServer. - - parameter custom_functions: optional list of custom function classes. - """ - kwargs.pop("host", None) - server = ModbusTcpServer( - context, - framer=kwargs.pop("framer", FramerType.SOCKET), - **kwargs - ) - await _serverList.run(server, custom_functions) - - -async def StartAsyncTlsServer( # pylint: disable=invalid-name,dangerous-default-value - context=None, - custom_functions=[], - **kwargs, -): - """Start and run a tls modbus server. - - For parameter explanation see ModbusTlsServer. - - parameter custom_functions: optional list of custom function classes. - """ - kwargs.pop("host", None) - server = ModbusTlsServer( - context, - framer=kwargs.pop("framer", FramerType.TLS), - **kwargs, - ) - await _serverList.run(server, custom_functions) - - -async def StartAsyncUdpServer( # pylint: disable=invalid-name,dangerous-default-value - context=None, - custom_functions=[], - **kwargs, -): - """Start and run a udp modbus server. - - For parameter explanation see ModbusUdpServer. - - parameter custom_functions: optional list of custom function classes. - """ - kwargs.pop("host", None) - server = ModbusUdpServer( - context, - **kwargs - ) - await _serverList.run(server, custom_functions) - - -async def StartAsyncSerialServer( # pylint: disable=invalid-name,dangerous-default-value - context=None, - custom_functions=[], - **kwargs, -): - """Start and run a serial modbus server. - - For parameter explanation see ModbusSerialServer. - - parameter custom_functions: optional list of custom function classes. - """ - server = ModbusSerialServer( - context, - **kwargs - ) - await _serverList.run(server, custom_functions) - - -def StartSerialServer(**kwargs): # pylint: disable=invalid-name - """Start and run a modbus serial server. - - For parameter explanation see ModbusSerialServer. - """ - return asyncio.run(StartAsyncSerialServer(**kwargs)) - - -def StartTcpServer(**kwargs): # pylint: disable=invalid-name - """Start and run a modbus TCP server. - - For parameter explanation see ModbusTcpServer. - """ - return asyncio.run(StartAsyncTcpServer(**kwargs)) - - -def StartTlsServer(**kwargs): # pylint: disable=invalid-name - """Start and run a modbus TLS server. - - For parameter explanation see ModbusTlsServer. - """ - return asyncio.run(StartAsyncTlsServer(**kwargs)) - - -def StartUdpServer(**kwargs): # pylint: disable=invalid-name - """Start and run a modbus UDP server. - - For parameter explanation see ModbusUdpServer. - """ - 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/pymodbus/server/base.py b/pymodbus/server/base.py new file mode 100644 index 000000000..025905128 --- /dev/null +++ b/pymodbus/server/base.py @@ -0,0 +1,94 @@ +"""Implementation of a Threaded Modbus Server.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from contextlib import suppress + +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType +from pymodbus.logging import Log +from pymodbus.pdu import DecodePDU, ModbusPDU +from pymodbus.transport import CommParams, ModbusProtocol + +from .requesthandler import ServerRequestHandler + + +class ModbusBaseServer(ModbusProtocol): + """Common functionality for all server classes.""" + + active_server: ModbusBaseServer | None + + def __init__( # pylint: disable=too-many-arguments + self, + params: CommParams, + context: ModbusServerContext | None, + ignore_missing_slaves: bool, + broadcast_enable: bool, + identity: ModbusDeviceIdentification | None, + framer: FramerType, + trace_packet: Callable[[bool, bytes], bytes] | None, + trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None, + trace_connect: Callable[[bool], None] | None, + custom_pdu: list[type[ModbusPDU]] | None, + ) -> None: + """Initialize base server.""" + super().__init__( + params, + True, + ) + self.loop = asyncio.get_running_loop() + self.decoder = DecodePDU(True) + if custom_pdu: + for func in custom_pdu: + self.decoder.register(func) + self.context = context or ModbusServerContext() + self.control = ModbusControlBlock() + self.ignore_missing_slaves = ignore_missing_slaves + self.broadcast_enable = broadcast_enable + self.trace_packet = trace_packet + self.trace_pdu = trace_pdu + self.trace_connect = trace_connect + 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() + ModbusBaseServer.active_server = self + + def callback_new_connection(self): + """Handle incoming connect.""" + return ServerRequestHandler(self) + + async def shutdown(self): + """Close server.""" + if not self.serving.done(): + self.serving.set_result(True) + self.close() + + async def serve_forever(self, *, background: bool = False): + """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.") + if not background: + with suppress(asyncio.exceptions.CancelledError): + await self.serving + Log.info("Server graceful shutdown.") + + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + raise RuntimeError("callback_new_connection should never be called") + + def callback_disconnected(self, exc: Exception | None) -> None: + """Call when connection is lost.""" + raise RuntimeError("callback_disconnected should never be called") + + def callback_data(self, data: bytes, addr: tuple | None = None) -> int: + """Handle received data.""" + raise RuntimeError("callback_data should never be called") diff --git a/pymodbus/server/requesthandler.py b/pymodbus/server/requesthandler.py new file mode 100644 index 000000000..a5449496b --- /dev/null +++ b/pymodbus/server/requesthandler.py @@ -0,0 +1,137 @@ +"""Implementation of a Threaded Modbus Server.""" +from __future__ import annotations + +import asyncio +import traceback + +from pymodbus.exceptions import ModbusIOException, NoSuchSlaveException +from pymodbus.logging import Log +from pymodbus.pdu.pdu import ExceptionResponse +from pymodbus.transaction import TransactionManager +from pymodbus.transport import CommParams, ModbusProtocol + + +class ServerRequestHandler(TransactionManager): + """Handle client connection.""" + + 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], + handle_local_echo=owner.comm_params.handle_local_echo, + ) + self.server = owner + self.framer = self.server.framer(self.server.decoder) + self.running = False + super().__init__( + params, + self.framer, + 0, + True, + None, + None, + None, + ) + + def callback_new_connection(self) -> ModbusProtocol: + """Call when listener receive new connection request.""" + raise RuntimeError("callback_new_connection should never be called") + + def callback_connected(self) -> None: + """Call when connection is succcesfull.""" + super().callback_connected() + slaves = self.server.context.slaves() + if self.server.broadcast_enable: + if 0 not in slaves: + slaves.append(0) + + def callback_disconnected(self, call_exc: Exception | None) -> None: + """Call when connection is lost.""" + super().callback_disconnected(call_exc) + try: + if call_exc is None: + Log.debug( + "Handler for stream [{}] has been canceled", self.comm_params.comm_name + ) + 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(), + ) + + def callback_data(self, data: bytes, addr: tuple | None = None) -> int: + """Handle received data.""" + try: + used_len = super().callback_data(data, addr) + except ModbusIOException: + response = ExceptionResponse( + 40, + exception_code=ExceptionResponse.ILLEGAL_FUNCTION + ) + self.server_send(response, 0) + return(len(data)) + if self.last_pdu: + if self.is_server: + self.loop.call_soon(self.handle_later) + else: + self.response_future.set_result(True) + return used_len + + def handle_later(self): + """Change sync (async not allowed in call_soon) to async.""" + asyncio.run_coroutine_threadsafe(self.handle_request(), self.loop) + + async def handle_request(self): + """Handle request.""" + broadcast = False + if not self.last_pdu: + return + try: + if self.server.broadcast_enable and not self.last_pdu.dev_id: + broadcast = True + # if broadcasting then execute on all slave contexts, + # note response will be ignored + for dev_id in self.server.context.slaves(): + response = await self.last_pdu.update_datastore(self.server.context[dev_id]) + else: + context = self.server.context[self.last_pdu.dev_id] + response = await self.last_pdu.update_datastore(context) + + except NoSuchSlaveException: + Log.error("requested slave does not exist: {}", self.last_pdu.dev_id) + if self.server.ignore_missing_slaves: + return # the client will simply timeout waiting for a response + response = ExceptionResponse(0x00, ExceptionResponse.GATEWAY_NO_RESPONSE) + except Exception as exc: # pylint: disable=broad-except + Log.error( + "Datastore unable to fulfill request: {}; {}", + exc, + traceback.format_exc(), + ) + response = ExceptionResponse(0x00, ExceptionResponse.SLAVE_FAILURE) + # no response when broadcasting + if not broadcast: + response.transaction_id = self.last_pdu.transaction_id + response.dev_id = self.last_pdu.dev_id + self.server_send(response, self.last_addr) + + def server_send(self, pdu, addr): + """Send message.""" + if not pdu: + Log.debug("Skipping sending response!!") + else: + self.pdu_send(pdu, addr=addr) diff --git a/pymodbus/server/server.py b/pymodbus/server/server.py new file mode 100644 index 000000000..4fb7b9233 --- /dev/null +++ b/pymodbus/server/server.py @@ -0,0 +1,286 @@ +"""Implementation of a Threaded Modbus Server.""" +from __future__ import annotations + +from collections.abc import Callable + +from pymodbus.datastore import ModbusServerContext +from pymodbus.device import ModbusDeviceIdentification +from pymodbus.framer import FramerType +from pymodbus.pdu import ModbusPDU +from pymodbus.transport import CommParams, CommType + +from .base import ModbusBaseServer + + +class ModbusTcpServer(ModbusBaseServer): + """A modbus threaded tcp socket server. + + .. tip:: + Remember to call serve_forever to start server. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + context: ModbusServerContext, + *, + framer=FramerType.SOCKET, + identity: ModbusDeviceIdentification | None = None, + address: tuple[str, int] = ("", 502), + ignore_missing_slaves: bool = False, + broadcast_enable: bool = False, + trace_packet: Callable[[bool, bytes], bytes] | None = None, + trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, + trace_connect: Callable[[bool], None] | None = None, + custom_pdu: list[type[ModbusPDU]] | None = 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 dev_id 0 as broadcast address, + False to treat 0 as any other dev_id + :param trace_packet: Called with bytestream received/to be sent + :param trace_pdu: Called with PDU received/to be sent + :param trace_connect: Called when connected/disconnected + :param custom_pdu: list of ModbusPDU custom classes + """ + 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, + identity, + framer, + trace_packet, + trace_pdu, + trace_connect, + custom_pdu, + ) + + +class ModbusTlsServer(ModbusTcpServer): + """A modbus threaded tls socket server. + + .. tip:: + Remember to call serve_forever to start server. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + context: ModbusServerContext, + *, + framer=FramerType.TLS, + identity: ModbusDeviceIdentification | None = None, + address: tuple[str, int] = ("", 502), + sslctx=None, + certfile=None, + keyfile=None, + password=None, + ignore_missing_slaves=False, + broadcast_enable=False, + trace_packet: Callable[[bool, bytes], bytes] | None = None, + trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, + trace_connect: Callable[[bool], None] | None = None, + custom_pdu: list[type[ModbusPDU]] | None = 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 dev_id 0 as broadcast address, + False to treat 0 as any other dev_id + :param trace_packet: Called with bytestream received/to be sent + :param trace_pdu: Called with PDU received/to be sent + :param trace_connect: Called when connected/disconnected + :param custom_pdu: list of ModbusPDU custom classes + """ + 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, + trace_packet=trace_packet, + trace_pdu=trace_pdu, + trace_connect=trace_connect, + custom_pdu=custom_pdu, + ) + + +class ModbusUdpServer(ModbusBaseServer): + """A modbus threaded udp socket server. + + .. tip:: + Remember to call serve_forever to start server. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + context: ModbusServerContext, + *, + framer=FramerType.SOCKET, + identity: ModbusDeviceIdentification | None = None, + address: tuple[str, int] = ("", 502), + ignore_missing_slaves: bool = False, + broadcast_enable: bool = False, + trace_packet: Callable[[bool, bytes], bytes] | None = None, + trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, + trace_connect: Callable[[bool], None] | None = None, + custom_pdu: list[type[ModbusPDU]] | None = 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 dev_id 0 as broadcast address, + False to treat 0 as any other dev_id + :param trace_packet: Called with bytestream received/to be sent + :param trace_pdu: Called with PDU received/to be sent + :param trace_connect: Called when connected/disconnected + :param custom_pdu: list of ModbusPDU custom classes + """ + # ---------------- + params = 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, + ) + super().__init__( + params, + context, + ignore_missing_slaves, + broadcast_enable, + identity, + framer, + trace_packet, + trace_pdu, + trace_connect, + custom_pdu, + ) + + +class ModbusSerialServer(ModbusBaseServer): + """A modbus threaded serial socket server. + + .. tip:: + Remember to call serve_forever to start server. + """ + + def __init__( + self, + context: ModbusServerContext, + *, + framer: FramerType = FramerType.RTU, + ignore_missing_slaves: bool = False, + identity: ModbusDeviceIdentification | None = None, + broadcast_enable: bool = False, + trace_packet: Callable[[bool, bytes], bytes] | None = None, + trace_pdu: Callable[[bool, ModbusPDU], ModbusPDU] | None = None, + trace_connect: Callable[[bool], None] | None = None, + custom_pdu: list[type[ModbusPDU]] | None = 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 dev_id 0 as broadcast address, + False to treat 0 as any other dev_id + :param reconnect_delay: reconnect delay in seconds + :param trace_packet: Called with bytestream received/to be sent + :param trace_pdu: Called with PDU received/to be sent + :param trace_connect: Called when connected/disconnected + :param custom_pdu: list of ModbusPDU custom classes + """ + 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), + handle_local_echo=kwargs.get("handle_local_echo", False) + ) + super().__init__( + params, + context, + ignore_missing_slaves, + broadcast_enable, + identity, + framer, + trace_packet, + trace_pdu, + trace_connect, + custom_pdu, + ) + self.handle_local_echo = kwargs.get("handle_local_echo", False) + + diff --git a/pymodbus/server/simulator/http_server.py b/pymodbus/server/simulator/http_server.py index ec5ae7d94..424234264 100644 --- a/pymodbus/server/simulator/http_server.py +++ b/pymodbus/server/simulator/http_server.py @@ -25,7 +25,7 @@ from pymodbus.device import ModbusDeviceIdentification from pymodbus.logging import Log from pymodbus.pdu import DecodePDU -from pymodbus.server.async_io import ( +from pymodbus.server.server import ( ModbusSerialServer, ModbusTcpServer, ModbusTlsServer, diff --git a/pymodbus/server/startstop.py b/pymodbus/server/startstop.py new file mode 100644 index 000000000..6578e2f28 --- /dev/null +++ b/pymodbus/server/startstop.py @@ -0,0 +1,184 @@ +"""Implementation of a Threaded Modbus Server.""" +from __future__ import annotations + +import asyncio +import os + +from pymodbus.datastore import ModbusServerContext +from pymodbus.pdu import ModbusPDU + +from .base import ModbusBaseServer +from .server import ( + ModbusSerialServer, + ModbusTcpServer, + ModbusTlsServer, + ModbusUdpServer, +) + + +async def StartAsyncTcpServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs, +) -> None: + """Start and run a tcp modbus server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusTcpServer + + .. tip:: + Only handles a single server ! + + Use ModbusTcpServer to allow multiple servers in one app. + """ + await ModbusTcpServer(context, custom_pdu=custom_functions, **kwargs).serve_forever() + + +def StartTcpServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs +) -> None: + """Start and run a modbus TCP server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusTcpServer + + .. tip:: + Only handles a single server ! + + Use ModbusTcpServer to allow multiple servers in one app. + """ + asyncio.run(StartAsyncTcpServer(context, custom_functions=custom_functions, **kwargs)) + + +async def StartAsyncTlsServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs, +) -> None: + """Start and run a tls modbus server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusTlsServer + + .. tip:: + Only handles a single server ! + + Use ModbusTlsServer to allow multiple servers in one app. + """ + await ModbusTlsServer(context, custom_pdu=custom_functions, **kwargs).serve_forever() + + +def StartTlsServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs +) -> None: + """Start and run a modbus TLS server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusTlsServer + + .. tip:: + Only handles a single server ! + + Use ModbusTlsServer to allow multiple servers in one app. + """ + asyncio.run(StartAsyncTlsServer(context, custom_functions=custom_functions, **kwargs)) + + +async def StartAsyncUdpServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs, +) -> None: + """Start and run a udp modbus server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusUdpServer + + .. tip:: + Only handles a single server ! + + Use ModbusUdpServer to allow multiple servers in one app. + """ + await ModbusUdpServer(context, custom_pdu=custom_functions, **kwargs).serve_forever() + + +def StartUdpServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs +) -> None: + """Start and run a modbus UDP server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusUdpServer + + .. tip:: + Only handles a single server ! + + Use ModbusUdpServer to allow multiple servers in one app. + """ + asyncio.run(StartAsyncUdpServer(context, custom_functions=custom_functions, **kwargs)) + + +async def StartAsyncSerialServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs, +) -> None: + """Start and run a serial modbus server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusSerialServer + + .. tip:: + Only handles a single server ! + + Use ModbusSerialServer to allow multiple servers in one app. + """ + await ModbusSerialServer(context, custom_pdu=custom_functions, **kwargs).serve_forever() + + +def StartSerialServer( # pylint: disable=invalid-name + context: ModbusServerContext, + custom_functions: list[type[ModbusPDU]] | None = None, + **kwargs +) -> None: + """Start and run a modbus serial server. + + :parameter context: Datastore object + :parameter custom_functions: optional list of custom PDU objects + :parameter kwargs: for parameter explanation see ModbusSerialServer + + .. tip:: + Only handles a single server ! + + Use ModbusSerialServer to allow multiple servers in one app. + """ + asyncio.run(StartAsyncSerialServer(context, custom_functions=custom_functions, **kwargs)) + + +async def ServerAsyncStop() -> None: # pylint: disable=invalid-name + """Terminate server.""" + if not ModbusBaseServer.active_server: + raise RuntimeError("Modbus server not running.") + await ModbusBaseServer.active_server.shutdown() + ModbusBaseServer.active_server = None + + +def ServerStop() -> None: # pylint: disable=invalid-name + """Terminate server.""" + if not ModbusBaseServer.active_server: + raise RuntimeError("Modbus server not running.") + future = asyncio.run_coroutine_threadsafe(ServerAsyncStop(), ModbusBaseServer.active_server.loop) + future.result(timeout=10 if os.name == 'nt' else 0.1) diff --git a/pymodbus/transaction/transaction.py b/pymodbus/transaction/transaction.py index 3a0cf1aa3..d7536ea7c 100644 --- a/pymodbus/transaction/transaction.py +++ b/pymodbus/transaction/transaction.py @@ -16,7 +16,6 @@ class TransactionManager(ModbusProtocol): """Transaction manager. This is the central class of the library, providing a separation between API and communication: - - clients/servers calls the manager to execute requests/responses - transport/framer/pdu is by the manager to communicate with the devices @@ -29,9 +28,7 @@ class TransactionManager(ModbusProtocol): Transaction manager offers: - a simple execute interface for requests (client) - a simple send interface for responses (server) - - external trace methods tracing: - - outgoing/incoming packets (byte stream) - - outgoing/incoming PDUs + - external trace methods tracing outgoing/incoming packets/PDUs (byte stream) """ def __init__( @@ -53,8 +50,7 @@ def __init__( self.trace_packet = trace_packet or self.dummy_trace_packet self.trace_pdu = trace_pdu or self.dummy_trace_pdu self.trace_connect = trace_connect or self.dummy_trace_connect - self.accept_no_response_limit = retries + 3 - self.count_no_responses = 0 + self.max_until_disconnect = self.count_until_disconnect = retries + 3 if sync_client: self.sync_client = sync_client self._sync_lock = RLock() @@ -62,6 +58,9 @@ def __init__( else: self._lock = asyncio.Lock() self.low_level_send = self.send + if self.is_server: + self.last_pdu: ModbusPDU | None + self.last_addr: tuple | None self.response_future: asyncio.Future = asyncio.Future() def dummy_trace_packet(self, sending: bool, data: bytes) -> bytes: @@ -109,12 +108,12 @@ def sync_execute(self, no_response_expected: bool, request: ModbusPDU) -> Modbus return self.sync_get_response() except asyncio.exceptions.TimeoutError: count_retries += 1 - if self.count_no_responses >= self.accept_no_response_limit: + if self.count_until_disconnect < 0: self.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." + "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION." ) - self.count_no_responses += 1 + self.count_until_disconnect -= 1 txt = f"No response received after {self.retries} retries, continue with next request" Log.error(txt) raise ModbusIOException(txt) @@ -141,30 +140,20 @@ async def execute(self, no_response_expected: bool, request: ModbusPDU) -> Modbu response = await asyncio.wait_for( self.response_future, timeout=self.comm_params.timeout_connect ) - self.count_no_responses = 0 + self.count_until_disconnect= self.max_until_disconnect return response except asyncio.exceptions.TimeoutError: count_retries += 1 - if self.count_no_responses >= self.accept_no_response_limit: + if self.count_until_disconnect < 0: self.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." + "ERROR: No response received of the last requests (default: retries+3), CLOSING CONNECTION." ) - self.count_no_responses += 1 + self.count_until_disconnect -= 1 txt = f"No response received after {self.retries} retries, continue with next request" Log.error(txt) raise ModbusIOException(txt) - async def server_execute(self) -> tuple[ModbusPDU, int, Exception]: - """Wait for request. - - Used in server, with an instance for each connection, therefore - there are NO concurrency. - """ - self.response_future = asyncio.Future() - pdu, addr, exc = await asyncio.wait_for(self.response_future, None) - return pdu, addr, exc - def pdu_send(self, pdu: ModbusPDU, addr: tuple | None = None) -> None: """Build byte stream and send.""" packet = self.framer.buildFrame(self.trace_pdu(True, pdu)) @@ -175,7 +164,7 @@ def callback_new_connection(self): def callback_connected(self) -> None: """Call when connection is succcesfull.""" - self.count_no_responses = 0 + self.count_until_disconnect = self.max_until_disconnect self.next_tid = 0 self.trace_connect(True) @@ -185,17 +174,13 @@ def callback_disconnected(self, exc: Exception | None) -> None: def callback_data(self, data: bytes, addr: tuple | None = None) -> int: """Handle received data.""" - try: - used_len, pdu = self.framer.processIncomingFrame(self.trace_packet(False, data)) - except ModbusIOException as exc: - if self.is_server: - Log.info(str(exc)) - return len(data) - raise exc + self.last_pdu = self.last_addr = None + used_len, pdu = self.framer.processIncomingFrame(self.trace_packet(False, data)) if pdu: - pdu = self.trace_pdu(False, pdu) - result = (pdu, addr, None) if self.is_server else pdu - self.response_future.set_result(result) + self.last_pdu = self.trace_pdu(False, pdu) + self.last_addr = addr + if not self.is_server: + self.response_future.set_result(pdu) return used_len def getNextTID(self) -> int: diff --git a/pyproject.toml b/pyproject.toml index 1159ef183..d1f25dd44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,7 +219,7 @@ all_files = "1" [tool.pytest.ini_options] testpaths = ["test"] -addopts = "--cov-report html --durations=10 --dist loadscope --numprocesses auto" +addopts = "--cov-report html --durations=3 --dist loadscope --numprocesses auto" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" timeout = 120 diff --git a/test/client/test_client.py b/test/client/test_client.py index 31f2ca384..990aa92e7 100755 --- a/test/client/test_client.py +++ b/test/client/test_client.py @@ -368,6 +368,7 @@ async def test_client_modbusbaseclient(self): ) client.register(pdu_bit.ReadCoilsResponse) assert str(client) + client.set_max_no_responses(110) client.close() async def test_client_connection_made(self): diff --git a/test/client/test_client_sync.py b/test/client/test_client_sync.py index dafda7438..07e8c8cf3 100755 --- a/test/client/test_client_sync.py +++ b/test/client/test_client_sync.py @@ -188,7 +188,7 @@ def test_tcp_client_repr(self): f"ipaddr={client.comm_params.host}, port={client.comm_params.port}, timeout={client.comm_params.timeout_connect}>" ) assert repr(client) == rep - + client.set_max_no_responses(110) class TestSyncClientTls: """Unittest for the pymodbus.client module.""" diff --git a/test/examples/test_examples.py b/test/examples/test_examples.py index 446190a75..dac91e480 100755 --- a/test/examples/test_examples.py +++ b/test/examples/test_examples.py @@ -15,8 +15,8 @@ from examples.client_async_calls import main as main_client_async_calls from examples.client_calls import main as main_client_calls from examples.client_calls import template_call -from examples.client_custom_msg import main as main_custom_client from examples.client_payload import main as main_payload_calls +from examples.custom_msg import main as main_custom_client from examples.datastore_simulator_share import main as main_datastore_simulator_share from examples.message_parser import main as main_parse_messages from examples.server_async import setup_server @@ -142,20 +142,18 @@ async def test_client_calls_errors(self, mock_server): client = setup_async_client(cmdline=mock_server) client.read_coils = mock.Mock(side_effect=ModbusException("test")) with pytest.raises(ModbusException): - await run_async_client(client, modbus_calls=template_call) + await run_async_client(client, modbus_calls=async_template_call) client.close() client.read_coils = mock.Mock(return_value=ExceptionResponse(0x05, 0x10)) with pytest.raises(ModbusException): await run_async_client(client, modbus_calls=template_call) client.close() - async def test_custom_msg( - self, mock_server, use_comm, use_framer, use_port, use_host - ): + async def test_custom_msg(self, use_comm, use_port, use_framer, use_host): """Test client with custom message.""" - if use_comm != "tcp" or use_framer != "socket": + _ = use_framer + if use_comm != "tcp": return - assert mock_server await main_custom_client(port=use_port, host=use_host) async def test_payload(self, mock_clc, mock_cls): diff --git a/test/pdu/test_pdu.py b/test/pdu/test_pdu.py index d441fba43..389f11597 100644 --- a/test/pdu/test_pdu.py +++ b/test/pdu/test_pdu.py @@ -240,3 +240,9 @@ async def test_pdu_datastore(self, pdutype, kwargs, mock_context): context = mock_context() context.validate = lambda a, b, c: True assert await pdu.update_datastore(context) + + async def test_pdu_default_datastore(self, mock_context): + """Test that all PDU types can be created.""" + pdu = ModbusPDU() + context = mock_context() + assert await pdu.update_datastore(context) diff --git a/test/server/test_server_asyncio.py b/test/server/test_server_asyncio.py index c05215414..eab736f0f 100755 --- a/test/server/test_server_asyncio.py +++ b/test/server/test_server_asyncio.py @@ -241,8 +241,12 @@ async def test_async_tcp_server_roundtrip(self): await asyncio.wait_for(BasicClient.done, timeout=0.1) assert BasicClient.received_data, expected_response + @pytest.mark.skip async def test_async_server_file_descriptors(self): - """Test sending and receiving data on tcp socket.""" + """Test sending and receiving data on tcp socket. + + This test takes a long time (minutes) to run, so should only run when needed. + """ addr = ("127.0.0.1", 25001) await self.start_server(serv_addr=addr) for _ in range(2048):