Skip to content

Commit 84ab5d4

Browse files
authored
New nullmodem and transport. (#1696)
1 parent b3e63f0 commit 84ab5d4

File tree

19 files changed

+639
-236
lines changed

19 files changed

+639
-236
lines changed

pymodbus/client/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pymodbus.logging import Log
1414
from pymodbus.pdu import ModbusRequest, ModbusResponse
1515
from pymodbus.transaction import DictTransactionManager
16-
from pymodbus.transport.transport import CommParams, ModbusProtocol
16+
from pymodbus.transport import CommParams, ModbusProtocol
1717
from pymodbus.utilities import ModbusTransactionState
1818

1919

pymodbus/client/serial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pymodbus.framer import ModbusFramer
1111
from pymodbus.framer.rtu_framer import ModbusRtuFramer
1212
from pymodbus.logging import Log
13-
from pymodbus.transport.transport import CommType
13+
from pymodbus.transport import CommType
1414
from pymodbus.utilities import ModbusTransactionState
1515

1616

pymodbus/client/tcp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pymodbus.framer import ModbusFramer
1111
from pymodbus.framer.socket_framer import ModbusSocketFramer
1212
from pymodbus.logging import Log
13-
from pymodbus.transport.transport import CommType
13+
from pymodbus.transport import CommType
1414
from pymodbus.utilities import ModbusTransactionState
1515

1616

pymodbus/client/tls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pymodbus.framer import ModbusFramer
88
from pymodbus.framer.tls_framer import ModbusTlsFramer
99
from pymodbus.logging import Log
10-
from pymodbus.transport.transport import CommParams, CommType
10+
from pymodbus.transport import CommParams, CommType
1111

1212

1313
class AsyncModbusTlsClient(AsyncModbusTcpClient):

pymodbus/client/udp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pymodbus.framer import ModbusFramer
99
from pymodbus.framer.socket_framer import ModbusSocketFramer
1010
from pymodbus.logging import Log
11-
from pymodbus.transport.transport import CommType
11+
from pymodbus.transport import CommType
1212

1313

1414
DGRAM_TYPE = socket.SOCK_DGRAM

pymodbus/server/async_io.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
ModbusSocketFramer,
2020
ModbusTlsFramer,
2121
)
22-
from pymodbus.transport.transport import CommParams, CommType, ModbusProtocol
22+
from pymodbus.transport import CommParams, CommType, ModbusProtocol
2323

2424

2525
with suppress(ImportError):

pymodbus/transport/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,22 @@
11
"""Transport."""
2+
__all__ = [
3+
"CommParams",
4+
"CommType",
5+
"create_serial_connection",
6+
"ModbusProtocol",
7+
"NullModem",
8+
"NULLMODEM_HOST",
9+
"SerialTransport",
10+
]
11+
12+
from pymodbus.transport.transport import (
13+
NULLMODEM_HOST,
14+
CommParams,
15+
CommType,
16+
ModbusProtocol,
17+
NullModem,
18+
)
19+
from pymodbus.transport.transport_serial import (
20+
SerialTransport,
21+
create_serial_connection,
22+
)

pymodbus/transport/transport.py

Lines changed: 112 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""ModbusProtocol layer."""
2-
# mypy: disable-error-code="name-defined,union-attr"
3-
# needed because asyncio.Server is not defined (to mypy) in v3.8.16
42
from __future__ import annotations
53

64
import asyncio
75
import dataclasses
86
import ssl
7+
from contextlib import suppress
98
from enum import Enum
109
from typing import Any, Callable, Coroutine
1110

@@ -124,7 +123,7 @@ def __init__(
124123
self.is_server = is_server
125124
self.is_closing = False
126125

127-
self.transport: asyncio.BaseModbusProtocol | asyncio.Server = None
126+
self.transport: asyncio.BaseTransport = None
128127
self.loop: asyncio.AbstractEventLoop = None
129128
self.recv_buffer: bytes = b""
130129
self.call_create: Callable[[], Coroutine[Any, Any, Any]] = lambda: None
@@ -258,7 +257,7 @@ async def transport_listen(self) -> bool:
258257
# ---------------------------------- #
259258
# ModbusProtocol asyncio standard methods #
260259
# ---------------------------------- #
261-
def connection_made(self, transport: asyncio.BaseModbusProtocol):
260+
def connection_made(self, transport: asyncio.BaseTransport):
262261
"""Call from asyncio, when a connection is made.
263262
264263
:param transport: socket etc. representing the connection.
@@ -298,10 +297,23 @@ def datagram_received(self, data: bytes, addr: tuple):
298297
self.sent_buffer = b""
299298
if not data:
300299
return
301-
Log.debug("recv: {} addr={}", data, ":hex", addr)
300+
Log.debug(
301+
"recv: {} old_data: {} addr={}",
302+
data,
303+
":hex",
304+
self.recv_buffer,
305+
":hex",
306+
addr,
307+
)
302308
self.recv_buffer += data
303309
cut = self.callback_data(self.recv_buffer, addr=addr)
304310
self.recv_buffer = self.recv_buffer[cut:]
311+
if self.recv_buffer:
312+
Log.debug(
313+
"recv, unused data waiting for next packet: {}",
314+
self.recv_buffer,
315+
":hex",
316+
)
305317

306318
def eof_received(self):
307319
"""Accept other end terminates connection."""
@@ -342,11 +354,11 @@ def transport_send(self, data: bytes, addr: tuple = None) -> None:
342354
self.sent_buffer = data
343355
if self.comm_params.comm_type == CommType.UDP:
344356
if addr:
345-
self.transport.sendto(data, addr=addr)
357+
self.transport.sendto(data, addr=addr) # type: ignore[attr-defined]
346358
else:
347-
self.transport.sendto(data)
359+
self.transport.sendto(data) # type: ignore[attr-defined]
348360
else:
349-
self.transport.write(data)
361+
self.transport.write(data) # type: ignore[attr-defined]
350362

351363
def transport_close(self, intern: bool = False, reconnect: bool = False) -> None:
352364
"""Close connection.
@@ -392,26 +404,11 @@ async def create_nullmodem(self, port):
392404
"""Bypass create_ and use null modem"""
393405
if self.is_server:
394406
# Listener object
395-
self.transport = NullModem(self)
396-
NullModem.listener_new_connection[port] = self.handle_new_connection
407+
self.transport = NullModem.set_listener(port, self)
397408
return self.transport, self
398409

399410
# connect object
400-
client_protocol = self.handle_new_connection()
401-
try:
402-
server_protocol = NullModem.listener_new_connection[port]()
403-
except KeyError as exc:
404-
raise asyncio.TimeoutError(
405-
f"No listener on port {self.comm_params.port} for connect"
406-
) from exc
407-
408-
client_transport = NullModem(client_protocol)
409-
server_transport = NullModem(server_protocol)
410-
client_transport.other_transport = server_transport
411-
server_transport.other_transport = client_transport
412-
client_protocol.connection_made(client_transport)
413-
server_protocol.connection_made(server_transport)
414-
return client_transport, client_protocol
411+
return NullModem.set_connection(port, self)
415412

416413
def handle_new_connection(self):
417414
"""Handle incoming connect."""
@@ -468,46 +465,117 @@ class NullModem(asyncio.DatagramTransport, asyncio.Transport):
468465
(Allowing tests to be shortcut without actual network calls)
469466
"""
470467

471-
listener_new_connection: dict[int, ModbusProtocol] = {}
468+
listeners: dict[int, ModbusProtocol] = {}
469+
connections: dict[NullModem, int] = {}
472470

473-
def __init__(self, protocol: ModbusProtocol):
471+
def __init__(self, protocol: ModbusProtocol, listen: int = None) -> None:
474472
"""Create half part of null modem"""
475473
asyncio.DatagramTransport.__init__(self)
476474
asyncio.Transport.__init__(self)
477-
self.other: NullModem = None
478-
self.protocol: ModbusProtocol | asyncio.BaseProtocol = protocol
475+
self.protocol: ModbusProtocol = protocol
479476
self.serving: asyncio.Future = asyncio.Future()
480-
self.other_transport: NullModem = None
477+
self.other_modem: NullModem = None
478+
self.listen = listen
479+
self.manipulator: Callable[[bytes], list[bytes]] = None
480+
self._is_closing = False
481+
482+
# -------------------------- #
483+
# external nullmodem methods #
484+
# -------------------------- #
485+
@classmethod
486+
def set_listener(cls, port: int, parent: ModbusProtocol) -> NullModem:
487+
"""Register listener."""
488+
if port in cls.listeners:
489+
raise AssertionError(f"Port {port} already listening !")
490+
cls.listeners[port] = parent
491+
return NullModem(parent, listen=port)
492+
493+
@classmethod
494+
def set_connection(
495+
cls, port: int, parent: ModbusProtocol
496+
) -> tuple[NullModem, ModbusProtocol]:
497+
"""Connect to listener."""
498+
if port not in cls.listeners:
499+
raise asyncio.TimeoutError(f"Port {port} not being listened on !")
500+
501+
client_protocol = parent.handle_new_connection()
502+
server_protocol = NullModem.listeners[port].handle_new_connection()
503+
client_transport = NullModem(client_protocol)
504+
server_transport = NullModem(server_protocol)
505+
cls.connections[client_transport] = port
506+
cls.connections[server_transport] = -port
507+
client_transport.other_modem = server_transport
508+
server_transport.other_modem = client_transport
509+
client_protocol.connection_made(client_transport)
510+
server_protocol.connection_made(server_transport)
511+
return client_transport, client_protocol
512+
513+
def set_manipulator(self, function: Callable[[bytes], list[bytes]]) -> None:
514+
"""Register a manipulator."""
515+
self.manipulator = function
516+
517+
@classmethod
518+
def is_dirty(cls):
519+
"""Check if everything is closed."""
520+
dirty = False
521+
if cls.connections:
522+
Log.error(
523+
"NullModem_FATAL missing close on port {} connect()",
524+
[str(key) for key in cls.connections.values()],
525+
)
526+
dirty = True
527+
if cls.listeners:
528+
Log.error(
529+
"NullModem_FATAL missing close on port {} listen()",
530+
[str(value) for value in cls.listeners],
531+
)
532+
dirty = True
533+
return dirty
481534

482535
# ---------------- #
483536
# external methods #
484537
# ---------------- #
485538

486-
def close(self):
539+
def close(self) -> None:
487540
"""Close null modem"""
541+
if self._is_closing:
542+
return
543+
self._is_closing = True
488544
if not self.serving.done():
489545
self.serving.set_result(True)
490-
if self.other_transport:
491-
self.other_transport.other_transport = None
492-
self.other_transport.protocol.connection_lost(None)
493-
self.other_transport = None
546+
if self.listen:
547+
del self.listeners[self.listen]
548+
return
549+
if self.connections:
550+
with suppress(KeyError):
551+
del self.connections[self]
552+
if self.other_modem:
553+
self.other_modem.other_modem = None
554+
self.other_modem.close()
555+
self.other_modem = None
556+
if self.protocol:
494557
self.protocol.connection_lost(None)
495558

496-
def sendto(self, data: bytes, _addr: Any = None):
559+
def sendto(self, data: bytes, _addr: Any = None) -> None:
497560
"""Send datagrame"""
498-
return self.write(data)
561+
self.write(data)
499562

500-
def write(self, data: bytes):
563+
def write(self, data: bytes) -> None:
501564
"""Send data"""
502-
self.other_transport.protocol.data_received(data)
565+
if not self.manipulator:
566+
self.other_modem.protocol.data_received(data)
567+
return
568+
data_manipulated = self.manipulator(data)
569+
for part in data_manipulated:
570+
self.other_modem.protocol.data_received(part)
503571

504-
async def serve_forever(self):
572+
async def serve_forever(self) -> None:
505573
"""Serve forever"""
506574
await self.serving
507575

508-
# ---------------- #
509-
# Abstract methods #
510-
# ---------------- #
576+
# ------------- #
577+
# Dummy methods #
578+
# ------------- #
511579
def abort(self) -> None:
512580
"""Abort connection."""
513581
self.close()
@@ -536,11 +604,10 @@ def get_protocol(self) -> ModbusProtocol | asyncio.BaseProtocol:
536604

537605
def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
538606
"""Set current protocol."""
539-
self.protocol = protocol
540607

541608
def is_closing(self) -> bool:
542609
"""Return true if closing"""
543-
return False
610+
return self._is_closing
544611

545612
def is_reading(self) -> bool:
546613
"""Return true if read is active."""

test/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ def pytest_configure():
1616
# -----------------------------------------------------------------------#
1717
# Generic fixtures
1818
# -----------------------------------------------------------------------#
19+
BASE_PORTS = {
20+
"TestBasicModbusProtocol": 8100,
21+
"TestBasicSerial": 8200,
22+
"TestCommModbusProtocol": 8300,
23+
"TestCommNullModem": 8400,
24+
"TestExamples": 8500,
25+
"TestModbusProtocol": 8600,
26+
"TestNullModem": 8700,
27+
"TestReconnectModbusProtocol": 8800,
28+
}
29+
30+
31+
@pytest.fixture(name="base_ports", scope="package")
32+
def get_base_ports():
33+
"""Return base_ports"""
34+
return BASE_PORTS
1935

2036

2137
class MockContext(ModbusBaseSlaveContext):

test/sub_examples/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from examples.server_async import run_async_server, setup_server
88
from pymodbus.server import ServerAsyncStop
9-
from pymodbus.transport.transport import NULLMODEM_HOST
9+
from pymodbus.transport import NULLMODEM_HOST
1010

1111

1212
@pytest.fixture(name="port_offset")

0 commit comments

Comments
 (0)