1025 lines
40 KiB
Python
1025 lines
40 KiB
Python
""" pyModbusTCP Server """
|
|
|
|
from .constants import READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS, \
|
|
WRITE_MULTIPLE_COILS, WRITE_MULTIPLE_REGISTERS, WRITE_SINGLE_COIL, WRITE_SINGLE_REGISTER, \
|
|
EXP_NONE, EXP_ILLEGAL_FUNCTION, EXP_DATA_ADDRESS, EXP_DATA_VALUE
|
|
from .utils import test_bit, set_bit
|
|
import logging
|
|
import socket
|
|
from socketserver import BaseRequestHandler, ThreadingTCPServer
|
|
import struct
|
|
from threading import Lock, Thread, Event
|
|
from warnings import warn
|
|
|
|
# add a logger for pyModbusTCP.server
|
|
logger = logging.getLogger('pyModbusTCP.server')
|
|
|
|
|
|
class DataBank:
|
|
""" Data space class with thread safe access functions """
|
|
|
|
_DEPR_MSG = 'This class method is deprecated. Use DataBank instance method instead: '
|
|
|
|
@classmethod
|
|
def get_bits(cls, *_args, **_kwargs):
|
|
msg = DataBank._DEPR_MSG + 'server.data_bank.get_coils() or get_discrete_inputs()'
|
|
warn(msg, DeprecationWarning, stacklevel=2)
|
|
|
|
@classmethod
|
|
def set_bits(cls, *_args, **_kwargs):
|
|
msg = DataBank._DEPR_MSG + 'server.data_bank.set_coils() or set_discrete_inputs()'
|
|
warn(msg, DeprecationWarning, stacklevel=2)
|
|
|
|
@classmethod
|
|
def get_words(cls, *_args, **_kwargs):
|
|
msg = DataBank._DEPR_MSG + 'server.data_bank.get_holding_registers() or get_input_registers()'
|
|
warn(msg, DeprecationWarning, stacklevel=2)
|
|
|
|
@classmethod
|
|
def set_words(cls, *_args, **_kwargs):
|
|
msg = DataBank._DEPR_MSG + 'server.data_bank.set_holding_registers() or set_input_registers()'
|
|
warn(msg, DeprecationWarning, stacklevel=2)
|
|
|
|
def __init__(self, coils_size=0x10000, coils_default_value=False,
|
|
d_inputs_size=0x10000, d_inputs_default_value=False,
|
|
h_regs_size=0x10000, h_regs_default_value=0,
|
|
i_regs_size=0x10000, i_regs_default_value=0,
|
|
virtual_mode=False):
|
|
"""Constructor
|
|
|
|
Modbus server data bank constructor.
|
|
|
|
:param coils_size: Number of coils to allocate (default is 65536)
|
|
:type coils_size: int
|
|
:param coils_default_value: Coils default value at startup (default is False)
|
|
:type coils_default_value: bool
|
|
:param d_inputs_size: Number of discrete inputs to allocate (default is 65536)
|
|
:type d_inputs_size: int
|
|
:param d_inputs_default_value: Discrete inputs default value at startup (default is False)
|
|
:type d_inputs_default_value: bool
|
|
:param h_regs_size: Number of holding registers to allocate (default is 65536)
|
|
:type h_regs_size: int
|
|
:param h_regs_default_value: Holding registers default value at startup (default is 0)
|
|
:type h_regs_default_value: int
|
|
:param i_regs_size: Number of input registers to allocate (default is 65536)
|
|
:type i_regs_size: int
|
|
:param i_regs_default_value: Input registers default value at startup (default is 0)
|
|
:type i_regs_default_value: int
|
|
:param virtual_mode: Disallow all modbus data space to work with virtual values (default is False)
|
|
:type virtual_mode: bool
|
|
"""
|
|
# public
|
|
self.coils_size = int(coils_size)
|
|
self.coils_default_value = bool(coils_default_value)
|
|
self.d_inputs_size = int(d_inputs_size)
|
|
self.d_inputs_default_value = bool(d_inputs_default_value)
|
|
self.h_regs_size = int(h_regs_size)
|
|
self.h_regs_default_value = int(h_regs_default_value)
|
|
self.i_regs_size = int(i_regs_size)
|
|
self.i_regs_default_value = int(i_regs_default_value)
|
|
self.virtual_mode = virtual_mode
|
|
# specific modes (override some values)
|
|
if self.virtual_mode:
|
|
self.coils_size = 0
|
|
self.d_inputs_size = 0
|
|
self.h_regs_size = 0
|
|
self.i_regs_size = 0
|
|
# private
|
|
self._coils_lock = Lock()
|
|
self._coils = [self.coils_default_value] * self.coils_size
|
|
self._d_inputs_lock = Lock()
|
|
self._d_inputs = [self.d_inputs_default_value] * self.d_inputs_size
|
|
self._h_regs_lock = Lock()
|
|
self._h_regs = [self.h_regs_default_value] * self.h_regs_size
|
|
self._i_regs_lock = Lock()
|
|
self._i_regs = [self.i_regs_default_value] * self.i_regs_size
|
|
|
|
def __repr__(self):
|
|
attrs_str = ''
|
|
for attr_name in self.__dict__:
|
|
if isinstance(attr_name, str) and not attr_name.startswith('_'):
|
|
if attrs_str:
|
|
attrs_str += ', '
|
|
attrs_str += '%s=%r' % (attr_name, self.__dict__[attr_name])
|
|
return 'DataBank(%s)' % attrs_str
|
|
|
|
def get_coils(self, address, number=1, srv_info=None):
|
|
"""Read data on server coils space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param number: number of bits (optional)
|
|
:type number: int
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:returns: list of bool or None if error
|
|
:rtype: list or None
|
|
"""
|
|
# secure extract of data from list used by server thread
|
|
with self._coils_lock:
|
|
if (address >= 0) and (address + number <= len(self._coils)):
|
|
return self._coils[address: number + address]
|
|
else:
|
|
return None
|
|
|
|
def set_coils(self, address, bit_list, srv_info=None):
|
|
"""Write data to server coils space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param bit_list: a list of bool to write
|
|
:type bit_list: list
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServerInfo
|
|
:returns: True if success or None if error
|
|
:rtype: bool or None
|
|
:raises ValueError: if bit_list members cannot be converted to bool
|
|
"""
|
|
# ensure bit_list values are bool
|
|
bit_list = [bool(b) for b in bit_list]
|
|
# keep trace of any changes
|
|
changes_list = []
|
|
# ensure atomic update of internal data
|
|
with self._coils_lock:
|
|
if (address >= 0) and (address + len(bit_list) <= len(self._coils)):
|
|
for offset, c_value in enumerate(bit_list):
|
|
c_address = address + offset
|
|
if self._coils[c_address] != c_value:
|
|
changes_list.append((c_address, self._coils[c_address], c_value))
|
|
self._coils[c_address] = c_value
|
|
else:
|
|
return None
|
|
# on server update
|
|
if srv_info:
|
|
# notify changes with on change method (after atomic update)
|
|
for address, from_value, to_value in changes_list:
|
|
self.on_coils_change(address, from_value, to_value, srv_info)
|
|
return True
|
|
|
|
def get_discrete_inputs(self, address, number=1, srv_info=None):
|
|
"""Read data on server discrete inputs space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param number: number of bits (optional)
|
|
:type number: int
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServerInfo
|
|
:returns: list of bool or None if error
|
|
:rtype: list or None
|
|
"""
|
|
# secure extract of data from list used by server thread
|
|
with self._d_inputs_lock:
|
|
if (address >= 0) and (address + number <= len(self._coils)):
|
|
return self._d_inputs[address: number + address]
|
|
else:
|
|
return None
|
|
|
|
def set_discrete_inputs(self, address, bit_list):
|
|
"""Write data to server discrete inputs space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param bit_list: a list of bool to write
|
|
:type bit_list: list
|
|
:returns: True if success or None if error
|
|
:rtype: bool or None
|
|
:raises ValueError: if bit_list members cannot be converted to bool
|
|
"""
|
|
# ensure bit_list values are bool
|
|
bit_list = [bool(b) for b in bit_list]
|
|
# ensure atomic update of internal data
|
|
with self._d_inputs_lock:
|
|
if (address >= 0) and (address + len(bit_list) <= len(self._coils)):
|
|
for offset, b_value in enumerate(bit_list):
|
|
self._d_inputs[address + offset] = b_value
|
|
else:
|
|
return None
|
|
return True
|
|
|
|
def get_holding_registers(self, address, number=1, srv_info=None):
|
|
"""Read data on server holding registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param number: number of words (optional)
|
|
:type number: int
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServerInfo
|
|
:returns: list of int or None if error
|
|
:rtype: list or None
|
|
"""
|
|
# secure extract of data from list used by server thread
|
|
with self._h_regs_lock:
|
|
if (address >= 0) and (address + number <= len(self._h_regs)):
|
|
return self._h_regs[address: number + address]
|
|
else:
|
|
return None
|
|
|
|
def set_holding_registers(self, address, word_list, srv_info=None):
|
|
"""Write data to server holding registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param word_list: a list of word to write
|
|
:type word_list: list
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServerInfo
|
|
:returns: True if success or None if error
|
|
:rtype: bool or None
|
|
:raises ValueError: if word_list members cannot be converted to int
|
|
"""
|
|
# ensure word_list values are int with a max bit length of 16
|
|
word_list = [int(w) & 0xffff for w in word_list]
|
|
# keep trace of any changes
|
|
changes_list = []
|
|
# ensure atomic update of internal data
|
|
with self._h_regs_lock:
|
|
if (address >= 0) and (address + len(word_list) <= len(self._h_regs)):
|
|
for offset, c_value in enumerate(word_list):
|
|
c_address = address + offset
|
|
if self._h_regs[c_address] != c_value:
|
|
changes_list.append((c_address, self._h_regs[c_address], c_value))
|
|
self._h_regs[c_address] = c_value
|
|
else:
|
|
return None
|
|
# on server update
|
|
if srv_info:
|
|
# notify changes with on change method (after atomic update)
|
|
for address, from_value, to_value in changes_list:
|
|
self.on_holding_registers_change(address, from_value, to_value, srv_info=srv_info)
|
|
return True
|
|
|
|
def get_input_registers(self, address, number=1, srv_info=None):
|
|
"""Read data on server input registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param number: number of words (optional)
|
|
:type number: int
|
|
:param srv_info: some server info (must be set by server only)
|
|
:type srv_info: ModbusServerInfo
|
|
:returns: list of int or None if error
|
|
:rtype: list or None
|
|
"""
|
|
# secure extract of data from list used by server thread
|
|
with self._i_regs_lock:
|
|
if (address >= 0) and (address + number <= len(self._h_regs)):
|
|
return self._i_regs[address: number + address]
|
|
else:
|
|
return None
|
|
|
|
def set_input_registers(self, address, word_list):
|
|
"""Write data to server input registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param word_list: a list of word to write
|
|
:type word_list: list
|
|
:returns: True if success or None if error
|
|
:rtype: bool or None
|
|
:raises ValueError: if word_list members cannot be converted to int
|
|
"""
|
|
# ensure word_list values are int with a max bit length of 16
|
|
word_list = [int(w) & 0xffff for w in word_list]
|
|
# ensure atomic update of internal data
|
|
with self._i_regs_lock:
|
|
if (address >= 0) and (address + len(word_list) <= len(self._h_regs)):
|
|
for offset, c_value in enumerate(word_list):
|
|
c_address = address + offset
|
|
if self._i_regs[c_address] != c_value:
|
|
self._i_regs[c_address] = c_value
|
|
else:
|
|
return None
|
|
return True
|
|
|
|
def on_coils_change(self, address, from_value, to_value, srv_info):
|
|
"""Call by server when a value change occur in coils space
|
|
|
|
This method is provided to be overridden with user code to catch changes
|
|
|
|
:param address: address of coil
|
|
:type address: int
|
|
:param from_value: coil original value
|
|
:type from_value: bool
|
|
:param to_value: coil next value
|
|
:type to_value: bool
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServerInfo
|
|
"""
|
|
pass
|
|
|
|
def on_holding_registers_change(self, address, from_value, to_value, srv_info):
|
|
"""Call by server when a value change occur in holding registers space
|
|
|
|
This method is provided to be overridden with user code to catch changes
|
|
|
|
:param address: address of register
|
|
:type address: int
|
|
:param from_value: register original value
|
|
:type from_value: int
|
|
:param to_value: register next value
|
|
:type to_value: int
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServerInfo
|
|
"""
|
|
pass
|
|
|
|
|
|
class DataHandler:
|
|
"""Default data handler for ModbusServer, map server threads calls to DataBank.
|
|
|
|
Custom handler must derive from this class.
|
|
"""
|
|
|
|
class Return:
|
|
def __init__(self, exp_code, data=None):
|
|
self.exp_code = exp_code
|
|
self.data = data
|
|
|
|
@property
|
|
def ok(self):
|
|
return self.exp_code == EXP_NONE
|
|
|
|
def __init__(self, data_bank=None):
|
|
"""Constructor
|
|
|
|
Modbus server data handler constructor.
|
|
|
|
:param data_bank: a reference to custom DefaultDataBank
|
|
:type data_bank: DataBank
|
|
"""
|
|
# check data_bank type
|
|
if data_bank and not isinstance(data_bank, DataBank):
|
|
raise ValueError('data_bank arg is invalid')
|
|
# public
|
|
self.data_bank = data_bank or DataBank()
|
|
|
|
def __repr__(self):
|
|
return 'ModbusServerDataHandler(data_bank=%s)' % self.data_bank
|
|
|
|
def read_coils(self, address, count, srv_info):
|
|
"""Call by server for reading in coils space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param count: number of coils
|
|
:type count: int
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# read bits from DataBank
|
|
bits_l = self.data_bank.get_coils(address, count, srv_info)
|
|
# return DataStatus to server
|
|
if bits_l is not None:
|
|
return DataHandler.Return(exp_code=EXP_NONE, data=bits_l)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
def write_coils(self, address, bits_l, srv_info):
|
|
"""Call by server for writing in the coils space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param bits_l: list of boolean to write
|
|
:type bits_l: list
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# write bits to DataBank
|
|
update_ok = self.data_bank.set_coils(address, bits_l, srv_info)
|
|
# return DataStatus to server
|
|
if update_ok:
|
|
return DataHandler.Return(exp_code=EXP_NONE)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
def read_d_inputs(self, address, count, srv_info):
|
|
"""Call by server for reading in the discrete inputs space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param count: number of discrete inputs
|
|
:type count: int
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# read bits from DataBank
|
|
bits_l = self.data_bank.get_discrete_inputs(address, count, srv_info)
|
|
# return DataStatus to server
|
|
if bits_l is not None:
|
|
return DataHandler.Return(exp_code=EXP_NONE, data=bits_l)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
def read_h_regs(self, address, count, srv_info):
|
|
"""Call by server for reading in the holding registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param count: number of holding registers
|
|
:type count: int
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# read words from DataBank
|
|
words_l = self.data_bank.get_holding_registers(address, count, srv_info)
|
|
# return DataStatus to server
|
|
if words_l is not None:
|
|
return DataHandler.Return(exp_code=EXP_NONE, data=words_l)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
def write_h_regs(self, address, words_l, srv_info):
|
|
"""Call by server for writing in the holding registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param words_l: list of word value to write
|
|
:type words_l: list
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# write words to DataBank
|
|
update_ok = self.data_bank.set_holding_registers(address, words_l, srv_info)
|
|
# return DataStatus to server
|
|
if update_ok:
|
|
return DataHandler.Return(exp_code=EXP_NONE)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
def read_i_regs(self, address, count, srv_info):
|
|
"""Call by server for reading in the input registers space
|
|
|
|
:param address: start address
|
|
:type address: int
|
|
:param count: number of input registers
|
|
:type count: int
|
|
:param srv_info: some server info
|
|
:type srv_info: ModbusServer.ServerInfo
|
|
:rtype: Return
|
|
"""
|
|
# read words from DataBank
|
|
words_l = self.data_bank.get_input_registers(address, count, srv_info)
|
|
# return DataStatus to server
|
|
if words_l is not None:
|
|
return DataHandler.Return(exp_code=EXP_NONE, data=words_l)
|
|
else:
|
|
return DataHandler.Return(exp_code=EXP_DATA_ADDRESS)
|
|
|
|
|
|
class ModbusServer:
|
|
""" Modbus TCP server """
|
|
|
|
class Error(Exception):
|
|
""" Base exception for ModbusServer related errors. """
|
|
pass
|
|
|
|
class NetworkError(Error):
|
|
""" Exception raise by ModbusServer on I/O errors. """
|
|
pass
|
|
|
|
class DataFormatError(Error):
|
|
""" Exception raise by ModbusServer for data format errors. """
|
|
pass
|
|
|
|
class ClientInfo:
|
|
""" Container class for client information """
|
|
|
|
def __init__(self, address='', port=0):
|
|
self.address = address
|
|
self.port = port
|
|
|
|
def __repr__(self):
|
|
return 'ClientInfo(address=%r, port=%r)' % (self.address, self.port)
|
|
|
|
class ServerInfo:
|
|
""" Container class for server information """
|
|
|
|
def __init__(self):
|
|
self.client = ModbusServer.ClientInfo()
|
|
self.recv_frame = ModbusServer.Frame()
|
|
|
|
class SessionData:
|
|
""" Container class for server session data. """
|
|
|
|
def __init__(self):
|
|
self.client = ModbusServer.ClientInfo()
|
|
self.request = ModbusServer.Frame()
|
|
self.response = ModbusServer.Frame()
|
|
|
|
@property
|
|
def srv_info(self):
|
|
info = ModbusServer.ServerInfo()
|
|
info.client = self.client
|
|
info.recv_frame = self.request
|
|
return info
|
|
|
|
def new_request(self):
|
|
self.request = ModbusServer.Frame()
|
|
self.response = ModbusServer.Frame()
|
|
|
|
def set_response_mbap(self):
|
|
self.response.mbap.transaction_id = self.request.mbap.transaction_id
|
|
self.response.mbap.protocol_id = self.request.mbap.protocol_id
|
|
self.response.mbap.unit_id = self.request.mbap.unit_id
|
|
|
|
class Frame:
|
|
def __init__(self):
|
|
""" Modbus Frame container. """
|
|
self.mbap = ModbusServer.MBAP()
|
|
self.pdu = ModbusServer.PDU()
|
|
|
|
@property
|
|
def raw(self):
|
|
self.mbap.length = len(self.pdu) + 1
|
|
return self.mbap.raw + self.pdu.raw
|
|
|
|
class MBAP:
|
|
""" MBAP (Modbus Application Protocol) container class. """
|
|
|
|
def __init__(self, transaction_id=0, protocol_id=0, length=0, unit_id=0):
|
|
# public
|
|
self.transaction_id = transaction_id
|
|
self.protocol_id = protocol_id
|
|
self.length = length
|
|
self.unit_id = unit_id
|
|
|
|
@property
|
|
def raw(self):
|
|
try:
|
|
return struct.pack('>HHHB', self.transaction_id,
|
|
self.protocol_id, self.length,
|
|
self.unit_id)
|
|
except struct.error as e:
|
|
raise ModbusServer.DataFormatError('MBAP raw encode pack error: %s' % e)
|
|
|
|
@raw.setter
|
|
def raw(self, value):
|
|
# close connection if no standard 7 bytes mbap header
|
|
if not (value and len(value) == 7):
|
|
raise ModbusServer.DataFormatError('MBAP must have a length of 7 bytes')
|
|
# decode header
|
|
(self.transaction_id, self.protocol_id,
|
|
self.length, self.unit_id) = struct.unpack('>HHHB', value)
|
|
# check frame header content inconsistency
|
|
if self.protocol_id != 0:
|
|
raise ModbusServer.DataFormatError('MBAP protocol ID must be 0')
|
|
if not 2 < self.length < 256:
|
|
raise ModbusServer.DataFormatError('MBAP length must be between 2 and 256')
|
|
|
|
class PDU:
|
|
""" PDU (Protocol Data Unit) container class. """
|
|
|
|
def __init__(self, raw=b''):
|
|
"""
|
|
Constructor
|
|
|
|
:param raw: raw PDU
|
|
:type raw: bytes
|
|
"""
|
|
self.raw = raw
|
|
|
|
def __len__(self):
|
|
return len(self.raw)
|
|
|
|
@property
|
|
def func_code(self):
|
|
return self.raw[0]
|
|
|
|
@property
|
|
def except_code(self):
|
|
return self.raw[1]
|
|
|
|
@property
|
|
def is_except(self):
|
|
return self.func_code > 0x7F
|
|
|
|
@property
|
|
def is_valid(self):
|
|
# PDU min length is 2 bytes
|
|
return self.__len__() < 2
|
|
|
|
def clear(self):
|
|
self.raw = b''
|
|
|
|
def build_except(self, func_code, exp_status):
|
|
self.clear()
|
|
self.add_pack('BB', func_code + 0x80, exp_status)
|
|
return self
|
|
|
|
def add_pack(self, fmt, *args):
|
|
try:
|
|
self.raw += struct.pack(fmt, *args)
|
|
except struct.error:
|
|
err_msg = 'unable to format PDU message (fmt: %s, values: %s)' % (fmt, args)
|
|
raise ModbusServer.DataFormatError(err_msg)
|
|
|
|
def unpack(self, fmt, from_byte=None, to_byte=None):
|
|
raw_section = self.raw[from_byte:to_byte]
|
|
try:
|
|
return struct.unpack(fmt, raw_section)
|
|
except struct.error:
|
|
err_msg = 'unable to decode PDU message (fmt: %s, values: %s)' % (fmt, raw_section)
|
|
raise ModbusServer.DataFormatError(err_msg)
|
|
|
|
class ModbusService(BaseRequestHandler):
|
|
|
|
@property
|
|
def server_running(self):
|
|
return self.server.evt_running.is_set()
|
|
|
|
def _send_all(self, data):
|
|
try:
|
|
self.request.sendall(data)
|
|
return True
|
|
except socket.timeout:
|
|
return False
|
|
|
|
def _recv_all(self, size):
|
|
data = b''
|
|
while len(data) < size:
|
|
try:
|
|
# avoid keeping this TCP thread run after server.stop() on main server
|
|
if not self.server_running:
|
|
raise ModbusServer.NetworkError('main server is not running')
|
|
# recv all data or a chunk of it
|
|
data_chunk = self.request.recv(size - len(data))
|
|
# check data chunk
|
|
if data_chunk:
|
|
data += data_chunk
|
|
else:
|
|
raise ModbusServer.NetworkError('recv return null')
|
|
except socket.timeout:
|
|
# just redo main server run test and recv operations on timeout
|
|
pass
|
|
return data
|
|
|
|
def setup(self):
|
|
# set a socket timeout of 1s on blocking operations (like send/recv)
|
|
# this avoids hang thread deletion when main server exit (see _recv_all method)
|
|
self.request.settimeout(1.0)
|
|
|
|
def handle(self):
|
|
# try/except end current thread on ModbusServer._InternalError or socket.error
|
|
# this also close the current TCP session associated with it
|
|
# init and update server info structure
|
|
session_data = ModbusServer.SessionData()
|
|
(session_data.client.address, session_data.client.port) = self.request.getpeername()
|
|
# debug message
|
|
logger.debug('Accept new connection from %r', session_data.client)
|
|
try:
|
|
# main processing loop
|
|
while True:
|
|
# init session data for new request
|
|
session_data.new_request()
|
|
# receive mbap from client
|
|
session_data.request.mbap.raw = self._recv_all(7)
|
|
# receive pdu from client
|
|
session_data.request.pdu.raw = self._recv_all(session_data.request.mbap.length - 1)
|
|
# update response MBAP fields with request data
|
|
session_data.set_response_mbap()
|
|
# pass the current session data to request engine
|
|
self.server.engine(session_data)
|
|
# send the tx pdu with the last rx mbap (only length field change)
|
|
self._send_all(session_data.response.raw)
|
|
except (ModbusServer.Error, socket.error) as e:
|
|
# debug message
|
|
logger.debug('Exception during request handling: %r', e)
|
|
# on main loop except: exit from it and cleanly close the current socket
|
|
self.request.close()
|
|
|
|
def __init__(self, host='localhost', port=502, no_block=False, ipv6=False,
|
|
data_bank=None, data_hdl=None, ext_engine=None):
|
|
"""Constructor
|
|
|
|
Modbus server constructor.
|
|
|
|
:param host: hostname or IPv4/IPv6 address server address (default is 'localhost')
|
|
:type host: str
|
|
:param port: TCP port number (default is 502)
|
|
:type port: int
|
|
:param no_block: no block mode, i.e. start() will return (default is False)
|
|
:type no_block: bool
|
|
:param ipv6: use ipv6 stack (default is False)
|
|
:type ipv6: bool
|
|
:param data_bank: instance of custom data bank, if you don't want the default one (optional)
|
|
:type data_bank: DataBank
|
|
:param data_hdl: instance of custom data handler, if you don't want the default one (optional)
|
|
:type data_hdl: DataHandler
|
|
:param ext_engine: an external engine reference (ref to ext_engine(session_data)) (optional)
|
|
:type ext_engine: callable
|
|
"""
|
|
# public
|
|
self.host = host
|
|
self.port = port
|
|
self.no_block = no_block
|
|
self.ipv6 = ipv6
|
|
self.ext_engine = ext_engine
|
|
self.data_hdl = None
|
|
self.data_bank = None
|
|
# if external engine is defined, ignore data_hdl and data_bank
|
|
if ext_engine:
|
|
if not callable(self.ext_engine):
|
|
raise ValueError('ext_engine must be callable')
|
|
else:
|
|
# default data handler is ModbusServerDataHandler or a child of it
|
|
if data_hdl is None:
|
|
self.data_hdl = DataHandler(data_bank=data_bank)
|
|
elif isinstance(data_hdl, DataHandler):
|
|
self.data_hdl = data_hdl
|
|
if data_bank:
|
|
raise ValueError('when data_hdl is set, you must define data_bank in it')
|
|
else:
|
|
raise ValueError('data_hdl is not a ModbusServerDataHandler (or child of it) instance')
|
|
# data bank shortcut
|
|
self.data_bank = self.data_hdl.data_bank
|
|
# private
|
|
self._evt_running = Event()
|
|
self._service = None
|
|
self._serve_th = None
|
|
# modbus default functions map
|
|
self._func_map = {READ_COILS: self._read_bits,
|
|
READ_DISCRETE_INPUTS: self._read_bits,
|
|
READ_HOLDING_REGISTERS: self._read_words,
|
|
READ_INPUT_REGISTERS: self._read_words,
|
|
WRITE_SINGLE_COIL: self._write_single_coil,
|
|
WRITE_SINGLE_REGISTER: self._write_single_register,
|
|
WRITE_MULTIPLE_COILS: self._write_multiple_coils,
|
|
WRITE_MULTIPLE_REGISTERS: self._write_multiple_registers}
|
|
|
|
def __repr__(self):
|
|
r_str = 'ModbusServer(host=\'%s\', port=%d, no_block=%s, ipv6=%s, data_bank=%s, data_hdl=%s, ext_engine=%s)'
|
|
r_str %= (self.host, self.port, self.no_block, self.ipv6, self.data_bank, self.data_hdl, self.ext_engine)
|
|
return r_str
|
|
|
|
def _engine(self, session_data):
|
|
"""Main request processing engine.
|
|
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# call external engine or internal one (if ext_engine undefined)
|
|
if callable(self.ext_engine):
|
|
try:
|
|
self.ext_engine(session_data)
|
|
except Exception as e:
|
|
raise ModbusServer.Error('external engine raise an exception: %r' % e)
|
|
else:
|
|
self._internal_engine(session_data)
|
|
|
|
def _internal_engine(self, session_data):
|
|
"""Default internal processing engine: call default modbus func.
|
|
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
try:
|
|
# call the ad-hoc function, if none exists, send an "illegal function" exception
|
|
func = self._func_map[session_data.request.pdu.func_code]
|
|
# check function found is callable
|
|
if not callable(func):
|
|
raise ValueError
|
|
# call ad-hoc func
|
|
func(session_data)
|
|
except (ValueError, KeyError):
|
|
session_data.response.pdu.build_except(session_data.request.pdu.func_code, EXP_ILLEGAL_FUNCTION)
|
|
|
|
def _read_bits(self, session_data):
|
|
"""
|
|
Functions Read Coils (0x01) or Read Discrete Inputs (0x02).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(start_address, quantity_bits) = recv_pdu.unpack('>HH', from_byte=1, to_byte=5)
|
|
# check quantity of requested bits
|
|
if 0x0001 <= quantity_bits <= 0x07D0:
|
|
# data handler read request: for coils or discrete inputs space
|
|
if recv_pdu.func_code == READ_COILS:
|
|
ret_hdl = self.data_hdl.read_coils(start_address, quantity_bits, session_data.srv_info)
|
|
else:
|
|
ret_hdl = self.data_hdl.read_d_inputs(start_address, quantity_bits, session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
# allocate bytes list
|
|
b_size = (quantity_bits + 7) // 8
|
|
bytes_l = [0] * b_size
|
|
# populate bytes list with data bank bits
|
|
for i, item in enumerate(ret_hdl.data):
|
|
if item:
|
|
bytes_l[i // 8] = set_bit(bytes_l[i // 8], i % 8)
|
|
# build pdu
|
|
send_pdu.add_pack('BB', recv_pdu.func_code, len(bytes_l))
|
|
send_pdu.add_pack('%dB' % len(bytes_l), *bytes_l)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, EXP_DATA_VALUE)
|
|
|
|
def _read_words(self, session_data):
|
|
"""
|
|
Functions Read Holding Registers (0x03) or Read Input Registers (0x04).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(start_addr, quantity_regs) = recv_pdu.unpack('>HH', from_byte=1, to_byte=5)
|
|
# check quantity of requested words
|
|
if 0x0001 <= quantity_regs <= 0x007D:
|
|
# data handler read request: for holding or input registers space
|
|
if recv_pdu.func_code == READ_HOLDING_REGISTERS:
|
|
ret_hdl = self.data_hdl.read_h_regs(start_addr, quantity_regs, session_data.srv_info)
|
|
else:
|
|
ret_hdl = self.data_hdl.read_i_regs(start_addr, quantity_regs, session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
# build pdu
|
|
send_pdu.add_pack('BB', recv_pdu.func_code, quantity_regs * 2)
|
|
# add_pack requested words
|
|
send_pdu.add_pack('>%dH' % len(ret_hdl.data), *ret_hdl.data)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, EXP_DATA_VALUE)
|
|
|
|
def _write_single_coil(self, session_data):
|
|
"""
|
|
Function Write Single Coil (0x05).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(coil_addr, coil_value) = recv_pdu.unpack('>HH', from_byte=1, to_byte=5)
|
|
# format coil raw value to bool
|
|
coil_as_bool = bool(coil_value == 0xFF00)
|
|
# data handler update request
|
|
ret_hdl = self.data_hdl.write_coils(coil_addr, [coil_as_bool], session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
send_pdu.add_pack('>BHH', recv_pdu.func_code, coil_addr, coil_value)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
|
|
def _write_single_register(self, session_data):
|
|
"""
|
|
Functions Write Single Register (0x06).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(reg_addr, reg_value) = recv_pdu.unpack('>HH', from_byte=1, to_byte=5)
|
|
# data handler update request
|
|
ret_hdl = self.data_hdl.write_h_regs(reg_addr, [reg_value], session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
send_pdu.add_pack('>BHH', recv_pdu.func_code, reg_addr, reg_value)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
|
|
def _write_multiple_coils(self, session_data):
|
|
"""
|
|
Function Write Multiple Coils (0x0F).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(start_addr, quantity_bits, byte_count) = recv_pdu.unpack('>HHB', from_byte=1, to_byte=6)
|
|
# ok flags: some tests on pdu fields
|
|
qty_bits_ok = 0x0001 <= quantity_bits <= 0x07B0
|
|
b_count_ok = byte_count >= (quantity_bits + 7) // 8
|
|
pdu_len_ok = len(recv_pdu.raw[6:]) >= byte_count
|
|
# test ok flags
|
|
if qty_bits_ok and b_count_ok and pdu_len_ok:
|
|
# allocate bits list
|
|
bits_l = [False] * quantity_bits
|
|
# populate bits list with bits from rx frame
|
|
for i, _ in enumerate(bits_l):
|
|
bit_val = recv_pdu.raw[i // 8 + 6]
|
|
bits_l[i] = test_bit(bit_val, i % 8)
|
|
# data handler update request
|
|
ret_hdl = self.data_hdl.write_coils(start_addr, bits_l, session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
send_pdu.add_pack('>BHH', recv_pdu.func_code, start_addr, quantity_bits)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, EXP_DATA_VALUE)
|
|
|
|
def _write_multiple_registers(self, session_data):
|
|
"""
|
|
Function Write Multiple Registers (0x10).
|
|
|
|
:param session_data: server engine data
|
|
:type session_data: ModbusServer.SessionData
|
|
"""
|
|
# pdu alias
|
|
recv_pdu = session_data.request.pdu
|
|
send_pdu = session_data.response.pdu
|
|
# decode pdu
|
|
(start_addr, quantity_regs, byte_count) = recv_pdu.unpack('>HHB', from_byte=1, to_byte=6)
|
|
# ok flags: some tests on pdu fields
|
|
qty_regs_ok = 0x0001 <= quantity_regs <= 0x007B
|
|
b_count_ok = byte_count == quantity_regs * 2
|
|
pdu_len_ok = len(recv_pdu.raw[6:]) >= byte_count
|
|
# test ok flags
|
|
if qty_regs_ok and b_count_ok and pdu_len_ok:
|
|
# allocate words list
|
|
regs_l = [0] * quantity_regs
|
|
# populate words list with words from rx frame
|
|
for i, _ in enumerate(regs_l):
|
|
offset = i * 2 + 6
|
|
regs_l[i] = recv_pdu.unpack('>H', from_byte=offset, to_byte=offset + 2)[0]
|
|
# data handler update request
|
|
ret_hdl = self.data_hdl.write_h_regs(start_addr, regs_l, session_data.srv_info)
|
|
# format regular or except response
|
|
if ret_hdl.ok:
|
|
send_pdu.add_pack('>BHH', recv_pdu.func_code, start_addr, quantity_regs)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, ret_hdl.exp_code)
|
|
else:
|
|
send_pdu.build_except(recv_pdu.func_code, EXP_DATA_VALUE)
|
|
|
|
def start(self):
|
|
"""Start the server.
|
|
|
|
This function will block (or not if no_block flag is set).
|
|
"""
|
|
# do nothing if server is already running
|
|
if not self.is_run:
|
|
# set class attribute
|
|
ThreadingTCPServer.address_family = socket.AF_INET6 if self.ipv6 else socket.AF_INET
|
|
ThreadingTCPServer.daemon_threads = True
|
|
# init server
|
|
self._service = ThreadingTCPServer((self.host, self.port), self.ModbusService, bind_and_activate=False)
|
|
# pass some things shared with server threads (access via self.server in ModbusService.handle())
|
|
self._service.evt_running = self._evt_running
|
|
self._service.engine = self._engine
|
|
# set socket options
|
|
self._service.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._service.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
# TODO test no_delay with bench
|
|
self._service.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
# bind and activate
|
|
try:
|
|
self._service.server_bind()
|
|
self._service.server_activate()
|
|
except OSError as e:
|
|
raise ModbusServer.NetworkError(e)
|
|
# serve request
|
|
if self.no_block:
|
|
self._serve_th = Thread(target=self._serve)
|
|
self._serve_th.daemon = True
|
|
self._serve_th.start()
|
|
else:
|
|
self._serve()
|
|
|
|
def stop(self):
|
|
"""Stop the server."""
|
|
if self.is_run:
|
|
self._service.shutdown()
|
|
self._service.server_close()
|
|
|
|
@property
|
|
def is_run(self):
|
|
"""Return True if server running.
|
|
|
|
"""
|
|
return self._evt_running.is_set()
|
|
|
|
def _serve(self):
|
|
try:
|
|
self._evt_running.set()
|
|
self._service.serve_forever()
|
|
except Exception:
|
|
self._service.server_close()
|
|
raise
|
|
except KeyboardInterrupt:
|
|
self._service.server_close()
|
|
finally:
|
|
self._evt_running.clear()
|