964 lines
39 KiB
Python
964 lines
39 KiB
Python
"""
|
|
Implementation of a Threaded Modbus Server
|
|
------------------------------------------
|
|
|
|
"""
|
|
from binascii import b2a_hex
|
|
import serial
|
|
from serial_asyncio import create_serial_connection
|
|
import ssl
|
|
import traceback
|
|
|
|
import asyncio
|
|
from pymodbus.compat import PYTHON_VERSION
|
|
from pymodbus.constants import Defaults
|
|
from pymodbus.utilities import hexlify_packets
|
|
from pymodbus.factory import ServerDecoder
|
|
from pymodbus.datastore import ModbusServerContext
|
|
from pymodbus.device import ModbusControlBlock
|
|
from pymodbus.device import ModbusDeviceIdentification
|
|
from pymodbus.transaction import *
|
|
from pymodbus.exceptions import NotImplementedException, NoSuchSlaveException
|
|
from pymodbus.pdu import ModbusExceptions as merror
|
|
from pymodbus.compat import socketserver, byte2int
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Logging
|
|
# --------------------------------------------------------------------------- #
|
|
import logging
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Protocol Handlers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class ModbusBaseRequestHandler(asyncio.BaseProtocol):
|
|
""" Implements modbus slave wire protocol
|
|
This uses the asyncio.Protocol to implement the client handler.
|
|
|
|
When a connection is established, the asyncio.Protocol.connection_made
|
|
callback is called. This callback will setup the connection and
|
|
create and schedule an asyncio.Task and assign it to running_task.
|
|
|
|
running_task will be canceled upon connection_lost event.
|
|
"""
|
|
def __init__(self, owner):
|
|
self.server = owner
|
|
self.running = False
|
|
self.receive_queue = asyncio.Queue()
|
|
self.handler_task = None # coroutine to be run on asyncio loop
|
|
|
|
def _log_exception(self):
|
|
if isinstance(self, ModbusConnectedRequestHandler):
|
|
_logger.error(
|
|
"Handler for stream [%s:%s] has "
|
|
"been canceled" % self.client_address[:2])
|
|
elif isinstance(self, ModbusSingleRequestHandler):
|
|
_logger.error(
|
|
"Handler for serial port has been cancelled")
|
|
else:
|
|
sock_name = self.protocol._sock.getsockname()
|
|
_logger.error("Handler for UDP socket [%s] has "
|
|
"been canceled" % sock_name[1])
|
|
|
|
def connection_made(self, transport):
|
|
"""
|
|
asyncio.BaseProtocol callback for socket establish
|
|
|
|
For streamed protocols (TCP) this will also correspond to an
|
|
entire conversation; however for datagram protocols (UDP) this
|
|
corresponds to the socket being opened
|
|
"""
|
|
try:
|
|
sockname = transport.get_extra_info('sockname')
|
|
if sockname is not None:
|
|
_logger.debug(
|
|
"Socket [%s:%s] opened" % transport.get_extra_info(
|
|
'sockname')[:2])
|
|
else:
|
|
if hasattr(transport, 'serial'):
|
|
_logger.debug(
|
|
"Serial connection opened on port: {}".format(
|
|
transport.serial.port)
|
|
)
|
|
self.transport = transport
|
|
self.running = True
|
|
self.framer = self.server.framer(self.server.decoder, client=None)
|
|
|
|
# schedule the connection handler on the event loop
|
|
if PYTHON_VERSION >= (3, 7):
|
|
self.handler_task = asyncio.create_task(self.handle())
|
|
else:
|
|
self.handler_task = asyncio.ensure_future(self.handle())
|
|
except Exception as ex: # pragma: no cover
|
|
_logger.error("Datastore unable to fulfill request: "
|
|
"%s; %s", ex, traceback.format_exc())
|
|
|
|
def connection_lost(self, exc):
|
|
"""
|
|
asyncio.BaseProtocol callback for socket tear down
|
|
|
|
For streamed protocols any break in the network connection will
|
|
be reported here; for datagram protocols, only a teardown of the
|
|
socket itself will result in this call.
|
|
"""
|
|
try:
|
|
self.handler_task.cancel()
|
|
if exc is None:
|
|
self._log_exception()
|
|
else: # pragma: no cover
|
|
if hasattr(self, "client_address"): # TCP connection
|
|
_logger.debug("Client Disconnection {} due "
|
|
"to {}".format(*self.client_address, exc))
|
|
|
|
self.running = False
|
|
|
|
except Exception as ex: # pragma: no cover
|
|
_logger.error("Datastore unable to fulfill request: "
|
|
"%s; %s", ex, traceback.format_exc())
|
|
|
|
async def handle(self):
|
|
"""Asyncio coroutine which represents a single conversation between
|
|
the modbus slave and master
|
|
|
|
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 ModbusBaseRequestHandler class's callback Future.
|
|
|
|
This callback future gets data from either
|
|
asyncio.DatagramProtocol.datagram_received or
|
|
from asyncio.BaseProtocol.data_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.
|
|
|
|
For ModbusConnectedRequestHandler, each connection will be given an
|
|
instance of the handle() coroutine and this instance will be put in the
|
|
active_connections dict. Calling server_close will individually cancel
|
|
each running handle() task.
|
|
|
|
For ModbusDisconnectedRequestHandler, a single handle() coroutine will
|
|
be started and maintained. Calling server_close will cancel that task.
|
|
|
|
"""
|
|
reset_frame = False
|
|
while self.running:
|
|
try:
|
|
units = self.server.context.slaves()
|
|
# this is an asyncio.Queue await, it will never fail
|
|
data = await self._recv_()
|
|
if isinstance(data, tuple):
|
|
# addr is populated when talking over UDP
|
|
data, *addr = data
|
|
else:
|
|
addr = (None,) # empty tuple
|
|
|
|
if not isinstance(units, (list, tuple)):
|
|
units = [units]
|
|
# if broadcast is enabled make sure to
|
|
# process requests to address 0
|
|
if self.server.broadcast_enable: # pragma: no cover
|
|
if 0 not in units:
|
|
units.append(0)
|
|
|
|
if _logger.isEnabledFor(logging.DEBUG):
|
|
_logger.debug('Handling data: ' + hexlify_packets(data))
|
|
|
|
single = self.server.context.single
|
|
self.framer.processIncomingPacket(
|
|
data=data, callback=lambda x: self.execute(x, *addr),
|
|
unit=units, single=single)
|
|
|
|
except asyncio.CancelledError:
|
|
# catch and ignore cancelation errors
|
|
self._log_exception()
|
|
except Exception as e:
|
|
# force TCP socket termination as processIncomingPacket
|
|
# should handle applicaiton layer errors
|
|
# for UDP sockets, simply reset the frame
|
|
if isinstance(self, ModbusConnectedRequestHandler):
|
|
client_addr = self.client_address[:2]
|
|
_logger.error("Unknown exception '{}' on stream {} "
|
|
"forcing disconnect".format(e, client_addr))
|
|
self.transport.close()
|
|
else:
|
|
_logger.error("Unknown error occurred %s" % e)
|
|
reset_frame = True # graceful recovery
|
|
finally:
|
|
if reset_frame:
|
|
self.framer.resetFrame()
|
|
reset_frame = False
|
|
|
|
def execute(self, request, *addr):
|
|
""" The callback to call with the resulting message
|
|
|
|
:param request: The decoded request message
|
|
"""
|
|
broadcast = False
|
|
try:
|
|
if self.server.broadcast_enable and request.unit_id == 0:
|
|
broadcast = True
|
|
# if broadcasting then execute on all slave contexts,
|
|
# note response will be ignored
|
|
for unit_id in self.server.context.slaves():
|
|
response = request.execute(self.server.context[unit_id])
|
|
else:
|
|
context = self.server.context[request.unit_id]
|
|
response = request.execute(context)
|
|
except NoSuchSlaveException as ex:
|
|
_logger.error("requested slave does "
|
|
"not exist: %s" % request.unit_id)
|
|
if self.server.ignore_missing_slaves:
|
|
return # the client will simply timeout waiting for a response
|
|
response = request.doException(merror.GatewayNoResponse)
|
|
except Exception as ex:
|
|
_logger.error("Datastore unable to fulfill request: "
|
|
"%s; %s", ex, traceback.format_exc())
|
|
response = request.doException(merror.SlaveFailure)
|
|
# no response when broadcasting
|
|
if not broadcast:
|
|
response.transaction_id = request.transaction_id
|
|
response.unit_id = request.unit_id
|
|
skip_encoding = False
|
|
if self.server.response_manipulator:
|
|
response, skip_encoding = self.server.response_manipulator(response)
|
|
self.send(response, *addr, skip_encoding=skip_encoding)
|
|
|
|
def send(self, message, *addr, **kwargs):
|
|
def __send(msg, *addr):
|
|
if _logger.isEnabledFor(logging.DEBUG):
|
|
_logger.debug('send: [%s]- %s' % (message, b2a_hex(msg)))
|
|
if addr == (None,):
|
|
self._send_(msg)
|
|
else:
|
|
self._send_(msg, *addr)
|
|
skip_encoding = kwargs.get("skip_encoding", False)
|
|
if skip_encoding:
|
|
__send(message, *addr)
|
|
elif message.should_respond:
|
|
# self.server.control.Counter.BusMessage += 1
|
|
pdu = self.framer.buildPacket(message)
|
|
__send(pdu, *addr)
|
|
else:
|
|
_logger.debug("Skipping sending response!!")
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Derived class implementations
|
|
# ----------------------------------------------------------------------- #
|
|
|
|
def _send_(self, data): # pragma: no cover
|
|
""" Send a request (string) to the network
|
|
|
|
:param message: The unencoded modbus response
|
|
"""
|
|
raise NotImplementedException("Method not implemented "
|
|
"by derived class")
|
|
|
|
async def _recv_(self): # pragma: no cover
|
|
""" Receive data from the network
|
|
|
|
:return:
|
|
"""
|
|
raise NotImplementedException("Method not implemented "
|
|
"by derived class")
|
|
|
|
|
|
class ModbusConnectedRequestHandler(ModbusBaseRequestHandler,asyncio.Protocol):
|
|
""" Implements the modbus server protocol
|
|
|
|
This uses asyncio.Protocol to implement
|
|
the client handler for a connected protocol (TCP).
|
|
"""
|
|
|
|
def connection_made(self, transport):
|
|
""" asyncio.BaseProtocol: Called when a connection is made. """
|
|
super().connection_made(transport)
|
|
|
|
self.client_address = transport.get_extra_info('peername')
|
|
self.server.active_connections[self.client_address] = self
|
|
_logger.debug("TCP client connection established "
|
|
"[%s:%s]" % self.client_address[:2])
|
|
|
|
def connection_lost(self, exc):
|
|
"""
|
|
asyncio.BaseProtocol: Called when the connection is lost or closed.
|
|
"""
|
|
super().connection_lost(exc)
|
|
client_addr = self.client_address[:2]
|
|
_logger.debug("TCP client disconnected [%s:%s]" % client_addr)
|
|
if self.client_address in self.server.active_connections:
|
|
self.server.active_connections.pop(self.client_address)
|
|
|
|
def data_received(self, data):
|
|
"""
|
|
asyncio.Protocol: (TCP) Called when some data is received.
|
|
data is a non-empty bytes object containing the incoming data.
|
|
"""
|
|
self.receive_queue.put_nowait(data)
|
|
|
|
async def _recv_(self):
|
|
return await self.receive_queue.get()
|
|
|
|
def _send_(self, data):
|
|
""" tcp send """
|
|
self.transport.write(data)
|
|
|
|
|
|
class ModbusDisconnectedRequestHandler(ModbusBaseRequestHandler,
|
|
asyncio.DatagramProtocol):
|
|
""" Implements the modbus server protocol
|
|
|
|
This uses the socketserver.BaseRequestHandler to implement
|
|
the client handler for a disconnected protocol (UDP). The
|
|
only difference is that we have to specify who to send the
|
|
resulting packet data to.
|
|
"""
|
|
def __init__(self,owner):
|
|
super().__init__(owner)
|
|
_future = asyncio.get_event_loop().create_future()
|
|
self.server.on_connection_terminated = _future
|
|
|
|
def connection_lost(self,exc):
|
|
super().connection_lost(exc)
|
|
self.server.on_connection_terminated.set_result(True)
|
|
|
|
def datagram_received(self,data, addr):
|
|
"""
|
|
asyncio.DatagramProtocol: Called when a datagram is received.
|
|
data is a bytes object containing the incoming data. addr
|
|
is the address of the peer sending the data; the exact
|
|
format depends on the transport.
|
|
"""
|
|
self.receive_queue.put_nowait((data, addr))
|
|
|
|
def error_received(self,exc): # pragma: no cover
|
|
"""
|
|
asyncio.DatagramProtocol: Called when a previous send
|
|
or receive operation raises an OSError. exc is the
|
|
OSError instance.
|
|
|
|
This method is called in rare conditions,
|
|
when the transport (e.g. UDP) detects that a datagram could
|
|
not be delivered to its recipient. In many conditions
|
|
though, undeliverable datagrams will be silently dropped.
|
|
"""
|
|
_logger.error("datagram connection error [%s]" % exc)
|
|
|
|
async def _recv_(self):
|
|
return await self.receive_queue.get()
|
|
|
|
def _send_(self, data, addr):
|
|
self.transport.sendto(data, addr=addr)
|
|
|
|
|
|
class ModbusServerFactory:
|
|
"""
|
|
Builder class for a modbus server
|
|
|
|
This also holds the server datastore so that it is persisted between connections
|
|
"""
|
|
|
|
def __init__(self, store, framer=None, identity=None, **kwargs):
|
|
import warnings
|
|
warnings.warn("deprecated API for asyncio. ServerFactory's are a "
|
|
"twisted construct and don't have an equivalent in "
|
|
"asyncio",
|
|
DeprecationWarning)
|
|
|
|
|
|
class ModbusSingleRequestHandler(ModbusBaseRequestHandler, asyncio.Protocol):
|
|
""" Implements the modbus server protocol
|
|
This uses asyncio.Protocol to implement
|
|
the client handler for a serial connection.
|
|
"""
|
|
def connection_made(self, transport):
|
|
super().connection_made(transport)
|
|
|
|
_logger.debug("Serial connection established")
|
|
|
|
def connection_lost(self, exc):
|
|
super().connection_lost(exc)
|
|
_logger.debug("Serial conection lost")
|
|
if hasattr(self.server, 'on_connection_lost'):
|
|
self.server.on_connection_lost()
|
|
|
|
def data_received(self, data):
|
|
self.receive_queue.put_nowait(data)
|
|
|
|
async def _recv_(self):
|
|
return await self.receive_queue.get()
|
|
|
|
def _send_(self, data):
|
|
if self.transport is not None:
|
|
self.transport.write(data)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Server Implementations
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class ModbusTcpServer:
|
|
"""
|
|
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,
|
|
framer=None,
|
|
identity=None,
|
|
address=None,
|
|
handler=None,
|
|
allow_reuse_address=False,
|
|
allow_reuse_port=False,
|
|
defer_start=False,
|
|
backlog=20,
|
|
loop=None,
|
|
**kwargs):
|
|
""" 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 handler: A handler for each client session; default is
|
|
ModbusConnectedRequestHandler. The handler class
|
|
receives connection create/teardown events
|
|
:param allow_reuse_address: Whether the server will allow the
|
|
reuse of an address.
|
|
:param allow_reuse_port: Whether the server will allow the
|
|
reuse of a port.
|
|
:param backlog: is the maximum number of queued connections
|
|
passed to listen(). Defaults to 20, increase if many
|
|
connections are being made and broken to your Modbus slave
|
|
:param loop: optional asyncio event loop to run in. Will default to
|
|
asyncio.get_event_loop() supplied value if None.
|
|
:param ignore_missing_slaves: True to not send errors on a request
|
|
to a missing slave
|
|
:param broadcast_enable: True to treat unit_id 0 as broadcast address,
|
|
False to treat 0 as any other unit_id
|
|
:param response_manipulator: Callback method for manipulating the
|
|
response
|
|
"""
|
|
self.active_connections = {}
|
|
self.loop = loop or asyncio.get_event_loop()
|
|
self.allow_reuse_address = allow_reuse_address
|
|
self.decoder = ServerDecoder()
|
|
self.framer = framer or ModbusSocketFramer
|
|
self.context = context or ModbusServerContext()
|
|
self.control = ModbusControlBlock()
|
|
self.address = address or ("", Defaults.Port)
|
|
self.handler = handler or ModbusConnectedRequestHandler
|
|
self.handler.server = self
|
|
self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves',
|
|
Defaults.IgnoreMissingSlaves)
|
|
self.broadcast_enable = kwargs.get('broadcast_enable',
|
|
Defaults.broadcast_enable)
|
|
self.response_manipulator = kwargs.get("response_manipulator", None)
|
|
if isinstance(identity, ModbusDeviceIdentification):
|
|
self.control.Identity.update(identity)
|
|
|
|
# asyncio future that will be done once server has started
|
|
self.serving = self.loop.create_future()
|
|
# constructors cannot be declared async, so we have to
|
|
# defer the initialization of the server
|
|
self.server = None
|
|
if PYTHON_VERSION >= (3, 7):
|
|
# start_serving is new in version 3.7
|
|
self.server_factory = self.loop.create_server(
|
|
lambda: self.handler(self),
|
|
*self.address,
|
|
reuse_address=allow_reuse_address,
|
|
reuse_port=allow_reuse_port,
|
|
backlog=backlog,
|
|
start_serving=not defer_start
|
|
)
|
|
else:
|
|
self.server_factory = self.loop.create_server(
|
|
lambda: self.handler(self),
|
|
*self.address,
|
|
reuse_address=allow_reuse_address,
|
|
reuse_port=allow_reuse_port,
|
|
backlog=backlog
|
|
)
|
|
|
|
async def serve_forever(self):
|
|
if self.server is None:
|
|
self.server = await self.server_factory
|
|
self.serving.set_result(True)
|
|
await self.server.serve_forever()
|
|
else:
|
|
raise RuntimeError("Can't call serve_forever on "
|
|
"an already running server object")
|
|
|
|
def server_close(self):
|
|
for k, v in self.active_connections.items():
|
|
_logger.warning("aborting active session {}".format(k))
|
|
v.handler_task.cancel()
|
|
self.active_connections = {}
|
|
self.server.close()
|
|
|
|
|
|
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__(self,
|
|
context,
|
|
framer=None,
|
|
identity=None,
|
|
address=None,
|
|
sslctx=None,
|
|
certfile=None,
|
|
keyfile=None,
|
|
handler=None,
|
|
allow_reuse_address=False,
|
|
allow_reuse_port=False,
|
|
defer_start=False,
|
|
backlog=20,
|
|
loop=None,
|
|
**kwargs):
|
|
""" 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 handler: A handler for each client session; default is
|
|
ModbusConnectedRequestHandler. The handler class
|
|
receives connection create/teardown events
|
|
:param allow_reuse_address: Whether the server will allow the
|
|
reuse of an address.
|
|
:param allow_reuse_port: Whether the server will allow the
|
|
reuse of a port.
|
|
:param backlog: is the maximum number of queued connections
|
|
passed to listen(). Defaults to 20, increase if many
|
|
connections are being made and broken to your Modbus slave
|
|
:param loop: optional asyncio event loop to run in. Will default to
|
|
asyncio.get_event_loop() supplied value if None.
|
|
:param ignore_missing_slaves: True to not send errors on a request
|
|
to a missing slave
|
|
:param broadcast_enable: True to treat unit_id 0 as broadcast address,
|
|
False to treat 0 as any other unit_id
|
|
:param response_manipulator: Callback method for
|
|
manipulating the response
|
|
"""
|
|
self.active_connections = {}
|
|
self.loop = loop or asyncio.get_event_loop()
|
|
self.allow_reuse_address = allow_reuse_address
|
|
self.decoder = ServerDecoder()
|
|
self.framer = framer or ModbusTlsFramer
|
|
self.context = context or ModbusServerContext()
|
|
self.control = ModbusControlBlock()
|
|
self.address = address or ("", Defaults.Port)
|
|
self.handler = handler or ModbusConnectedRequestHandler
|
|
self.handler.server = self
|
|
self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves',
|
|
Defaults.IgnoreMissingSlaves)
|
|
self.broadcast_enable = kwargs.get('broadcast_enable',
|
|
Defaults.broadcast_enable)
|
|
self.response_manipulator = kwargs.get("response_manipulator", None)
|
|
|
|
if isinstance(identity, ModbusDeviceIdentification):
|
|
self.control.Identity.update(identity)
|
|
|
|
self.sslctx = sslctx
|
|
if self.sslctx is None:
|
|
self.sslctx = ssl.create_default_context()
|
|
self.sslctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
|
|
# According to MODBUS/TCP Security Protocol Specification, it is
|
|
# TLSv2 at least
|
|
self.sslctx.options |= ssl.OP_NO_TLSv1_1
|
|
self.sslctx.options |= ssl.OP_NO_TLSv1
|
|
self.sslctx.options |= ssl.OP_NO_SSLv3
|
|
self.sslctx.options |= ssl.OP_NO_SSLv2
|
|
self.sslctx.verify_mode = ssl.CERT_OPTIONAL
|
|
self.sslctx.check_hostname = False
|
|
# asyncio future that will be done once server has started
|
|
self.serving = self.loop.create_future()
|
|
# constructors cannot be declared async, so we have to
|
|
# defer the initialization of the server
|
|
self.server = None
|
|
if PYTHON_VERSION >= (3, 7):
|
|
# start_serving is new in version 3.7
|
|
self.server_factory = self.loop.create_server(
|
|
lambda: self.handler(self),
|
|
*self.address,
|
|
ssl=self.sslctx,
|
|
reuse_address=allow_reuse_address,
|
|
reuse_port=allow_reuse_port,
|
|
backlog=backlog,
|
|
start_serving=not defer_start
|
|
)
|
|
else:
|
|
self.server_factory = self.loop.create_server(
|
|
lambda: self.handler(self),
|
|
*self.address,
|
|
ssl=self.sslctx,
|
|
reuse_address=allow_reuse_address,
|
|
reuse_port=allow_reuse_port,
|
|
backlog=backlog
|
|
)
|
|
|
|
|
|
class ModbusUdpServer:
|
|
"""
|
|
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, framer=None, identity=None, address=None,
|
|
handler=None, allow_reuse_address=False,
|
|
allow_reuse_port=False,
|
|
defer_start=False,
|
|
backlog=20,
|
|
loop=None,
|
|
**kwargs):
|
|
""" 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 handler: A handler for each client session; default is
|
|
ModbusDisonnectedRequestHandler
|
|
:param ignore_missing_slaves: True to not send errors on a request
|
|
to a missing slave
|
|
:param broadcast_enable: True to treat unit_id 0 as broadcast address,
|
|
False to treat 0 as any other unit_id
|
|
:param response_manipulator: Callback method for
|
|
manipulating the response
|
|
"""
|
|
self.loop = loop or asyncio.get_event_loop()
|
|
self.decoder = ServerDecoder()
|
|
self.framer = framer or ModbusSocketFramer
|
|
self.context = context or ModbusServerContext()
|
|
self.control = ModbusControlBlock()
|
|
self.address = address or ("", Defaults.Port)
|
|
self.handler = handler or ModbusDisconnectedRequestHandler
|
|
self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves',
|
|
Defaults.IgnoreMissingSlaves)
|
|
self.broadcast_enable = kwargs.get('broadcast_enable',
|
|
Defaults.broadcast_enable)
|
|
self.response_manipulator = kwargs.get("response_manipulator", None)
|
|
|
|
if isinstance(identity, ModbusDeviceIdentification):
|
|
self.control.Identity.update(identity)
|
|
|
|
self.protocol = None
|
|
self.endpoint = None
|
|
self.on_connection_terminated = None
|
|
self.stop_serving = self.loop.create_future()
|
|
# asyncio future that will be done once server has started
|
|
self.serving = self.loop.create_future()
|
|
self.server_factory = self.loop.create_datagram_endpoint(
|
|
lambda: self.handler(self),
|
|
local_addr=self.address,
|
|
reuse_address=allow_reuse_address,
|
|
reuse_port=allow_reuse_port,
|
|
allow_broadcast=True
|
|
)
|
|
|
|
async def serve_forever(self):
|
|
if self.protocol is None:
|
|
self.protocol, self.endpoint = await self.server_factory
|
|
self.serving.set_result(True)
|
|
await self.stop_serving
|
|
else:
|
|
raise RuntimeError("Can't call serve_forever on an "
|
|
"already running server object")
|
|
|
|
def server_close(self):
|
|
self.stop_serving.set_result(True)
|
|
if self.endpoint.handler_task is not None:
|
|
self.endpoint.handler_task.cancel()
|
|
|
|
self.protocol.close()
|
|
|
|
|
|
class ModbusSerialServer(object):
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
handler = None
|
|
|
|
def __init__(self, context, framer=None, identity=None, **kwargs): # pragma: no cover
|
|
""" Overloaded initializer for 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
|
|
: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 ignore_missing_slaves: True to not send errors on a request
|
|
to a missing slave
|
|
:param broadcast_enable: True to treat unit_id 0 as broadcast address,
|
|
False to treat 0 as any other unit_id
|
|
:param autoreonnect: True to enable automatic reconnection,
|
|
False otherwise
|
|
:param reconnect_delay: reconnect delay in seconds
|
|
:param response_manipulator: Callback method for
|
|
manipulating the response
|
|
"""
|
|
self.device = kwargs.get('port', 0)
|
|
self.stopbits = kwargs.get('stopbits', Defaults.Stopbits)
|
|
self.bytesize = kwargs.get('bytesize', Defaults.Bytesize)
|
|
self.parity = kwargs.get('parity', Defaults.Parity)
|
|
self.baudrate = kwargs.get('baudrate', Defaults.Baudrate)
|
|
self.timeout = kwargs.get('timeout', Defaults.Timeout)
|
|
self.ignore_missing_slaves = kwargs.get('ignore_missing_slaves',
|
|
Defaults.IgnoreMissingSlaves)
|
|
self.broadcast_enable = kwargs.get('broadcast_enable',
|
|
Defaults.broadcast_enable)
|
|
self.auto_reconnect = kwargs.get('auto_reconnect', False)
|
|
self.reconnect_delay = kwargs.get('reconnect_delay', 2)
|
|
self.reconnecting_task = None
|
|
|
|
self.handler = kwargs.get("handler") or ModbusSingleRequestHandler
|
|
self.framer = framer or ModbusRtuFramer
|
|
self.decoder = ServerDecoder()
|
|
self.context = context or ModbusServerContext()
|
|
self.response_manipulator = kwargs.get("response_manipulator", None)
|
|
self.control = ModbusControlBlock()
|
|
if isinstance(identity, ModbusDeviceIdentification):
|
|
self.control.Identity.update(identity)
|
|
|
|
self.protocol = None
|
|
self.transport = None
|
|
|
|
async def start(self):
|
|
await self._connect()
|
|
|
|
def _protocol_factory(self):
|
|
return self.handler(self)
|
|
|
|
async def _delayed_connect(self):
|
|
await asyncio.sleep(self.reconnect_delay)
|
|
await self._connect()
|
|
|
|
async def _connect(self):
|
|
if self.reconnecting_task is not None:
|
|
self.reconnecting_task = None
|
|
|
|
try:
|
|
self.transport, self.protocol = await create_serial_connection(
|
|
asyncio.get_event_loop(),
|
|
self._protocol_factory,
|
|
self.device,
|
|
baudrate=self.baudrate,
|
|
bytesize=self.bytesize,
|
|
parity=self.parity,
|
|
stopbits=self.stopbits,
|
|
timeout=self.timeout
|
|
)
|
|
except serial.serialutil.SerialException as e:
|
|
_logger.debug("Failed to open serial port: {}".format(self.device))
|
|
if not self.auto_reconnect:
|
|
raise e
|
|
|
|
self._check_reconnect()
|
|
|
|
except Exception as e:
|
|
_logger.debug("Exception while create - {}".format(e))
|
|
|
|
def on_connection_lost(self):
|
|
if self.transport is not None:
|
|
self.transport.close()
|
|
self.transport = None
|
|
self.protocol = None
|
|
|
|
self._check_reconnect()
|
|
|
|
def _check_reconnect(self):
|
|
_logger.debug("checkking autoreconnect {} {}".format(
|
|
self.auto_reconnect, self.reconnecting_task))
|
|
if self.auto_reconnect and (self.reconnecting_task is None):
|
|
_logger.debug("Scheduling serial connection reconnect")
|
|
loop = asyncio.get_event_loop()
|
|
self.reconnecting_task = loop.create_task(self._delayed_connect())
|
|
|
|
async def serve_forever(self):
|
|
while True:
|
|
await asyncio.sleep(360)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Creation Factories
|
|
# --------------------------------------------------------------------------- #
|
|
async def StartTcpServer(context=None, identity=None, address=None,
|
|
custom_functions=[], defer_start=True, **kwargs):
|
|
""" A factory to start and run a tcp modbus server
|
|
|
|
:param context: The ModbusServerContext datastore
|
|
:param identity: An optional identify structure
|
|
:param address: An optional (interface, port) to bind to.
|
|
:param custom_functions: An optional list of custom function classes
|
|
supported by server instance.
|
|
:param defer_start: if set, a coroutine which can be started and stopped
|
|
will be returned. Otherwise, the server will be immediately spun
|
|
up without the ability to shut it off from within the asyncio loop
|
|
:param ignore_missing_slaves: True to not send errors on a request to a
|
|
missing slave
|
|
:return: an initialized but inactive server object coroutine
|
|
"""
|
|
framer = kwargs.pop("framer", ModbusSocketFramer)
|
|
server = ModbusTcpServer(context, framer, identity, address, **kwargs)
|
|
|
|
for f in custom_functions:
|
|
server.decoder.register(f) # pragma: no cover
|
|
|
|
if not defer_start:
|
|
await server.serve_forever()
|
|
|
|
return server
|
|
|
|
|
|
async def StartTlsServer(context=None, identity=None, address=None,
|
|
sslctx=None,
|
|
certfile=None, keyfile=None,
|
|
allow_reuse_address=False,
|
|
allow_reuse_port=False,
|
|
custom_functions=[],
|
|
defer_start=True, **kwargs):
|
|
""" A factory to start and run a tls modbus server
|
|
|
|
:param context: The ModbusServerContext datastore
|
|
: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 allow_reuse_address: Whether the server will allow the reuse of an
|
|
address.
|
|
:param allow_reuse_port: Whether the server will allow the reuse of a port.
|
|
:param custom_functions: An optional list of custom function classes
|
|
supported by server instance.
|
|
:param defer_start: if set, a coroutine which can be started and stopped
|
|
will be returned. Otherwise, the server will be immediately spun
|
|
up without the ability to shut it off from within the asyncio loop
|
|
:param ignore_missing_slaves: True to not send errors on a request to a
|
|
missing slave
|
|
:return: an initialized but inactive server object coroutine
|
|
"""
|
|
framer = kwargs.pop("framer", ModbusTlsFramer)
|
|
server = ModbusTlsServer(context, framer, identity, address, sslctx,
|
|
certfile, keyfile,
|
|
allow_reuse_address=allow_reuse_address,
|
|
allow_reuse_port=allow_reuse_port, **kwargs)
|
|
|
|
for f in custom_functions:
|
|
server.decoder.register(f) # pragma: no cover
|
|
|
|
if not defer_start:
|
|
await server.serve_forever()
|
|
|
|
return server
|
|
|
|
|
|
async def StartUdpServer(context=None, identity=None, address=None,
|
|
custom_functions=[], defer_start=True, **kwargs):
|
|
""" A factory to start and run a udp modbus server
|
|
|
|
:param context: The ModbusServerContext datastore
|
|
:param identity: An optional identify structure
|
|
:param address: An optional (interface, port) to bind to.
|
|
:param custom_functions: An optional list of custom function classes
|
|
supported by server instance.
|
|
:param framer: The framer to operate with (default ModbusSocketFramer)
|
|
:param ignore_missing_slaves: True to not send errors on a request
|
|
to a missing slave
|
|
"""
|
|
framer = kwargs.pop('framer', ModbusSocketFramer)
|
|
server = ModbusUdpServer(context, framer, identity, address, **kwargs)
|
|
|
|
for f in custom_functions:
|
|
server.decoder.register(f) # pragma: no cover
|
|
|
|
if not defer_start:
|
|
await server.serve_forever() # pragma: no cover
|
|
|
|
return server
|
|
|
|
|
|
async def StartSerialServer(context=None, identity=None,
|
|
custom_functions=[], **kwargs): # pragma: no cover
|
|
""" A factory to start and run a serial modbus server
|
|
|
|
:param context: The ModbusServerContext datastore
|
|
:param identity: An optional identify structure
|
|
:param custom_functions: An optional list of custom function classes
|
|
supported by server instance.
|
|
:param framer: The framer to operate with (default ModbusAsciiFramer)
|
|
: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 ignore_missing_slaves: True to not send errors on a request to a
|
|
missing slave
|
|
"""
|
|
framer = kwargs.pop('framer', ModbusAsciiFramer)
|
|
server = ModbusSerialServer(context, framer, identity, **kwargs)
|
|
for f in custom_functions:
|
|
server.decoder.register(f)
|
|
await server.start()
|
|
await server.serve_forever()
|
|
|
|
|
|
def StopServer():
|
|
"""
|
|
Helper method to stop Async Server
|
|
"""
|
|
import warnings
|
|
warnings.warn("deprecated API for asyncio. Call server_close() on "
|
|
"server object returned by StartXxxServer",
|
|
DeprecationWarning)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Exported symbols
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
__all__ = [
|
|
|
|
"StartTcpServer", "StartTlsServer", "StartUdpServer", "StartSerialServer"
|
|
|
|
]
|