Files
uarep-ctl/modbus/pymodbus/server/reactive/main.py

373 lines
14 KiB
Python

"""
Copyright (c) 2020 by RiptideIO
All rights reserved.
"""
import os
import asyncio
import time
import random
import logging
from pymodbus.version import version as pymodbus_version
from pymodbus.compat import IS_PYTHON3, PYTHON_VERSION
from pymodbus.pdu import ExceptionResponse, ModbusExceptions
from pymodbus.datastore.store import (ModbusSparseDataBlock,
ModbusSequentialDataBlock)
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
from pymodbus.device import ModbusDeviceIdentification
if not IS_PYTHON3 or PYTHON_VERSION < (3, 6):
print(f"You are running {PYTHON_VERSION}."
"Reactive server requires python3.6 or above")
exit()
try:
from aiohttp import web
except ImportError as e:
print("Reactive server requires aiohttp. "
"Please install with 'pip install aiohttp' and try again.")
exit(1)
from pymodbus.server.async_io import (ModbusTcpServer,
ModbusTlsServer,
ModbusSerialServer,
ModbusUdpServer,
ModbusSingleRequestHandler,
ModbusConnectedRequestHandler,
ModbusDisconnectedRequestHandler)
from pymodbus.transaction import (ModbusRtuFramer,
ModbusSocketFramer,
ModbusTlsFramer,
ModbusAsciiFramer,
ModbusBinaryFramer)
logger = logging.getLogger(__name__)
SERVER_MAPPER = {
"tcp": ModbusTcpServer,
"serial": ModbusSerialServer,
"udp": ModbusUdpServer,
"tls": ModbusTlsServer
}
DEFAULT_FRAMER = {
"tcp": ModbusSocketFramer,
"rtu": ModbusRtuFramer,
"tls": ModbusTlsFramer,
"udp": ModbusSocketFramer,
"ascii": ModbusAsciiFramer,
"binary": ModbusBinaryFramer
}
DEFAULT_MANIPULATOR = {
"response_type": "normal", # normal, error, delayed, empty
"delay_by": 0,
"error_code": ModbusExceptions.IllegalAddress,
"clear_after": 5 # request count
}
DEFUALT_HANDLERS = {
"ModbusSingleRequestHandler": ModbusSingleRequestHandler,
"ModbusConnectedRequestHandler": ModbusConnectedRequestHandler,
"ModbusDisconnectedRequestHandler": ModbusDisconnectedRequestHandler
}
DEFAULT_MODBUS_MAP = {"start_offset": 0,
"count": 100,
"value": 0, "sparse": False}
DEFAULT_DATA_BLOCK = {
"co": DEFAULT_MODBUS_MAP,
"di": DEFAULT_MODBUS_MAP,
"ir": DEFAULT_MODBUS_MAP,
"hr": DEFAULT_MODBUS_MAP
}
HINT = """
Reactive Modbus Server started.
{}
===========================================================================
Example Usage:
curl -X POST http://{}:{} -d '{{"response_type": "error", "error_code": 4}}'
===========================================================================
"""
class ReactiveServer:
"""
Modbus Asynchronous Server which can manipulate the response dynamically.
Useful for testing
"""
def __init__(self, host, port, modbus_server, loop=None):
self._web_app = web.Application()
self._runner = web.AppRunner(self._web_app)
self._host = host
self._port = int(port)
self._modbus_server = modbus_server
self._loop = loop
self._add_routes()
self._counter = 0
self._modbus_server.response_manipulator = self.manipulate_response
self._manipulator_config = dict(**DEFAULT_MANIPULATOR)
self._web_app.on_startup.append(self.start_modbus_server)
self._web_app.on_shutdown.append(self.stop_modbus_server)
@property
def web_app(self):
return self._web_app
@property
def manipulator_config(self):
return self._manipulator_config
@manipulator_config.setter
def manipulator_config(self, value):
if isinstance(value, dict):
self._manipulator_config.update(**value)
def _add_routes(self):
self._web_app.add_routes([
web.post('/', self._response_manipulator)])
async def start_modbus_server(self, app):
"""
Start Modbus server as asyncio task after startup
:param app: Webapp
:return:
"""
try:
if hasattr(asyncio, "create_task"):
if isinstance(self._modbus_server, ModbusSerialServer):
app["modbus_serial_server"] = asyncio.create_task(
self._modbus_server.start())
app["modbus_server"] = asyncio.create_task(
self._modbus_server.serve_forever())
else:
if isinstance(self._modbus_server, ModbusSerialServer):
app["modbus_serial_server"] = asyncio.ensure_future(
self._modbus_server.start()
)
app["modbus_server"] = asyncio.ensure_future(
self._modbus_server.serve_forever())
logger.info("Modbus server started")
except Exception as e:
logger.error("Error starting modbus server")
logger.error(e)
async def stop_modbus_server(self, app):
"""
Stop modbus server
:param app: Webapp
:return:
"""
logger.info("Stopping modbus server")
if isinstance(self._modbus_server, ModbusSerialServer):
app["modbus_serial_server"].cancel()
app["modbus_server"].cancel()
await app["modbus_server"]
logger.info("Modbus server Stopped")
async def _response_manipulator(self, request):
"""
POST request Handler for response manipulation end point
Payload is a dict with following fields
:response_type : One among (normal, delayed, error, empty, stray)
:error_code: Modbus error code for error response
:delay_by: Delay sending response by <n> seconds
:param request:
:return:
"""
data = await request.json()
self._manipulator_config.update(data)
return web.json_response(data=data)
def update_manipulator_config(self, config):
"""
Updates manipulator config. Resets previous counters
:param config: Manipulator config (dict)
:return:
"""
self._counter = 0
self._manipulator_config = config
def manipulate_response(self, response):
"""
Manipulates the actual response according to the required error state.
:param response: Modbus response object
:return: Modbus response
"""
skip_encoding = False
if not self._manipulator_config:
return response
else:
clear_after = self._manipulator_config.get("clear_after")
if clear_after and self._counter > clear_after:
logger.info("Resetting manipulator"
" after {} responses".format(clear_after))
self.update_manipulator_config(dict(DEFAULT_MANIPULATOR))
return response
response_type = self._manipulator_config.get("response_type")
if response_type == "error":
error_code = self._manipulator_config.get("error_code")
logger.warning(
"Sending error response for all incoming requests")
err_response = ExceptionResponse(response.function_code, error_code)
err_response.transaction_id = response.transaction_id
err_response.unit_id = response.unit_id
response = err_response
self._counter += 1
elif response_type == "delayed":
delay_by = self._manipulator_config.get("delay_by")
logger.warning(
"Delaying response by {}s for "
"all incoming requests".format(delay_by))
time.sleep(delay_by)
self._counter += 1
elif response_type == "empty":
logger.warning("Sending empty response")
self._counter += 1
response.should_respond = False
elif response_type == "stray":
data_len = self._manipulator_config.get("data_len", 10)
if data_len <= 0:
logger.warning(f"Invalid data_len {data_len}. "
f"Using default lenght 10")
data_len = 10
response = os.urandom(data_len)
self._counter += 1
skip_encoding = True
return response, skip_encoding
def run(self):
"""
Run Web app
:return:
"""
def _info(message):
msg = HINT.format(message, self._host, self._port)
print(msg)
# print(message)
web.run_app(self._web_app, host=self._host, port=self._port,
print=_info)
async def run_async(self):
"""
Run Web app
:return:
"""
try:
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
except Exception as e:
logger.error(e)
@classmethod
def create_identity(cls, vendor="Pymodbus", product_code="PM",
vendor_url='http://github.com/riptideio/pymodbus/',
product_name="Pymodbus Server",
model_name="Reactive Server",
version=pymodbus_version.short()):
"""
Create modbus identity
:param vendor:
:param product_code:
:param vendor_url:
:param product_name:
:param model_name:
:param version:
:return: ModbusIdentity object
"""
identity = ModbusDeviceIdentification()
identity.VendorName = vendor
identity.ProductCode = product_code
identity.VendorUrl = vendor_url
identity.ProductName = product_name
identity.ModelName = model_name
identity.MajorMinorRevision = version
return identity
@classmethod
def create_context(cls, data_block=None, unit=1,
single=False):
"""
Create Modbus context.
:param data_block: Datablock (dict) Refer DEFAULT_DATA_BLOCK
:param unit: Unit id for the slave
:param single: To run as a single slave
:return: ModbusServerContext object
"""
block = dict()
data_block = data_block or DEFAULT_DATA_BLOCK
for modbus_entity, block_desc in data_block.items():
start_address = block_desc.get("start_address", 0)
default_count = block_desc.get("count", 0)
default_value = block_desc.get("value", 0)
default_values = [default_value]*default_count
sparse = block_desc.get("sparse", False)
db = ModbusSequentialDataBlock if not sparse else ModbusSparseDataBlock
if sparse:
address_map = block_desc.get("address_map")
if not address_map:
address_map = random.sample(
range(start_address+1, default_count), default_count-1)
address_map.insert(0, 0)
block[modbus_entity] = {add: val for add in sorted(address_map) for val in default_values}
else:
block[modbus_entity] =db(start_address, default_values)
slave_context = ModbusSlaveContext(**block, zero_mode=True)
if not single:
slaves = {}
for i in unit:
slaves[i] = slave_context
else:
slaves = slave_context
server_context = ModbusServerContext(slaves, single=single)
return server_context
@classmethod
def factory(cls, server, framer=None, context=None, unit=1, single=False,
host="localhost", modbus_port=5020, web_port=8080,
data_block=DEFAULT_DATA_BLOCK, identity=None, loop=None, **kwargs):
"""
Factory to create ReactiveModbusServer
:param server: Modbus server type (tcp, rtu, tls, udp)
:param framer: Modbus framer (ModbusSocketFramer, ModbusRTUFramer, ModbusTLSFramer)
:param context: Modbus server context to use
:param unit: Modbus unit id
:param single: Run in single mode
:param host: Host address to use for both web app and modbus server (default localhost)
:param modbus_port: Modbus port for TCP and UDP server(default: 5020)
:param web_port: Web App port (default: 8080)
:param data_block: Datablock (refer DEFAULT_DATA_BLOCK)
:param identity: Modbus identity object
:param loop: Asyncio loop to use
:param kwargs: Other server specific keyword arguments, refer corresponding servers documentation
:return: ReactiveServer object
"""
if server.lower() not in SERVER_MAPPER:
logger.error(f"Invalid server {server}", server)
exit(1)
server = SERVER_MAPPER.get(server)
if not framer:
framer = DEFAULT_FRAMER.get(server)
if not context:
context = cls.create_context(data_block=data_block,
unit=unit, single=single)
if not identity:
identity = cls.create_identity()
if server == ModbusSerialServer:
kwargs["port"] = modbus_port
server = server(context, framer=framer, identity=identity,
**kwargs)
else:
server = server(context, framer=framer, identity=identity,
address=(host, modbus_port), defer_start=False,
**kwargs)
return ReactiveServer(host, web_port, server, loop)
# __END__