540 lines
22 KiB
Python
540 lines
22 KiB
Python
"""
|
|
Collection of transaction based abstractions
|
|
|
|
"""
|
|
|
|
import struct
|
|
import socket
|
|
import time
|
|
from threading import RLock
|
|
from functools import partial
|
|
|
|
from pymodbus.exceptions import ModbusIOException, NotImplementedException
|
|
from pymodbus.exceptions import InvalidMessageReceivedException
|
|
from pymodbus.constants import Defaults
|
|
from pymodbus.framer.ascii_framer import ModbusAsciiFramer
|
|
from pymodbus.framer.rtu_framer import ModbusRtuFramer
|
|
from pymodbus.framer.socket_framer import ModbusSocketFramer
|
|
from pymodbus.framer.tls_framer import ModbusTlsFramer
|
|
from pymodbus.framer.binary_framer import ModbusBinaryFramer
|
|
from pymodbus.utilities import hexlify_packets, ModbusTransactionState
|
|
from pymodbus.compat import iterkeys, byte2int
|
|
|
|
|
|
# Python 2 compatibility.
|
|
try:
|
|
TimeoutError
|
|
except NameError:
|
|
TimeoutError = socket.timeout
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Logging
|
|
# --------------------------------------------------------------------------- #
|
|
import logging
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The Global Transaction Manager
|
|
# --------------------------------------------------------------------------- #
|
|
class ModbusTransactionManager(object):
|
|
""" Implements a transaction for a manager
|
|
|
|
The transaction protocol can be represented by the following pseudo code::
|
|
|
|
count = 0
|
|
do
|
|
result = send(message)
|
|
if (timeout or result == bad)
|
|
count++
|
|
else break
|
|
while (count < 3)
|
|
|
|
This module helps to abstract this away from the framer and protocol.
|
|
"""
|
|
|
|
def __init__(self, client, **kwargs):
|
|
""" Initializes an instance of the ModbusTransactionManager
|
|
|
|
:param client: The client socket wrapper
|
|
:param retry_on_empty: Should the client retry on empty
|
|
:param retries: The number of retries to allow
|
|
"""
|
|
self.tid = Defaults.TransactionId
|
|
self.client = client
|
|
self.backoff = kwargs.get('backoff', Defaults.Backoff) or 0.3
|
|
self.retry_on_empty = kwargs.get('retry_on_empty',
|
|
Defaults.RetryOnEmpty)
|
|
self.retry_on_invalid = kwargs.get('retry_on_invalid',
|
|
Defaults.RetryOnInvalid)
|
|
self.retries = kwargs.get('retries', Defaults.Retries) or 1
|
|
self.reset_socket = kwargs.get('reset_socket', True)
|
|
self._transaction_lock = RLock()
|
|
self._no_response_devices = []
|
|
if client:
|
|
self._set_adu_size()
|
|
|
|
def _set_adu_size(self):
|
|
# base ADU size of modbus frame in bytes
|
|
if isinstance(self.client.framer, ModbusSocketFramer):
|
|
self.base_adu_size = 7 # tid(2), pid(2), length(2), uid(1)
|
|
elif isinstance(self.client.framer, ModbusRtuFramer):
|
|
self.base_adu_size = 3 # address(1), CRC(2)
|
|
elif isinstance(self.client.framer, ModbusAsciiFramer):
|
|
self.base_adu_size = 7 # start(1)+ Address(2), LRC(2) + end(2)
|
|
elif isinstance(self.client.framer, ModbusBinaryFramer):
|
|
self.base_adu_size = 5 # start(1) + Address(1), CRC(2) + end(1)
|
|
elif isinstance(self.client.framer, ModbusTlsFramer):
|
|
self.base_adu_size = 0 # no header and footer
|
|
else:
|
|
self.base_adu_size = -1
|
|
|
|
def _calculate_response_length(self, expected_pdu_size):
|
|
if self.base_adu_size == -1:
|
|
return None
|
|
else:
|
|
return self.base_adu_size + expected_pdu_size
|
|
|
|
def _calculate_exception_length(self):
|
|
""" Returns the length of the Modbus Exception Response according to
|
|
the type of Framer.
|
|
"""
|
|
if isinstance(self.client.framer, (ModbusSocketFramer,
|
|
ModbusTlsFramer)):
|
|
return self.base_adu_size + 2 # Fcode(1), ExcecptionCode(1)
|
|
elif isinstance(self.client.framer, ModbusAsciiFramer):
|
|
return self.base_adu_size + 4 # Fcode(2), ExcecptionCode(2)
|
|
elif isinstance(self.client.framer, (ModbusRtuFramer,
|
|
ModbusBinaryFramer)):
|
|
return self.base_adu_size + 2 # Fcode(1), ExcecptionCode(1)
|
|
|
|
return None
|
|
|
|
def _validate_response(self, request, response, exp_resp_len):
|
|
"""
|
|
Validate Incoming response against request
|
|
:param request: Request sent
|
|
:param response: Response received
|
|
:param exp_resp_len: Expected response length
|
|
:return: New transactions state
|
|
"""
|
|
if not response:
|
|
return False
|
|
|
|
mbap = self.client.framer.decode_data(response)
|
|
if mbap.get('unit') != request.unit_id or mbap.get('fcode') & 0x7F != request.function_code:
|
|
return False
|
|
|
|
if 'length' in mbap and exp_resp_len:
|
|
return mbap.get('length') == exp_resp_len
|
|
return True
|
|
|
|
def execute(self, request):
|
|
""" Starts the producer to send the next request to
|
|
consumer.write(Frame(request))
|
|
"""
|
|
with self._transaction_lock:
|
|
try:
|
|
_logger.debug("Current transaction state - {}".format(
|
|
ModbusTransactionState.to_string(self.client.state))
|
|
)
|
|
retries = self.retries
|
|
request.transaction_id = self.getNextTID()
|
|
_logger.debug("Running transaction "
|
|
"{}".format(request.transaction_id))
|
|
_buffer = hexlify_packets(self.client.framer._buffer)
|
|
if _buffer:
|
|
_logger.debug("Clearing current Frame "
|
|
": - {}".format(_buffer))
|
|
self.client.framer.resetFrame()
|
|
broadcast = (self.client.broadcast_enable
|
|
and request.unit_id == 0)
|
|
if broadcast:
|
|
self._transact(request, None, broadcast=True)
|
|
response = b'Broadcast write sent - no response expected'
|
|
else:
|
|
expected_response_length = None
|
|
if not isinstance(self.client.framer, ModbusSocketFramer):
|
|
if hasattr(request, "get_response_pdu_size"):
|
|
response_pdu_size = request.get_response_pdu_size()
|
|
if isinstance(self.client.framer, ModbusAsciiFramer):
|
|
response_pdu_size = response_pdu_size * 2
|
|
if response_pdu_size:
|
|
expected_response_length = self._calculate_response_length(response_pdu_size)
|
|
if request.unit_id in self._no_response_devices:
|
|
full = True
|
|
else:
|
|
full = False
|
|
c_str = str(self.client)
|
|
if "modbusudpclient" in c_str.lower().strip():
|
|
full = True
|
|
if not expected_response_length:
|
|
expected_response_length = Defaults.ReadSize
|
|
response, last_exception = self._transact(
|
|
request,
|
|
expected_response_length,
|
|
full=full,
|
|
broadcast=broadcast
|
|
)
|
|
while retries > 0:
|
|
valid_response = self._validate_response(
|
|
request, response, expected_response_length
|
|
)
|
|
if valid_response:
|
|
if request.unit_id in self._no_response_devices and response:
|
|
self._no_response_devices.remove(request.unit_id)
|
|
_logger.debug("Got response!!!")
|
|
break
|
|
else:
|
|
if not response:
|
|
if request.unit_id not in self._no_response_devices:
|
|
self._no_response_devices.append(request.unit_id)
|
|
if self.retry_on_empty:
|
|
response, last_exception = self._retry_transaction(retries, "empty", request, expected_response_length, full=full)
|
|
retries -= 1
|
|
else:
|
|
# No response received and retries not enabled
|
|
break
|
|
else:
|
|
if self.retry_on_invalid:
|
|
response, last_exception = self._retry_transaction(retries, "invalid", request, expected_response_length, full=full)
|
|
retries -= 1
|
|
else:
|
|
break
|
|
# full = False
|
|
addTransaction = partial(self.addTransaction,
|
|
tid=request.transaction_id)
|
|
self.client.framer.processIncomingPacket(response,
|
|
addTransaction,
|
|
request.unit_id)
|
|
response = self.getTransaction(request.transaction_id)
|
|
if not response:
|
|
if len(self.transactions):
|
|
response = self.getTransaction(tid=0)
|
|
else:
|
|
last_exception = last_exception or (
|
|
"No Response received from the remote unit"
|
|
"/Unable to decode response")
|
|
response = ModbusIOException(last_exception,
|
|
request.function_code)
|
|
if self.reset_socket:
|
|
self.client.close()
|
|
if hasattr(self.client, "state"):
|
|
_logger.debug("Changing transaction state from "
|
|
"'PROCESSING REPLY' to "
|
|
"'TRANSACTION_COMPLETE'")
|
|
self.client.state = (
|
|
ModbusTransactionState.TRANSACTION_COMPLETE)
|
|
|
|
return response
|
|
except ModbusIOException as ex:
|
|
# Handle decode errors in processIncomingPacket method
|
|
_logger.exception(ex)
|
|
self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE
|
|
if self.reset_socket:
|
|
self.client.close()
|
|
return ex
|
|
|
|
def _retry_transaction(self, retries, reason,
|
|
packet, response_length, full=False):
|
|
_logger.debug("Retry on {} response - {}".format(reason, retries))
|
|
_logger.debug("Changing transaction state from "
|
|
"'WAITING_FOR_REPLY' to 'RETRYING'")
|
|
self.client.state = ModbusTransactionState.RETRYING
|
|
if self.backoff:
|
|
delay = 2 ** (self.retries - retries) * self.backoff
|
|
time.sleep(delay)
|
|
_logger.debug("Sleeping {}".format(delay))
|
|
self.client.connect()
|
|
if hasattr(self.client, "_in_waiting"):
|
|
in_waiting = self.client._in_waiting()
|
|
if in_waiting:
|
|
if response_length == in_waiting:
|
|
result = self._recv(response_length, full)
|
|
return result, None
|
|
return self._transact(packet, response_length, full=full)
|
|
|
|
def _transact(self, packet, response_length,
|
|
full=False, broadcast=False):
|
|
"""
|
|
Does a Write and Read transaction
|
|
:param packet: packet to be sent
|
|
:param response_length: Expected response length
|
|
:param full: the target device was notorious for its no response. Dont
|
|
waste time this time by partial querying
|
|
:return: response
|
|
"""
|
|
last_exception = None
|
|
try:
|
|
self.client.connect()
|
|
packet = self.client.framer.buildPacket(packet)
|
|
if _logger.isEnabledFor(logging.DEBUG):
|
|
_logger.debug("SEND: " + hexlify_packets(packet))
|
|
size = self._send(packet)
|
|
if isinstance(size, bytes) and self.client.state == ModbusTransactionState.RETRYING:
|
|
_logger.debug("Changing transaction state from "
|
|
"'RETRYING' to 'PROCESSING REPLY'")
|
|
self.client.state = ModbusTransactionState.PROCESSING_REPLY
|
|
return size, None
|
|
if broadcast:
|
|
if size:
|
|
_logger.debug("Changing transaction state from 'SENDING' "
|
|
"to 'TRANSACTION_COMPLETE'")
|
|
self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE
|
|
return b'', None
|
|
if size:
|
|
_logger.debug("Changing transaction state from 'SENDING' "
|
|
"to 'WAITING FOR REPLY'")
|
|
self.client.state = ModbusTransactionState.WAITING_FOR_REPLY
|
|
if hasattr(self.client, "handle_local_echo") and self.client.handle_local_echo is True:
|
|
local_echo_packet = self._recv(size, full)
|
|
if local_echo_packet != packet:
|
|
return b'', "Wrong local echo"
|
|
result = self._recv(response_length, full)
|
|
# result2 = self._recv(response_length, full)
|
|
if _logger.isEnabledFor(logging.DEBUG):
|
|
_logger.debug("RECV: " + hexlify_packets(result))
|
|
|
|
except (socket.error, ModbusIOException,
|
|
InvalidMessageReceivedException) as msg:
|
|
if self.reset_socket:
|
|
self.client.close()
|
|
_logger.debug("Transaction failed. (%s) " % msg)
|
|
last_exception = msg
|
|
result = b''
|
|
return result, last_exception
|
|
|
|
def _send(self, packet, retrying=False):
|
|
return self.client.framer.sendPacket(packet)
|
|
|
|
def _recv(self, expected_response_length, full):
|
|
total = None
|
|
if not full:
|
|
exception_length = self._calculate_exception_length()
|
|
if isinstance(self.client.framer, ModbusSocketFramer):
|
|
min_size = 8
|
|
elif isinstance(self.client.framer, ModbusRtuFramer):
|
|
min_size = 2
|
|
elif isinstance(self.client.framer, ModbusAsciiFramer):
|
|
min_size = 5
|
|
elif isinstance(self.client.framer, ModbusBinaryFramer):
|
|
min_size = 3
|
|
else:
|
|
min_size = expected_response_length
|
|
|
|
read_min = self.client.framer.recvPacket(min_size)
|
|
if len(read_min) != min_size:
|
|
msg_start = "Incomplete message" if read_min else "No response"
|
|
raise InvalidMessageReceivedException(
|
|
"%s received, expected at least %d bytes "
|
|
"(%d received)" % (msg_start, min_size, len(read_min))
|
|
)
|
|
if read_min:
|
|
if isinstance(self.client.framer, ModbusSocketFramer):
|
|
func_code = byte2int(read_min[-1])
|
|
elif isinstance(self.client.framer, ModbusRtuFramer):
|
|
func_code = byte2int(read_min[-1])
|
|
elif isinstance(self.client.framer, ModbusAsciiFramer):
|
|
func_code = int(read_min[3:5], 16)
|
|
elif isinstance(self.client.framer, ModbusBinaryFramer):
|
|
func_code = byte2int(read_min[-1])
|
|
else:
|
|
func_code = -1
|
|
|
|
if func_code < 0x80: # Not an error
|
|
if isinstance(self.client.framer, ModbusSocketFramer):
|
|
# Ommit UID, which is included in header size
|
|
h_size = self.client.framer._hsize
|
|
length = struct.unpack(">H", read_min[4:6])[0] - 1
|
|
expected_response_length = h_size + length
|
|
if expected_response_length is not None:
|
|
expected_response_length -= min_size
|
|
total = expected_response_length + min_size
|
|
else:
|
|
expected_response_length = exception_length - min_size
|
|
total = expected_response_length + min_size
|
|
else:
|
|
total = expected_response_length
|
|
else:
|
|
read_min = b''
|
|
total = expected_response_length
|
|
result = self.client.framer.recvPacket(expected_response_length)
|
|
result = read_min + result
|
|
actual = len(result)
|
|
if total is not None and actual != total:
|
|
msg_start = "Incomplete message" if actual else "No response"
|
|
_logger.debug("{} received, "
|
|
"Expected {} bytes Recieved "
|
|
"{} bytes !!!!".format(msg_start, total, actual))
|
|
elif actual == 0:
|
|
# If actual == 0 and total is not None then the above
|
|
# should be triggered, so total must be None here
|
|
_logger.debug("No response received to unbounded read !!!!")
|
|
if self.client.state != ModbusTransactionState.PROCESSING_REPLY:
|
|
_logger.debug("Changing transaction state from "
|
|
"'WAITING FOR REPLY' to 'PROCESSING REPLY'")
|
|
self.client.state = ModbusTransactionState.PROCESSING_REPLY
|
|
return result
|
|
|
|
def addTransaction(self, request, tid=None):
|
|
""" Adds a transaction to the handler
|
|
|
|
This holds the request in case it needs to be resent.
|
|
After being sent, the request is removed.
|
|
|
|
:param request: The request to hold on to
|
|
:param tid: The overloaded transaction id to use
|
|
"""
|
|
raise NotImplementedException("addTransaction")
|
|
|
|
def getTransaction(self, tid):
|
|
""" Returns a transaction matching the referenced tid
|
|
|
|
If the transaction does not exist, None is returned
|
|
|
|
:param tid: The transaction to retrieve
|
|
"""
|
|
raise NotImplementedException("getTransaction")
|
|
|
|
def delTransaction(self, tid):
|
|
""" Removes a transaction matching the referenced tid
|
|
|
|
:param tid: The transaction to remove
|
|
"""
|
|
raise NotImplementedException("delTransaction")
|
|
|
|
def getNextTID(self):
|
|
""" Retrieve the next unique transaction identifier
|
|
|
|
This handles incrementing the identifier after
|
|
retrieval
|
|
|
|
:returns: The next unique transaction identifier
|
|
"""
|
|
self.tid = (self.tid + 1) & 0xffff
|
|
return self.tid
|
|
|
|
def reset(self):
|
|
""" Resets the transaction identifier """
|
|
self.tid = Defaults.TransactionId
|
|
self.transactions = type(self.transactions)()
|
|
|
|
|
|
class DictTransactionManager(ModbusTransactionManager):
|
|
""" Impelements a transaction for a manager where the
|
|
results are keyed based on the supplied transaction id.
|
|
"""
|
|
|
|
def __init__(self, client, **kwargs):
|
|
""" Initializes an instance of the ModbusTransactionManager
|
|
|
|
:param client: The client socket wrapper
|
|
"""
|
|
self.transactions = {}
|
|
super(DictTransactionManager, self).__init__(client, **kwargs)
|
|
|
|
def __iter__(self):
|
|
""" Iterater over the current managed transactions
|
|
|
|
:returns: An iterator of the managed transactions
|
|
"""
|
|
return iterkeys(self.transactions)
|
|
|
|
def addTransaction(self, request, tid=None):
|
|
""" Adds a transaction to the handler
|
|
|
|
This holds the requets in case it needs to be resent.
|
|
After being sent, the request is removed.
|
|
|
|
:param request: The request to hold on to
|
|
:param tid: The overloaded transaction id to use
|
|
"""
|
|
tid = tid if tid != None else request.transaction_id
|
|
_logger.debug("Adding transaction %d" % tid)
|
|
self.transactions[tid] = request
|
|
|
|
def getTransaction(self, tid):
|
|
""" Returns a transaction matching the referenced tid
|
|
|
|
If the transaction does not exist, None is returned
|
|
|
|
:param tid: The transaction to retrieve
|
|
|
|
"""
|
|
_logger.debug("Getting transaction %d" % tid)
|
|
|
|
return self.transactions.pop(tid, None)
|
|
|
|
def delTransaction(self, tid):
|
|
""" Removes a transaction matching the referenced tid
|
|
|
|
:param tid: The transaction to remove
|
|
"""
|
|
_logger.debug("deleting transaction %d" % tid)
|
|
|
|
self.transactions.pop(tid, None)
|
|
|
|
|
|
class FifoTransactionManager(ModbusTransactionManager):
|
|
""" Impelements a transaction for a manager where the
|
|
results are returned in a FIFO manner.
|
|
"""
|
|
|
|
def __init__(self, client, **kwargs):
|
|
""" Initializes an instance of the ModbusTransactionManager
|
|
|
|
:param client: The client socket wrapper
|
|
"""
|
|
super(FifoTransactionManager, self).__init__(client, **kwargs)
|
|
self.transactions = []
|
|
|
|
def __iter__(self):
|
|
""" Iterater over the current managed transactions
|
|
|
|
:returns: An iterator of the managed transactions
|
|
"""
|
|
return iter(self.transactions)
|
|
|
|
def addTransaction(self, request, tid=None):
|
|
""" Adds a transaction to the handler
|
|
|
|
This holds the requets in case it needs to be resent.
|
|
After being sent, the request is removed.
|
|
|
|
:param request: The request to hold on to
|
|
:param tid: The overloaded transaction id to use
|
|
"""
|
|
tid = tid if tid is not None else request.transaction_id
|
|
_logger.debug("Adding transaction %d" % tid)
|
|
|
|
self.transactions.append(request)
|
|
|
|
def getTransaction(self, tid):
|
|
""" Returns a transaction matching the referenced tid
|
|
|
|
If the transaction does not exist, None is returned
|
|
|
|
:param tid: The transaction to retrieve
|
|
"""
|
|
return self.transactions.pop(0) if self.transactions else None
|
|
|
|
def delTransaction(self, tid):
|
|
""" Removes a transaction matching the referenced tid
|
|
|
|
:param tid: The transaction to remove
|
|
"""
|
|
_logger.debug("Deleting transaction %d" % tid)
|
|
if self.transactions: self.transactions.pop(0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Exported symbols
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
__all__ = [
|
|
"FifoTransactionManager",
|
|
"DictTransactionManager",
|
|
"ModbusSocketFramer", "ModbusTlsFramer", "ModbusRtuFramer",
|
|
"ModbusAsciiFramer", "ModbusBinaryFramer",
|
|
]
|