Skip to content
Merged
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
115 changes: 115 additions & 0 deletions pymodbus/transport/nullmodem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Null modem transport.

This is a special transport, mostly thought of for testing.

NullModem interconnect 2 transport objects and transfers calls:
- server.listen()
- dummy
- client.connect()
- call client.connection_made()
- call server.connection_made()
- client/server.close()
- call client.connection_lost()
- call server.connection_lost()
- server/client.send
- call client/server.data_received()

"""
from __future__ import annotations

import asyncio

from pymodbus.logging import Log
from pymodbus.transport.transport import Transport


class DummyTransport(asyncio.BaseTransport):
"""Use in connection_made calls."""

def close(self):
"""Define dummy."""

def get_protocol(self):
"""Define dummy."""

def is_closing(self):
"""Define dummy."""

def set_protocol(self, _protocol):
"""Define dummy."""

def abort(self):
"""Define dummy."""


class NullModem(Transport):
"""Transport layer.

Contains methods to act as a null modem between 2 objects.
(Allowing tests to be shortcut without actual network calls)
"""

nullmodem_client: NullModem = None
nullmodem_server: NullModem = None

def __init__(self, *arg):
"""Overwrite init."""
self.is_server: bool = False
self.other_end: NullModem = None
super().__init__(*arg)

async def transport_connect(self) -> bool:
"""Handle generic connect and call on to specific transport connect."""
Log.debug("NullModem: Simulate connect on {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
if self.nullmodem_server:
self.__class__.nullmodem_client = self
self.other_end = self.nullmodem_server
self.nullmodem_server.other_end = self
self.cb_connection_made()
self.other_end.cb_connection_made()
return True
return False

async def transport_listen(self):
"""Handle generic listen and call on to specific transport listen."""
Log.debug("NullModem: Simulate listen on {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
self.is_server = True
self.__class__.nullmodem_server = self
return DummyTransport()

# -------------------------------- #
# Helper methods for child classes #
# -------------------------------- #
async def send(self, data: bytes) -> bool:
"""Send request.

:param data: non-empty bytes object with data to send.
"""
Log.debug("NullModem: simulate send {}", data, ":hex")
self.other_end.data_received(data)
return True

def close(self, reconnect: bool = False) -> None:
"""Close connection.

:param reconnect: (default false), try to reconnect
"""
self.recv_buffer = b""
if not reconnect:
if self.nullmodem_client:
self.nullmodem_client.cb_connection_lost(None)
if self.nullmodem_server:
self.nullmodem_server.cb_connection_lost(None)
self.__class__.nullmodem_client = None
self.__class__.nullmodem_server = None

# ----------------- #
# The magic methods #
# ----------------- #
def __str__(self) -> str:
"""Build a string representation of the connection."""
return f"{self.__class__.__name__}({self.comm_params.comm_name})"
14 changes: 6 additions & 8 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Base for all transport types."""
"""Transport layer."""
# mypy: disable-error-code="name-defined"
# needed because asyncio.Server is not defined (to mypy) in v3.8.16
from __future__ import annotations
Expand Down Expand Up @@ -94,7 +94,6 @@ def __init__(

self.reconnect_delay_current: float = 0.0
self.transport: asyncio.BaseTransport | asyncio.Server = None
self.protocol: asyncio.BaseProtocol = None
self.loop: asyncio.AbstractEventLoop = None
self.reconnect_task: asyncio.Task = None
self.recv_buffer: bytes = b""
Expand Down Expand Up @@ -266,9 +265,9 @@ async def transport_connect(self) -> bool:
Log.debug("Connecting {}", self.comm_params.comm_name)
if not self.loop:
self.loop = asyncio.get_running_loop()
self.transport, self.protocol = None, None
self.transport = None
try:
self.transport, self.protocol = await asyncio.wait_for(
self.transport, _protocol = await asyncio.wait_for(
self.call_connect_listen(),
timeout=self.comm_params.timeout_connect,
)
Expand Down Expand Up @@ -346,9 +345,9 @@ def error_received(self, exc):
Log.debug("-> error_received {}", exc)
raise RuntimeError(str(exc))

# -------------------------------- #
# Helper methods for child classes #
# -------------------------------- #
# ----------------------------------- #
# Helper methods for external classes #
# ----------------------------------- #
async def send(self, data: bytes) -> bool:
"""Send request.

Expand All @@ -369,7 +368,6 @@ def close(self, reconnect: bool = False) -> None:
self.transport.abort()
self.transport.close()
self.transport = None
self.protocol = None
if not reconnect and self.reconnect_task:
self.reconnect_task.cancel()
self.reconnect_task = None
Expand Down
54 changes: 39 additions & 15 deletions test/sub_transport/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest
import pytest_asyncio

from pymodbus.transport.nullmodem import NullModem
from pymodbus.transport.transport import Transport


Expand Down Expand Up @@ -38,21 +39,6 @@ def prepare_baseparams(use_port):
return BaseParams


class DummySocket: # pylint: disable=too-few-public-methods
"""Socket simulator for test."""

def __init__(self):
"""Initialize."""
self.close = mock.Mock()
self.abort = mock.Mock()


@pytest.fixture(name="dummy_socket")
def prepare_dummysocket():
"""Prepare dummy_socket class."""
return DummySocket


@pytest.fixture(name="commparams")
def prepare_testparams():
"""Prepare CommParamsClass object."""
Expand Down Expand Up @@ -82,6 +68,44 @@ async def prepare_transport():
return transport


@pytest.fixture(name="nullmodem")
async def prepare_nullmodem():
"""Prepare nullmodem object."""
transport = NullModem(
BaseParams.comm_name,
BaseParams.reconnect_delay,
BaseParams.reconnect_delay_max,
BaseParams.timeout_connect,
mock.Mock(name="cb_connection_made"),
mock.Mock(name="cb_connection_lost"),
mock.Mock(name="cb_handle_data", return_value=0),
)
transport.__class__.nullmodem_client = None
transport.__class__.nullmodem_server = None
with suppress(RuntimeError):
transport.loop = asyncio.get_running_loop()
return transport


@pytest.fixture(name="nullmodem_server")
async def prepare_nullmodem_server():
"""Prepare nullmodem object."""
transport = NullModem(
BaseParams.comm_name,
BaseParams.reconnect_delay,
BaseParams.reconnect_delay_max,
BaseParams.timeout_connect,
mock.Mock(name="cb_connection_made"),
mock.Mock(name="cb_connection_lost"),
mock.Mock(name="cb_handle_data", return_value=0),
)
transport.__class__.nullmodem_client = None
transport.__class__.nullmodem_server = None
with suppress(RuntimeError):
transport.loop = asyncio.get_running_loop()
return transport


@pytest_asyncio.fixture(name="transport_server")
async def prepare_transport_server():
"""Prepare transport object."""
Expand Down
12 changes: 8 additions & 4 deletions test/sub_transport/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pytest
from serial import SerialException

from pymodbus.transport.nullmodem import DummyTransport


class TestBasicTransport:
"""Test transport module, base part."""
Expand Down Expand Up @@ -45,10 +47,10 @@ async def test_str_magic(self, params, transport):
"""Test magic."""
assert str(transport) == f"Transport({params.comm_name})"

async def test_connection_made(self, dummy_socket, transport, commparams):
async def test_connection_made(self, transport, commparams):
"""Test connection_made()."""
transport.loop = None
transport.connection_made(dummy_socket())
transport.connection_made(DummyTransport())
assert transport.transport
assert not transport.recv_buffer
assert not transport.reconnect_task
Expand Down Expand Up @@ -76,9 +78,11 @@ async def test_connection_lost(self, transport):
transport.close()
assert not transport.reconnect_task

async def test_close(self, dummy_socket, transport):
async def test_close(self, transport):
"""Test close()."""
socket = dummy_socket()
socket = DummyTransport()
socket.abort = mock.Mock()
socket.close = mock.Mock()
transport.connection_made(socket)
transport.cb_connection_made.reset_mock()
transport.cb_connection_lost.reset_mock()
Expand Down
118 changes: 118 additions & 0 deletions test/sub_transport/test_nullmodem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Test transport."""

from pymodbus.transport.nullmodem import DummyTransport


class TestNullModemTransport:
"""Test null modem module."""

async def test_str_magic(self, nullmodem, params):
"""Test magic."""
str(nullmodem)
assert str(nullmodem) == f"NullModem({params.comm_name})"

def test_DummyTransport(self):
"""Test DummyTransport class."""
socket = DummyTransport()
socket.close()
socket.get_protocol()
socket.is_closing()
socket.set_protocol(None)
socket.abort()

def test_class_variables(self, nullmodem, nullmodem_server):
"""Test connection_made()."""
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
assert not nullmodem_server.nullmodem_client
assert not nullmodem_server.nullmodem_server
nullmodem.__class__.nullmodem_client = self
nullmodem.is_server = False
nullmodem_server.__class__.nullmodem_server = self
nullmodem_server.is_server = True

assert nullmodem.nullmodem_client == nullmodem_server.nullmodem_client
assert nullmodem.nullmodem_server == nullmodem_server.nullmodem_server

async def test_transport_connect(self, nullmodem):
"""Test connection_made()."""
nullmodem.loop = None
assert not await nullmodem.transport_connect()
assert not nullmodem.nullmodem_server
assert not nullmodem.nullmodem_client
assert nullmodem.loop
nullmodem.cb_connection_made.assert_not_called()
nullmodem.cb_connection_lost.assert_not_called()
nullmodem.cb_handle_data.assert_not_called()

async def test_transport_listen(self, nullmodem_server):
"""Test connection_made()."""
nullmodem_server.loop = None
assert await nullmodem_server.transport_listen()
assert nullmodem_server.is_server
assert nullmodem_server.nullmodem_server
assert not nullmodem_server.nullmodem_client
assert nullmodem_server.loop
nullmodem_server.cb_connection_made.assert_not_called()
nullmodem_server.cb_connection_lost.assert_not_called()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_connected(self, nullmodem, nullmodem_server):
"""Test connection is correct."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
assert nullmodem.nullmodem_client
assert nullmodem.nullmodem_server
assert nullmodem.loop
assert not nullmodem.is_server
assert nullmodem_server.is_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_not_called()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_not_called()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_client_close(self, nullmodem, nullmodem_server):
"""Test close()."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
nullmodem.close()
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_called_once()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_called_once()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_server_close(self, nullmodem, nullmodem_server):
"""Test close()."""
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
nullmodem_server.close()
assert not nullmodem.nullmodem_client
assert not nullmodem.nullmodem_server
nullmodem.cb_connection_made.assert_called_once()
nullmodem.cb_connection_lost.assert_called_once()
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_connection_made.assert_called_once()
nullmodem_server.cb_connection_lost.assert_called_once()
nullmodem_server.cb_handle_data.assert_not_called()

async def test_data(self, nullmodem, nullmodem_server):
"""Test data exchange."""
data = b"abcd"
assert await nullmodem_server.transport_listen()
assert await nullmodem.transport_connect()
assert await nullmodem.send(data)
assert nullmodem_server.recv_buffer == data
assert not nullmodem.recv_buffer
nullmodem.cb_handle_data.assert_not_called()
nullmodem_server.cb_handle_data.assert_called_once()
assert await nullmodem_server.send(data)
assert nullmodem_server.recv_buffer == data
assert nullmodem.recv_buffer == data
nullmodem.cb_handle_data.assert_called_once()
nullmodem_server.cb_handle_data.assert_called_once()