Exemple #1
0
class ModbusReceiver:
    def __init__(self,
                 port,
                 localhost=True,
                 device_function_codes=None,
                 socket_type=socket.SOCK_STREAM,
                 failures={}):
        self.port = port
        self.localhost = localhost
        self.logger = Logger('ServerLogger-{}'.format(port),
                             '../logger/logs/server_log.txt',
                             prefix='Server {}'.format(port))
        self.stop = threading.Event()
        self.done = threading.Event()
        self.device_function_codes = device_function_codes
        self._current_connection = None
        self.socket_type = socket_type
        self.failures = failures
        self.lock = threading.RLock()

    '''
        Dispatches packet data for decoding based on it's function code.
        ModbusDecoder handles decoding of the packets and returns a Dict containing
        appropriate data. Invalid function codes lead to an invalid_function_code message which
        is also created by the modbus decoder.
    '''

    def _dissect_packet(self, packet_data) -> Dict:
        function_code = packet_data[0]
        # Check that the device supports this function code
        if self.device_function_codes:
            if function_code not in self.device_function_codes:
                return modbusdecoder.invalid_function_code(packet_data)
        switch = {
            1: modbusdecoder.read_coils,
            2: modbusdecoder.read_discrete_inputs,
            3: modbusdecoder.read_holding_registers,
            4: modbusdecoder.read_input_registers,
            5: modbusdecoder.write_single_coil,
            6: modbusdecoder.write_single_holding_register,
            15: modbusdecoder.write_multiple_coils,
            16: modbusdecoder.write_multiple_holding_registers
        }
        function = switch.get(function_code,
                              modbusdecoder.invalid_function_code)
        return function(packet_data)

    def _start_server_tcp(self, request_handler: Callable) -> None:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            if self.localhost:
                s.bind(('localhost', self.port))
            else:
                s.bind((socket.gethostname(), self.port))
            s.listen(5)
            self.logger.info('Server started {}:{}'.format(
                socket.gethostname(), self.port))
            while not self.stop.is_set():
                if self.failures.get('disconnected', False):
                    sleep(1)
                    continue
                self._current_connection, address = s.accept()
                self.logger.info('New connection accepted {}'.format(
                    self._current_connection.getpeername()))
                with self._current_connection:
                    while not self.stop.is_set():
                        try:
                            buffer = self._current_connection.recv(7)
                            if buffer == b'' or len(buffer) <= 0:
                                self.logger.debug(
                                    'Initial read was empty, peer connection was likely closed'
                                )
                                break
                            header = buffer
                            self.logger.debug(
                                'MB:{} Header DATA like: {}'.format(
                                    self.port, header))
                            # Modbus length is in bytes 4 & 5 of the header according to spec (pg 25)
                            # https://www.prosoft-technology.com/kb/assets/intro_modbustcp.pdf
                            header = modbusdecoder.dissect_header(header)
                            length = header['length']
                            if length == 0:
                                self.logger.debug(
                                    'A length 0 header was read, closing connection'
                                )
                                break
                            data = self._current_connection.recv(length - 1)
                            StatisticsCollector.increment_packets_received()
                            response_start = time.time()
                            is_error, dissection = self._dissect_packet(data)
                            if is_error:
                                self.logger.debug(
                                    'MB:{} Header appears like: {}'.format(
                                        self.port, header))
                                self.logger.debug('MB:{} Request: {}'.format(
                                    self.port, hexlify(buffer + data)))
                                self.logger.debug(
                                    'MB:{} An error was found in the modbus request {}'
                                    .format(self.port, hexlify(dissection)))
                                self._current_connection.sendall(dissection)
                                response_stop = time.time()
                                StatisticsCollector.increment_error_packets_sent(
                                )
                                StatisticsCollector.increment_responses_sent()
                                StatisticsCollector.increment_avg_response(
                                    response_stop - response_start)
                                continue
                            else:
                                dissection['type'] = 'request'
                                header['function_code'] = data[0]
                                response = request_handler({
                                    'header': header,
                                    'body': dissection
                                })
                                self.logger.debug(
                                    'MB:{} Header: {} Body:{}'.format(
                                        self.port, header, dissection))
                                self.logger.debug('MB:{} Request: {}'.format(
                                    self.port, hexlify(buffer + data)))
                                self.logger.debug(
                                    'MB:{} Responding: {}'.format(
                                        self.port, hexlify(response)))
                                # add failures to the receiver
                                if not self.simulate_failures():
                                    continue
                                self._current_connection.sendall(response)
                                response_stop = time.time()
                                StatisticsCollector.increment_responses_sent()
                                StatisticsCollector.increment_avg_response(
                                    response_stop - response_start)
                        except IOError as e:
                            self.logger.warning(
                                'An IO error occurred when reading the socket {}'
                                .format(e))
                            self.logger.debug('Closing connection')
                            self._current_connection.close()
                            StatisticsCollector.increment_socket_errors()
                            break
            self.done.set()

    def _start_server_udp(self, request_handler: Callable) -> None:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            if self.localhost:
                s.bind(('localhost', self.port))
                self.logger.info('Starting UDP server at localhost:{}'.format(
                    self.port))
            else:
                s.bind((socket.gethostname(), self.port))
                self.logger.debug('Starting UDP server at {}:{}'.format(
                    socket.gethostname(), self.port))
            while not self.stop.is_set():
                try:
                    if self.failures.get('disconnected', False):
                        sleep(1)
                        continue
                    buffer, address = s.recvfrom(256)
                    self.logger.debug(
                        'Message received from: {}'.format(address))
                    StatisticsCollector.increment_packets_received()
                    response_start = time.time()
                    if buffer == b'' or len(buffer) <= 0:
                        self.logger.debug(
                            'Initial read was empty, peer connection was likely closed'
                        )
                        continue
                    header = buffer[:7]
                    header = modbusdecoder.dissect_header(header)
                    length = header['length']
                    if length == 0:
                        self.logger.debug('Length 0 message received')
                        continue
                    data = buffer[7:7 + length - 1]
                    is_error, dissection = self._dissect_packet(data)
                    if is_error:
                        self.logger.debug(
                            'An error was found in the modbus request {}'.
                            format(dissection))
                        self.logger.debug(
                            'Header appears like: {}'.format(header))
                        self.logger.debug('Buffer: {}'.format(hexlify(buffer)))
                        s.sendto(dissection, address)
                        response_stop = time.time()
                        StatisticsCollector.increment_avg_response(
                            response_stop - response_start)
                        StatisticsCollector.increment_error_packets_sent()
                        StatisticsCollector.increment_responses_sent()
                        continue
                    else:
                        dissection['type'] = 'request'
                        header['function_code'] = data[0]
                        response = request_handler({
                            'header': header,
                            'body': dissection
                        })
                        # add failures to the receiver
                        if not self.simulate_failures():
                            continue
                        s.sendto(response, address)
                        response_stop = time.time()
                        StatisticsCollector.increment_avg_response(
                            response_stop - response_start)
                        StatisticsCollector.increment_responses_sent()
                        self.logger.debug('MB:{} Request: {}'.format(
                            self.port, hexlify(buffer[:7 + length])))
                        self.logger.debug('MB:{} Header: {} Body:{}'.format(
                            self.port, header, dissection))
                        self.logger.debug('MB:{} Responding: {}'.format(
                            self.port, hexlify(response)))
                except IOError as e:
                    self.logger.warning(
                        'An IO error occurred with the socket {}'.format(e))
                    StatisticsCollector.increment_socket_errors()
                    continue
        self.done.set()

    # Return False to not respond
    def simulate_failures(self):
        with self.lock:
            if self.failures.get('stop-responding', False):
                self.logger.info('MB:{} Simulating no-response'.format(
                    self.port))
                return False
            elif self.failures.get('flake-response'):
                val = random.choice([1, 2, 3])
                if val == 1:
                    upper_bound = self.failures['flake-response']
                    sleep_time = random.randint(0, upper_bound) * 0.01
                    self.logger.info(
                        'MB:{} Simulating flake-response "delayed" {}ms'.
                        format(self.port, sleep_time))
                    time.sleep(sleep_time)
                elif val == 2:
                    self.logger.info(
                        'MB:{} Simulating flake-response "no-response"'.format(
                            self.port))
                    return False
            elif self.failures.get('delay-response', False):
                upper_bound = self.failures['delay-response']
                sleep_time = random.randint(0, upper_bound) * 0.01
                self.logger.info('MB:{} Simulating delay-response {}ms'.format(
                    self.port, sleep_time))
                time.sleep(sleep_time)
            return True

    def set_failures(self, failures):
        with self.lock:
            self.failures = failures

    '''
        Starts the Modbus server and listens for packets over a TCP/IP connection. By default it will bind to
        localhost at a port specified in the constructor. Upon receiving a modbus message it will decode the header
        and send the function code and data to the _dissect_packet function for further processing. Error packets
        lead to an immediate response with an error code, while valid requests are sent back to the request handler.
    '''

    def start_server(self, request_handler: Callable) -> None:
        if self.socket_type == socket.SOCK_STREAM:
            self._start_server_tcp(request_handler)
        else:
            self._start_server_udp(request_handler)

    '''
        Breaks the server out of it's blocking accept or recv calls and sets the stop flag.
        In order to do this the method uses a 'dummy' connection to break the blocking call.
        It then sends a 'dummy' message that will lead to the method dropping the request and exiting.
        **NOTE**: This is especially useful when the server is being run on it's own thread.
    '''

    def stop_server(self) -> None:
        self.logger.info('Stopping server now')
        self.stop.set()
        if self._current_connection:
            self._current_connection.close()
        sleep(.5)
        # In order to stop the server we have to interrupt
        # The blocking socket.accept()
        # We create a connection that sends a header for a 0 length
        # Packet
        if not self.done.is_set() and self.socket_type == socket.SOCK_STREAM:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                if self.localhost:
                    s.connect(('localhost', self.port))
                else:
                    s.connect((socket.gethostname(), self.port))
                s.sendall(b'\x00\x01\x00\x00\x00\x00\x00')
                s.close()
class LogicController:
    def __init__(self, name: str, conf: Dict):
        self.plc_name = name
        self.conf = conf
        self.modbus_port = conf['modbus_port']
        self.worker_processes = {}
        self.setup_complete = False
        self.logger = Logger("PLCLogger",
                             "../logger/logs/plc_log.txt",
                             prefix="[{}]".format(self.plc_name))
        self.clock = PLCClock()
        self.register_map = {}

    def __str__(self):
        return "{}:\n{}".format(self.plc_name, self.conf)

    def start_plc(self, modbus_port=None):
        if self.setup_complete:
            self.start_workers()
            self.start_modbus_server(modbus_port)
        else:
            self.logger.warning(
                "PLC has not been initialized, rejecting start up")

    def register_workers(self, selector, publish_queue):
        workers_conf = self.conf['workers']
        for worker_name, attr in workers_conf.items():
            # Invoke the factory to create a new worker
            attr['name'] = worker_name
            worker, response_pipe_r = WorkerFactory.create_new_worker(attr)
            if worker is None:
                continue
            # Add the clock to the workers attributes
            attr['clock'] = self.clock
            # If this worker intends to respond to simulink then
            # Link up it's pipe to the main selector
            if response_pipe_r:
                respond_to = (attr['respond_to']['host'],
                              attr['respond_to']['port'])
                selector.register(response_pipe_r, selectors.EVENT_READ, {
                    "connection_type": "response",
                    "respond_to": respond_to
                })
            # If this worker intends to listen from simulink data then it should give a port
            # A server socket will be set up for this port in the main selector
            # Data destined to this port will be parsed, packaged, and then sent to listening worker processes
            # using the publish_queue
            port = 0
            if attr.get('port', None):
                port = attr['port']
                serverfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                serverfd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                serverfd.bind(('', attr['port']))
                selector.register(serverfd, selectors.EVENT_READ, {
                    "connection_type": "server_socket",
                    "channel": attr['port']
                })
                # Unsure whether creation of thread or starting a thread attaches it to the parent ps
                # If there are performance issues in the simulink interface you can investigate this
            channel = attr.get('channel', port)
            p = threading.Thread(target=worker.run,
                                 args=(publish_queue.register(channel), ))
            self.worker_processes[worker_name] = {
                "process": p,
                "attributes": attr,
                "worker": worker,
            }
            self.register_map[int(attr['register'])] = worker
            self.logger.info("Setting up worker '{}'".format(worker_name))
        self.setup_complete = True

    def start_workers(self):
        for worker_name, info in self.worker_processes.items():
            self.logger.info("Starting up worker '{}'".format(worker_name))
            info['process'].start()

    def start_modbus_server(self, port=None):
        if port is None:
            port = self.conf['modbus_port']

        ENDIANNESS = 'BIG'

        def handle_request(request):
            request_header = request['header']
            request_body = request['body']
            self.logger.debug(
                "Servicing modbus request {}".format(request_header))
            start_register = request_body['address']
            if request_header[
                    'function_code'] == FunctionCodes.WRITE_SINGLE_HOLDING_REGISTER:
                setting = request_body['value']
                worker = self.register_map.get(start_register, None)
                if worker:
                    if hasattr(worker, 'set_reading'):
                        worker.set_reading(setting)
                        self.logger.info(
                            "Setting new pressure reading to {} at {}".format(
                                setting, worker.attributes['name']))
                return modbusencoder.respond_write_registers(
                    request_header, 0, 1, endianness=ENDIANNESS)
            else:
                readings = []
                register_count = request_body['count']
                for current_reg in range(start_register, register_count, 2):
                    worker = self.register_map.get(current_reg, None)
                    if worker:
                        self.logger.info('Retrieving data from {}'.format(
                            worker.attributes['name']))
                        readings.append((worker.get_reading(), 'FLOAT32'))
                self.logger.info(
                    "Responding to request with {}".format(readings))
                return modbusencoder.respond_read_registers(
                    request_header, readings, endianness=ENDIANNESS)

        DEVICE_FUNCTION_CODES = [3, 4, 6, 16]
        modbus_receiver = ModbusReceiver(
            port,
            device_function_codes=DEVICE_FUNCTION_CODES,
            socket_type=socket.SOCK_DGRAM)
        self.logger.info("Starting modbus server for PLC on {}".format(
            self.modbus_port))
        modbus_receiver.start_server(handle_request)