diff --git a/.gitignore b/.gitignore index 2df4ce197..ccafc6135 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -*.db *.pyc *.swp __pycache__/ diff --git a/doc/index.rst b/doc/index.rst index af14c6c65..e2f902a65 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,6 +14,7 @@ Welcome to PyModbus's documentation! changelog.rst source/library/client.rst source/library/server.rst + source/library/nullmodem.rst source/library/simulator/simulator source/library/REPL source/library/datastore.rst diff --git a/doc/source/library/nullmodem.rst b/doc/source/library/nullmodem.rst new file mode 100644 index 000000000..eab73c663 --- /dev/null +++ b/doc/source/library/nullmodem.rst @@ -0,0 +1,20 @@ +NullModem +========= + +Pymodbus offers a special NullModem transport to help end-to-end test without network. + +The NullModem is activated by setting host= (port= for serial) to NULLMODEM_HOST (import pymodbus.transport) + +The NullModem works with the normal transport types, and simply substitutes the physical connection: +- *Serial* (RS-485) typically using a dongle +- *TCP* +- *TLS* +- *UDP* + +The NullModem is currently integrated in +- :mod:`ModbusClient` +- :mod:`AsyncModbusClient` +- :mod:`ModbusServer` +- :mod:`AsyncModbusServer` + +Of course the NullModem requires that server and client(s) run in the same python instance. diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index 45796b4d1..f7dbe8309 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -296,6 +296,7 @@ def __init__( reconnect_delay=0.0, reconnect_delay_max=0.0, timeout_connect=0.0, + new_connection_class=lambda: ModbusServerRequestHandler(self), ), ) params.source_address = address @@ -322,10 +323,6 @@ def __init__( # defer the initialization of the server self.handle_local_echo = False - def handle_new_connection(self): - """Handle incoming connect.""" - return ModbusServerRequestHandler(self) - async def serve_forever(self): """Start endless loop.""" if self.transport: @@ -408,6 +405,7 @@ def __init__( # pylint: disable=too-many-arguments sslctx=CommParams.generate_ssl( True, certfile, keyfile, password, sslctx=sslctx ), + new_connection_class=lambda: ModbusServerRequestHandler(self), ) super().__init__( context, @@ -466,6 +464,7 @@ def __init__( reconnect_delay=0.0, reconnect_delay_max=0.0, timeout_connect=0.0, + new_connection_class=lambda: ModbusServerRequestHandler(self), ), True, ) @@ -487,10 +486,6 @@ def __init__( self.serving_done = asyncio.Future() self.handle_local_echo = False - def handle_new_connection(self): - """Handle incoming connect.""" - return ModbusServerRequestHandler(self) - async def serve_forever(self): """Start endless loop.""" if self.transport: @@ -568,6 +563,7 @@ def __init__( parity=kwargs.get("parity", "N"), baudrate=kwargs.get("baudrate", 19200), stopbits=kwargs.get("stopbits", 1), + new_connection_class=lambda: ModbusServerRequestHandler(self), ), True, ) @@ -593,10 +589,6 @@ def __init__( async def start(self): """Start connecting.""" - def handle_new_connection(self): - """Handle incoming connect.""" - return ModbusServerRequestHandler(self) - async def shutdown(self): """Terminate server.""" self.transport_close() diff --git a/pymodbus/transport/message.py b/pymodbus/transport/message.py new file mode 100644 index 000000000..f047db9da --- /dev/null +++ b/pymodbus/transport/message.py @@ -0,0 +1,200 @@ +"""ModbusMessage layer. + +is extending ModbusTransport to handle receiving and sending of messsagees. + +ModbusMessage provides a unified interface to send/receive Modbus requests/responses. +""" +from enum import Enum + +from pymodbus.logging import Log +from pymodbus.transport.transport import CommParams, ModbusProtocol + + +class CommFrameType(Enum): + """Type of Modbus header""" + + SOCKET = 1 + TLS = 2 + RTU = 3 + ASCII = 4 + + +class ModbusMessage(ModbusProtocol): + """Message layer extending transport layer. + + When receiving: + - Secures full valid Modbus message is received (across multiple callbacks from transport) + - Validates and removes Modbus header (CRC for serial, MBAP for others) + - Decodes frame according to frame type + - Callback with pure request/response + + When sending: + - Encod request/response according to frame type + - Generate Modbus message by adding header (CRC for serial, MBAP for others) + - Call transport to do the actual sending of data + + The class is designed to take care of differences between the different modbus headers, and + provide a neutral interface for the upper layers. + """ + + def __init__( + self, + frameType: CommFrameType, + params: CommParams, + is_server: bool, + slaves: list[int], + function_codes: list[int], + ) -> None: + """Initialize a message instance. + + :param frameType: Modbus frame type + :param params: parameter dataclass + :param is_server: true if object act as a server (listen/connect) + :param slaves: list of slave id to accept + :param function_codes: List of acceptable function codes + """ + self.slaves = slaves + self.framerType: ModbusFrameType = { + CommFrameType.SOCKET: FrameTypeSocket(self), + CommFrameType.TLS: FrameTypeTLS(self), + CommFrameType.RTU: FrameTypeRTU(self), + CommFrameType.ASCII: FrameTypeASCII(self), + }[frameType] + self.function_codes = function_codes + params.new_connection_class = lambda: ModbusMessage( + frameType, + self.comm_params, + False, + self.slaves, + self.function_codes, + ) + super().__init__(params, is_server) + + def callback_data(self, data: bytes, addr: tuple = None) -> int: + """Handle call from transport with data.""" + if len(data) < self.framerType.min_len: + return 0 + + cut_len = self.framerType.verifyFrameHeader(data) + if cut_len: + return cut_len + + # add generic handling + return 0 + + # --------- # + # callbacks # + # --------- # + def callback_message(self, data: bytes) -> None: + """Handle received data.""" + Log.debug("callback_message called: {}", data, ":hex") + + # ----------------------------------- # + # Helper methods for external classes # + # ----------------------------------- # + def message_send(self, data: bytes, addr: tuple = None) -> None: + """Send request. + + :param data: non-empty bytes object with data to send. + :param addr: optional addr, only used for UDP server. + """ + Log.debug("send: {}", data, ":hex") + self.transport_send(data, addr=addr) + + # ---------------- # + # Internal methods # + # ---------------- # + + +class ModbusFrameType: # pylint: disable=too-few-public-methods + """Generic header""" + + min_len: int = 0 + + +class FrameTypeSocket(ModbusFrameType): # pylint: disable=too-few-public-methods + """Modbus Socket frame type. + + [ MBAP Header ] [ Function Code] [ Data ] + [ tid ][ pid ][ length ][ uid ] + 2b 2b 2b 1b 1b Nb + + * length = uid + function code + data + """ + + min_len: int = 8 + + def __init__(self, message): + """Initialize""" + self.message = message + + +class FrameTypeTLS(ModbusFrameType): # pylint: disable=too-few-public-methods + """Modbus TLS frame type + + [ Function Code] [ Data ] + 1b Nb + """ + + min_len: int = 1 + + def __init__(self, message): + """Initialize""" + self.message = message + + +class FrameTypeRTU(ModbusFrameType): # pylint: disable=too-few-public-methods + """Modbus RTU frame type. + + [ Start Wait ] [Address ][ Function Code] [ Data ][ CRC ][ End Wait ] + 3.5 chars 1b 1b Nb 2b 3.5 chars + + Wait refers to the amount of time required to transmit at least x many + characters. In this case it is 3.5 characters. Also, if we receive a + wait of 1.5 characters at any point, we must trigger an error message. + Also, it appears as though this message is little endian. The logic is + simplified as the following:: + + The following table is a listing of the baud wait times for the specified + baud rates:: + + ------------------------------------------------------------------ + Baud 1.5c (18 bits) 3.5c (38 bits) + ------------------------------------------------------------------ + 1200 15,000 ms 31,667 ms + 4800 3,750 ms 7,917 ms + 9600 1,875 ms 3,958 ms + 19200 0,938 ms 1,979 ms + 38400 0,469 ms 0,989 ms + 115200 0,156 ms 0,329 ms + ------------------------------------------------------------------ + 1 Byte = 8 bits + 1 bit parity + 2 stop bit = 11 bits + + * Note: due to the USB converter and the OS drivers, timing cannot be quaranteed + neither when receiving nor when sending. + """ + + min_len: int = 4 + + def __init__(self, message): + """Initialize""" + self.message = message + + +class FrameTypeASCII(ModbusFrameType): # pylint: disable=too-few-public-methods + """Modbus ASCII Frame Controller. + + [ Start ][Address ][ Function ][ Data ][ LRC ][ End ] + 1c 2c 2c Nc 2c 2c + + * data can be 0 - 2x252 ASCII chars + * end is Carriage and return line feed, however the line feed + character can be changed via a special command + * start is ":" + """ + + min_len: int = 9 + + def __init__(self, message): + """Initialize""" + self.message = message diff --git a/pymodbus/transport/transport.py b/pymodbus/transport/transport.py index 6993b0f02..3e0e9551b 100644 --- a/pymodbus/transport/transport.py +++ b/pymodbus/transport/transport.py @@ -47,6 +47,7 @@ class CommParams: port: int = 0 source_address: tuple[str, int] = ("0.0.0.0", 0) handle_local_echo: bool = False + new_connection_class: Callable[[], ModbusProtocol] = None # tls sslctx: ssl.SSLContext = None @@ -136,14 +137,18 @@ def __init__( self.loop: asyncio.AbstractEventLoop = None self.recv_buffer: bytes = b"" self.call_create: Callable[[], Coroutine[Any, Any, Any]] = lambda: None + self.unique_id: str = str(id(self)) if self.is_server: self.active_connections: dict[str, ModbusProtocol] = {} else: self.listener: ModbusProtocol = None - self.unique_id: str = str(id(self)) self.reconnect_task: asyncio.Task = None self.reconnect_delay_current: float = 0.0 self.sent_buffer: bytes = b"" + if not self.comm_params.new_connection_class: + self.comm_params.new_connection_class = lambda: ModbusProtocol( + self.comm_params, False + ) # ModbusProtocol specific setup if self.comm_params.comm_type == CommType.SERIAL: @@ -422,7 +427,7 @@ def handle_new_connection(self): # Clients reuse the same object. return self - new_protocol = ModbusProtocol(self.comm_params, False) + new_protocol = self.comm_params.new_connection_class() self.active_connections[new_protocol.unique_id] = new_protocol new_protocol.listener = self return new_protocol @@ -464,7 +469,7 @@ def __str__(self) -> str: return f"{self.__class__.__name__}({self.comm_params.comm_name})" -class NullModem(asyncio.DatagramTransport, asyncio.Transport): +class NullModem(asyncio.DatagramTransport, asyncio.BaseTransport): """ModbusProtocol layer. Contains methods to act as a null modem between 2 objects. diff --git a/test/sub_transport/test_basic.py b/test/sub_transport/test_basic.py index 8a05ee999..bcd25db03 100644 --- a/test/sub_transport/test_basic.py +++ b/test/sub_transport/test_basic.py @@ -46,7 +46,6 @@ async def test_init_nullmodem(self, client, server): assert client.unique_id == str(id(client)) assert not hasattr(client, "active_connections") assert not client.is_server - assert not hasattr(server, "unique_id") assert not server.active_connections assert server.is_server @@ -101,6 +100,12 @@ async def test_connection_lost(self, client, dummy_protocol): async def test_data_received(self, client): """Test data_received.""" + client.callback_data = mock.MagicMock() + client.datagram_received(b"abc", "127.0.0.1") + client.callback_data.assert_called_once() + + async def test_datagram(self, client): + """Test datagram_received().""" client.callback_data = mock.MagicMock(return_value=2) client.data_received(b"123456") client.callback_data.assert_called_once() @@ -108,12 +113,6 @@ async def test_data_received(self, client): client.data_received(b"789") assert client.recv_buffer == b"56789" - async def test_datagram(self, client): - """Test datagram_received().""" - client.callback_data = mock.MagicMock() - client.datagram_received(b"abc", "127.0.0.1") - client.callback_data.assert_called_once() - async def test_eof_received(self, client): """Test eof_received.""" client.eof_received() @@ -139,8 +138,18 @@ async def test_transport_send(self, client): client.transport_send(b"abc") client.transport_send(b"abc", addr=("localhost", 502)) - async def test_handle_local_echo(self, client): - """Test transport_send().""" + @pytest.mark.parametrize( + ("sent_buffer", "data", "recv_buffer", "new_sent_buffer", "called"), + [ + (b"123", b"abc123def", b"abcdef", b"", 1), + (b"123", b"12", b"", b"3", 0), + (b"123", b"abcdef", b"abcdef", b"", 1), + ], + ) + async def test_handle_local_echo( + self, client, sent_buffer, data, recv_buffer, new_sent_buffer, called + ): + """Test sent_buffer.""" client.comm_params.handle_local_echo = True client.transport = mock.Mock() test_data = b"abc" @@ -162,6 +171,13 @@ async def test_handle_local_echo(self, client): client.datagram_received(test_data, ("127.0.0.1", 502)) assert client.recv_buffer == test_data + test_data assert not client.sent_buffer + client.callback_data = mock.MagicMock(return_value=0) + client.transport = mock.MagicMock() + client.transport_send(sent_buffer) + client.data_received(data) + assert client.recv_buffer == recv_buffer + assert client.sent_buffer == new_sent_buffer + assert client.callback_data.call_count == called async def test_transport_close(self, server, dummy_protocol): """Test transport_close()."""