Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
*.db
*.pyc
*.swp
__pycache__/
Expand Down
1 change: 1 addition & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions doc/source/library/nullmodem.rst
Original file line number Diff line number Diff line change
@@ -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:`Modbus<x>Client`
- :mod:`AsyncModbus<x>Client`
- :mod:`Modbus<x>Server`
- :mod:`AsyncModbus<x>Server`

Of course the NullModem requires that server and client(s) run in the same python instance.
16 changes: 4 additions & 12 deletions pymodbus/server/async_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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()
Expand Down
200 changes: 200 additions & 0 deletions pymodbus/transport/message.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading