This commit is contained in:
2025-01-03 15:06:21 +01:00
parent 68a7c9cb0f
commit d91714829d
3441 changed files with 615211 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
"""Server.
import external classes, to make them easier to use:
"""
__all__ = [
"get_simulator_commandline",
"ModbusSerialServer",
"ModbusSimulatorServer",
"ModbusTcpServer",
"ModbusTlsServer",
"ModbusUdpServer",
"ServerAsyncStop",
"ServerStop",
"StartAsyncSerialServer",
"StartAsyncTcpServer",
"StartAsyncTlsServer",
"StartAsyncUdpServer",
"StartSerialServer",
"StartTcpServer",
"StartTlsServer",
"StartUdpServer",
]
from pymodbus.server.async_io import (
ModbusSerialServer,
ModbusTcpServer,
ModbusTlsServer,
ModbusUdpServer,
ServerAsyncStop,
ServerStop,
StartAsyncSerialServer,
StartAsyncTcpServer,
StartAsyncTlsServer,
StartAsyncUdpServer,
StartSerialServer,
StartTcpServer,
StartTlsServer,
StartUdpServer,
)
from pymodbus.server.simulator.http_server import ModbusSimulatorServer
from pymodbus.server.simulator.main import get_commandline as get_simulator_commandline

View File

@@ -0,0 +1,742 @@
"""Implementation of a Threaded Modbus Server."""
# pylint: disable=missing-type-doc
import asyncio
import os
import time
import traceback
from contextlib import suppress
from typing import Union
from pymodbus.datastore import ModbusServerContext
from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification
from pymodbus.exceptions import NoSuchSlaveException
from pymodbus.factory import ServerDecoder
from pymodbus.framer import ModbusFramer
from pymodbus.logging import Log
from pymodbus.pdu import ModbusExceptions as merror
from pymodbus.transaction import (
ModbusAsciiFramer,
ModbusRtuFramer,
ModbusSocketFramer,
ModbusTlsFramer,
)
from pymodbus.transport import CommParams, CommType, ModbusProtocol
with suppress(ImportError):
pass
# --------------------------------------------------------------------------- #
# Protocol Handlers
# --------------------------------------------------------------------------- #
class ModbusServerRequestHandler(ModbusProtocol):
"""Implements modbus slave wire protocol.
This uses the asyncio.Protocol to implement the server protocol.
When a connection is established, a callback is called.
This callback will setup the connection and
create and schedule an asyncio.Task and assign it to running_task.
"""
def __init__(self, owner):
"""Initialize."""
params = CommParams(
comm_name="server",
comm_type=owner.comm_params.comm_type,
reconnect_delay=0.0,
reconnect_delay_max=0.0,
timeout_connect=0.0,
host=owner.comm_params.source_address[0],
port=owner.comm_params.source_address[1],
)
super().__init__(params, False)
self.server = owner
self.running = False
self.receive_queue = asyncio.Queue()
self.handler_task = None # coroutine to be run on asyncio loop
self.framer: ModbusFramer = None
def _log_exception(self):
"""Show log exception."""
Log.debug(
"Handler for stream [{}] has been canceled", self.comm_params.comm_name
)
def callback_connected(self) -> None:
"""Call when connection is succcesfull."""
try:
self.running = True
self.framer = self.server.framer(
self.server.decoder,
client=None,
)
# schedule the connection handler on the event loop
self.handler_task = asyncio.create_task(self.handle())
except Exception as exc: # pragma: no cover pylint: disable=broad-except
Log.error(
"Server callback_connected exception: {}; {}",
exc,
traceback.format_exc(),
)
def callback_disconnected(self, call_exc: Exception) -> None:
"""Call when connection is lost."""
try:
if self.handler_task:
self.handler_task.cancel()
if hasattr(self.server, "on_connection_lost"):
self.server.on_connection_lost()
if call_exc is None:
self._log_exception()
else:
Log.debug(
"Client Disconnection {} due to {}",
self.comm_params.comm_name,
call_exc,
)
self.running = False
except Exception as exc: # pylint: disable=broad-except
Log.error(
"Datastore unable to fulfill request: {}; {}",
exc,
traceback.format_exc(),
)
async def inner_handle(self):
"""Handle handler."""
slaves = 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 broadcast is enabled make sure to
# process requests to address 0
if self.server.broadcast_enable: # pragma: no cover
if 0 not in slaves:
slaves.append(0)
Log.debug("Handling data: {}", data, ":hex")
single = self.server.context.single
self.framer.processIncomingPacket(
data=data,
callback=lambda x: self.execute(x, *addr),
slave=slaves,
single=single,
)
async def handle(self):
"""Return 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 ModbusServerRequestHandler class's callback Future.
This callback future gets data from either
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.
"""
reset_frame = False
while self.running:
try:
await self.inner_handle()
except asyncio.CancelledError:
# catch and ignore cancellation errors
if self.running:
self._log_exception()
self.running = False
except Exception as exc: # pylint: disable=broad-except
# force TCP socket termination as processIncomingPacket
# should handle application layer errors
# for UDP sockets, simply reset the frame
if isinstance(self, ModbusServerRequestHandler):
Log.error(
'Unknown exception "{}" on stream {} forcing disconnect',
exc,
self.comm_params.comm_name,
)
self.transport_close()
self.callback_disconnected(exc)
else:
Log.error("Unknown error occurred {}", exc)
reset_frame = True # graceful recovery
finally:
if reset_frame:
self.framer.resetFrame()
reset_frame = False
def execute(self, request, *addr):
"""Call with the resulting message.
:param request: The decoded request message
:param addr: the address
"""
if self.server.request_tracer:
self.server.request_tracer(request, *addr)
broadcast = False
try:
if self.server.broadcast_enable and not request.slave_id:
broadcast = True
# if broadcasting then execute on all slave contexts,
# note response will be ignored
for slave_id in self.server.context.slaves():
response = request.execute(self.server.context[slave_id])
else:
context = self.server.context[request.slave_id]
response = request.execute(context)
except NoSuchSlaveException:
Log.error("requested slave does not exist: {}", request.slave_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 exc: # pylint: disable=broad-except
Log.error(
"Datastore unable to fulfill request: {}; {}",
exc,
traceback.format_exc(),
)
response = request.doException(merror.SlaveFailure)
# no response when broadcasting
if not broadcast:
response.transaction_id = request.transaction_id
response.slave_id = request.slave_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):
"""Send message."""
if kwargs.get("skip_encoding", False):
self.transport_send(message, addr=addr)
elif message.should_respond:
pdu = self.framer.buildPacket(message)
self.transport_send(pdu, addr=addr)
else:
Log.debug("Skipping sending response!!")
async def _recv_(self): # pragma: no cover
"""Receive data from the network."""
try:
result = await self.receive_queue.get()
except RuntimeError:
Log.error("Event loop is closed")
result = None
return result
def callback_data(self, data: bytes, addr: tuple = None) -> int:
"""Handle received data."""
if addr:
self.receive_queue.put_nowait((data, addr))
else:
self.receive_queue.put_nowait(data)
return len(data)
# --------------------------------------------------------------------------- #
# Server Implementations
# --------------------------------------------------------------------------- #
class ModbusBaseServer(ModbusProtocol):
"""Common functionality for all server classes"""
def __init__(
self,
params: CommParams,
context,
ignore_missing_slaves,
broadcast_enable,
response_manipulator,
request_tracer,
identity,
framer,
) -> None:
"""Initialize base server."""
super().__init__(
params,
True,
)
self.loop = asyncio.get_running_loop()
self.decoder = ServerDecoder()
self.context = context or ModbusServerContext()
self.control = ModbusControlBlock()
self.ignore_missing_slaves = ignore_missing_slaves
self.broadcast_enable = broadcast_enable
self.response_manipulator = response_manipulator
self.request_tracer = request_tracer
self.handle_local_echo = False
if isinstance(identity, ModbusDeviceIdentification):
self.control.Identity.update(identity)
self.framer = framer
self.serving: asyncio.Future = asyncio.Future()
def callback_new_connection(self):
"""Handle incoming connect."""
return ModbusServerRequestHandler(self)
async def shutdown(self):
"""Shutdown server."""
await self.server_close()
async def server_close(self):
"""Close server."""
if not self.serving.done():
self.serving.set_result(True)
self.transport_close()
async def serve_forever(self):
"""Start endless loop."""
if self.transport:
raise RuntimeError(
"Can't call serve_forever on an already running server object"
)
await self.transport_listen()
Log.info("Server listening.")
await self.serving
Log.info("Server graceful shutdown.")
class ModbusTcpServer(ModbusBaseServer):
"""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=ModbusSocketFramer,
identity=None,
address=("", 502),
ignore_missing_slaves=False,
broadcast_enable=False,
response_manipulator=None,
request_tracer=None,
):
"""Initialize 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 ignore_missing_slaves: True to not send errors on a request
to a missing slave
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
False to treat 0 as any other slave_id
:param response_manipulator: Callback method for manipulating the
response
:param request_tracer: Callback method for tracing
"""
params = getattr(
self,
"tls_setup",
CommParams(
comm_type=CommType.TCP,
comm_name="server_listener",
reconnect_delay=0.0,
reconnect_delay_max=0.0,
timeout_connect=0.0,
),
)
params.source_address = address
super().__init__(
params,
context,
ignore_missing_slaves,
broadcast_enable,
response_manipulator,
request_tracer,
identity,
framer,
)
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__( # pylint: disable=too-many-arguments
self,
context,
framer=ModbusTlsFramer,
identity=None,
address=("", 502),
sslctx=None,
certfile=None,
keyfile=None,
password=None,
ignore_missing_slaves=False,
broadcast_enable=False,
response_manipulator=None,
request_tracer=None,
):
"""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 password: The password for for decrypting the private key file
:param ignore_missing_slaves: True to not send errors on a request
to a missing slave
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
False to treat 0 as any other slave_id
:param response_manipulator: Callback method for
manipulating the response
"""
self.tls_setup = CommParams(
comm_type=CommType.TLS,
comm_name="server_listener",
reconnect_delay=0.0,
reconnect_delay_max=0.0,
timeout_connect=0.0,
sslctx=CommParams.generate_ssl(
True, certfile, keyfile, password, sslctx=sslctx
),
)
super().__init__(
context,
framer=framer,
identity=identity,
address=address,
ignore_missing_slaves=ignore_missing_slaves,
broadcast_enable=broadcast_enable,
response_manipulator=response_manipulator,
request_tracer=request_tracer,
)
class ModbusUdpServer(ModbusBaseServer):
"""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=ModbusSocketFramer,
identity=None,
address=("", 502),
ignore_missing_slaves=False,
broadcast_enable=False,
response_manipulator=None,
request_tracer=None,
):
"""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 ignore_missing_slaves: True to not send errors on a request
to a missing slave
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
False to treat 0 as any other slave_id
:param response_manipulator: Callback method for
manipulating the response
:param request_tracer: Callback method for tracing
"""
# ----------------
super().__init__(
CommParams(
comm_type=CommType.UDP,
comm_name="server_listener",
source_address=address,
reconnect_delay=0.0,
reconnect_delay_max=0.0,
timeout_connect=0.0,
),
context,
ignore_missing_slaves,
broadcast_enable,
response_manipulator,
request_tracer,
identity,
framer,
)
class ModbusSerialServer(ModbusBaseServer):
"""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.
"""
def __init__(
self, context, framer=ModbusRtuFramer, identity=None, **kwargs
): # pragma: no cover
"""Initialize 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, default ModbusRtuFramer
: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 handle_local_echo: (optional) Discard local echo from dongle.
:param ignore_missing_slaves: True to not send errors on a request
to a missing slave
:param broadcast_enable: True to treat slave_id 0 as broadcast address,
False to treat 0 as any other slave_id
:param reconnect_delay: reconnect delay in seconds
:param response_manipulator: Callback method for
manipulating the response
:param request_tracer: Callback method for tracing
"""
super().__init__(
CommParams(
comm_type=CommType.SERIAL,
comm_name="server_listener",
reconnect_delay=kwargs.get("reconnect_delay", 2),
reconnect_delay_max=0.0,
timeout_connect=kwargs.get("timeout", 3),
source_address=(kwargs.get("port", 0), 0),
bytesize=kwargs.get("bytesize", 8),
parity=kwargs.get("parity", "N"),
baudrate=kwargs.get("baudrate", 19200),
stopbits=kwargs.get("stopbits", 1),
),
context,
kwargs.get("ignore_missing_slaves", False),
kwargs.get("broadcast_enable", False),
kwargs.get("request_tracer", None),
kwargs.get("response_manipulator", None),
kwargs.get("identity", None),
framer,
)
self.handle_local_echo = kwargs.get("handle_local_echo", False)
# --------------------------------------------------------------------------- #
# Creation Factories
# --------------------------------------------------------------------------- #
class _serverList:
"""Maintains information about the active server.
:meta private:
"""
active_server: Union[ModbusTcpServer, ModbusUdpServer, ModbusSerialServer] = None
def __init__(self, server):
"""Register new server."""
self.server = server
self.loop = asyncio.get_event_loop()
@classmethod
async def run(cls, server, custom_functions):
"""Help starting/stopping server."""
for func in custom_functions:
server.decoder.register(func)
cls.active_server = _serverList(server)
with suppress(asyncio.exceptions.CancelledError):
await server.serve_forever()
@classmethod
async def async_stop(cls):
"""Wait for server stop."""
if not cls.active_server:
raise RuntimeError("ServerAsyncStop called without server task active.")
await cls.active_server.server.shutdown()
if os.name == "nt":
await asyncio.sleep(1)
else:
await asyncio.sleep(0.1)
cls.active_server = None
@classmethod
def stop(cls):
"""Wait for server stop."""
if not cls.active_server:
Log.info("ServerStop called without server task active.")
return
if not cls.active_server.loop.is_running():
Log.info("ServerStop called with loop stopped.")
return
asyncio.run_coroutine_threadsafe(cls.async_stop(), cls.active_server.loop)
if os.name == "nt":
time.sleep(10)
else:
time.sleep(0.5)
async def StartAsyncTcpServer( # pylint: disable=invalid-name,dangerous-default-value
context=None,
identity=None,
address=None,
custom_functions=[],
**kwargs,
):
"""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 kwargs: The rest
"""
kwargs.pop("host", None)
server = ModbusTcpServer(
context, kwargs.pop("framer", ModbusSocketFramer), identity, address, **kwargs
)
await _serverList.run(server, custom_functions)
async def StartAsyncTlsServer( # pylint: disable=invalid-name,dangerous-default-value
context=None,
identity=None,
address=None,
sslctx=None,
certfile=None,
keyfile=None,
password=None,
custom_functions=[],
**kwargs,
):
"""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 password: The password for for decrypting the private key file
:param custom_functions: An optional list of custom function classes
supported by server instance.
:param kwargs: The rest
"""
kwargs.pop("host", None)
server = ModbusTlsServer(
context,
kwargs.pop("framer", ModbusTlsFramer),
identity,
address,
sslctx,
certfile,
keyfile,
password,
**kwargs,
)
await _serverList.run(server, custom_functions)
async def StartAsyncUdpServer( # pylint: disable=invalid-name,dangerous-default-value
context=None,
identity=None,
address=None,
custom_functions=[],
**kwargs,
):
"""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 kwargs:
"""
kwargs.pop("host", None)
server = ModbusUdpServer(
context, kwargs.pop("framer", ModbusSocketFramer), identity, address, **kwargs
)
await _serverList.run(server, custom_functions)
async def StartAsyncSerialServer( # pylint: disable=invalid-name,dangerous-default-value
context=None,
identity=None,
custom_functions=[],
**kwargs,
): # pragma: no cover
"""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 kwargs: The rest
"""
server = ModbusSerialServer(
context, kwargs.pop("framer", ModbusAsciiFramer), identity=identity, **kwargs
)
await _serverList.run(server, custom_functions)
def StartSerialServer(**kwargs): # pylint: disable=invalid-name
"""Start and run a serial modbus server."""
return asyncio.run(StartAsyncSerialServer(**kwargs))
def StartTcpServer(**kwargs): # pylint: disable=invalid-name
"""Start and run a serial modbus server."""
return asyncio.run(StartAsyncTcpServer(**kwargs))
def StartTlsServer(**kwargs): # pylint: disable=invalid-name
"""Start and run a serial modbus server."""
return asyncio.run(StartAsyncTlsServer(**kwargs))
def StartUdpServer(**kwargs): # pylint: disable=invalid-name
"""Start and run a serial modbus server."""
return asyncio.run(StartAsyncUdpServer(**kwargs))
async def ServerAsyncStop(): # pylint: disable=invalid-name
"""Terminate server."""
await _serverList.async_stop()
def ServerStop(): # pylint: disable=invalid-name
"""Terminate server."""
_serverList.stop()

View File

@@ -0,0 +1 @@
"""Initialize."""

View File

@@ -0,0 +1,55 @@
"""Configuration for Pymodbus REPL Reactive Module."""
DEFAULT_CONFIG = {
"tcp": {
"ignore_missing_slaves": False,
},
"serial": {
"stopbits": 1,
"bytesize": 8,
"parity": "N",
"baudrate": 9600,
"timeout": 3,
"reconnect_delay": 2,
},
"tls": {
"certfile": None,
"keyfile": None,
"ignore_missing_slaves": False,
},
"udp": {
"ignore_missing_slaves": False,
},
"data_block_settings": {
"min_binary_value": 0, # For coils and DI
"max_binary_value": 1, # For coils and DI
"min_register_value": 0, # For Holding and input registers
"max_register_value": 65535, # For Holding and input registers
"data_block": {
"discrete_inputs": {
"block_start": 0, # Block start
"block_size": 100, # Block end
"default": 0, # Default value,
"sparse": False,
},
"coils": {
"block_start": 0,
"block_size": 100,
"default": 0,
"sparse": False,
},
"holding_registers": {
"block_start": 0,
"block_size": 100,
"default": 0,
"sparse": False,
},
"input_registers": {
"block_start": 0,
"block_size": 100,
"default": 0,
"sparse": False,
},
},
},
}

View File

@@ -0,0 +1,501 @@
"""Reactive main."""
from __future__ import annotations
import asyncio
# pylint: disable=missing-type-doc
import os
import random
import sys
import threading
import time
from enum import Enum
try:
from aiohttp import web
except ImportError:
print(
"Reactive server requires aiohttp. "
'Please install with "pip install aiohttp" and try again.'
)
sys.exit(1)
from pymodbus import __version__ as pymodbus_version
from pymodbus.datastore import ModbusServerContext, ModbusSlaveContext
from pymodbus.datastore.store import (
BaseModbusDataBlock,
ModbusSequentialDataBlock,
ModbusSparseDataBlock,
)
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.logging import Log
from pymodbus.pdu import ExceptionResponse, ModbusExceptions
from pymodbus.server.async_io import (
ModbusSerialServer,
ModbusTcpServer,
ModbusTlsServer,
ModbusUdpServer,
)
from pymodbus.transaction import (
ModbusAsciiFramer,
ModbusBinaryFramer,
ModbusRtuFramer,
ModbusSocketFramer,
ModbusTlsFramer,
)
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
}
DEFAULT_MODBUS_MAP = {
"block_start": 0,
"block_size": 100,
"default": 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 ReactiveModbusSlaveContext(ModbusSlaveContext):
"""Reactive Modbus slave context"""
def __init__(
self,
discrete_inputs: BaseModbusDataBlock = None,
coils: BaseModbusDataBlock = None,
input_registers: BaseModbusDataBlock = None,
holding_registers: BaseModbusDataBlock = None,
zero_mode: bool = False,
randomize: int = 0,
change_rate: int = 0,
**kwargs,
):
"""Reactive Modbus slave context supporting simulating data.
:param discrete_inputs: Discrete input data block
:param coils: Coils data block
:param input_registers: Input registers data block
:param holding_registers: Holding registers data block
:param zero_mode: Enable zero mode for data blocks
:param randomize: Randomize reads every <n> reads for DI and IR,
default is disabled (0)
:param change_rate: Rate in % of registers to change for DI and IR,
default is disabled (0)
:param min_binary_value: Minimum value for coils and discrete inputs
:param max_binary_value: Max value for discrete inputs
:param min_register_value: Minimum value for input registers
:param max_register_value: Max value for input registers
"""
super().__init__(
di=discrete_inputs,
co=coils,
ir=input_registers,
hr=holding_registers,
zero_mode=zero_mode,
)
min_binary_value = kwargs.get("min_binary_value", 0)
max_binary_value = kwargs.get("max_binary_value", 1)
min_register_value = kwargs.get("min_register_value", 0)
max_register_value = kwargs.get("max_register_value", 65535)
self._randomize = randomize
self._change_rate = change_rate
if self._randomize > 0 and self._change_rate > 0:
sys.exit(
"'randomize' and 'change_rate' is not allowed to use at the same time"
)
self._lock = threading.Lock()
self._read_counter = {"d": 0, "i": 0}
self._min_binary_value = min_binary_value
self._max_binary_value = max_binary_value
self._min_register_value = min_register_value
self._max_register_value = max_register_value
def getValues(self, fc_as_hex, address, count=1):
"""Get `count` values from datastore.
:param fc_as_hex: The function we are working with
:param address: The starting address
:param count: The number of values to retrieve
:returns: The requested values from a:a+c
"""
if not self.zero_mode:
address += 1
Log.debug("getValues: fc-[{}] address-{}: count-{}", fc_as_hex, address, count)
_block_type = self.decode(fc_as_hex)
if self._randomize > 0 and _block_type in {"d", "i"}:
with self._lock:
if not (self._read_counter.get(_block_type) % self._randomize):
# Update values
if _block_type == "d":
min_val = self._min_binary_value
max_val = self._max_binary_value
else:
min_val = self._min_register_value
max_val = self._max_register_value
values = [random.randint(min_val, max_val) for _ in range(count)]
self.store[_block_type].setValues(address, values)
self._read_counter[_block_type] += 1
elif self._change_rate > 0 and _block_type in {"d", "i"}:
regs_to_changes = round(count * self._change_rate / 100)
random_indices = random.sample(range(count), regs_to_changes)
for offset in random_indices:
with self._lock:
# Update values
if _block_type == "d":
min_val = self._min_binary_value
max_val = self._max_binary_value
else:
min_val = self._min_register_value
max_val = self._max_register_value
self.store[_block_type].setValues(
address + offset, random.randint(min_val, max_val)
)
values = self.store[_block_type].getValues(address, count)
return values
class ReactiveServer:
"""Modbus Asynchronous Server which can manipulate the response dynamically.
Useful for testing
"""
def __init__(self, host, port, modbus_server):
"""Initialize."""
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._add_routes()
self._counter = 0
self._modbus_server.response_manipulator = self.manipulate_response
self._manipulator_config = {**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):
"""Start web_app."""
return self._web_app
@property
def manipulator_config(self):
"""Manipulate config."""
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):
"""Add routes."""
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
"""
try:
if hasattr(asyncio, "create_task"):
app["modbus_server"] = asyncio.create_task(
self._modbus_server.serve_forever()
)
else:
app["modbus_server"] = asyncio.ensure_future(
self._modbus_server.serve_forever()
)
Log.info("Modbus server started")
except Exception as exc: # pylint: disable=broad-except
Log.error("Error starting modbus server {}", exc)
async def stop_modbus_server(self, app):
"""Stop modbus server.
:param app: Webapp
"""
Log.info("Stopping modbus server")
if isinstance(self._modbus_server, ModbusSerialServer):
app["modbus_serial_server"].cancel()
app["modbus_server"].cancel()
await app["modbus_server"]
Log.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):
"""Update manipulator config. Resets previous counters.
:param config: Manipulator config (dict)
"""
self._counter = 0
self._manipulator_config = config
def manipulate_response(self, response):
"""Manipulate 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
clear_after = self._manipulator_config.get("clear_after")
if clear_after and self._counter > clear_after:
Log.info("Resetting manipulator after {} responses", 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")
Log.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.slave_id = response.slave_id
response = err_response
self._counter += 1
elif response_type == "delayed":
delay_by = self._manipulator_config.get("delay_by")
Log.warning("Delaying response by {}s for all incoming requests", delay_by)
time.sleep(delay_by) # change to async
self._counter += 1
elif response_type == "empty":
Log.warning("Sending empty response")
self._counter += 1
response.should_respond = False
elif response_type == "stray":
if (data_len := self._manipulator_config.get("data_len", 10)) <= 0:
Log.warning("Invalid data_len {}, using default 10", data_len)
data_len = 10
response = os.urandom(data_len)
self._counter += 1
skip_encoding = True
return response, skip_encoding
async def run_async(self, repl_mode=False):
"""Run Web app."""
try:
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
if not repl_mode:
message = (
f"======== Running on http://{self._host}:{self._port} ========"
)
msg = HINT.format(message, self._host, self._port)
print(msg)
except Exception as exc: # pylint: disable=broad-except
Log.error("Exception {}", exc)
@classmethod
def create_identity(
cls,
vendor="Pymodbus",
product_code="PM",
vendor_url="https://github.com/pymodbus-dev/pymodbus/",
product_name="Pymodbus Server",
model_name="Reactive Server",
version=pymodbus_version,
):
"""Create modbus identity.
:param vendor:
:param product_code:
:param vendor_url:
:param product_name:
:param model_name:
:param version:
:return: ModbusIdentity object
"""
identity = ModbusDeviceIdentification(
info_name={
"VendorName": vendor,
"ProductCode": product_code,
"VendorUrl": vendor_url,
"ProductName": product_name,
"ModelName": model_name,
"MajorMinorRevision": version,
}
)
return identity
@classmethod
def create_context(
cls,
data_block_settings: dict = {},
slave: list[int] | int = [1],
single: bool = False,
randomize: int = 0,
change_rate: int = 0,
): # pylint: disable=dangerous-default-value
"""Create Modbus context.
:param data_block_settings: Datablock (dict) Refer DEFAULT_DATA_BLOCK
:param slave: Unit id for the slave
:param single: To run as a single slave
:param randomize: Randomize every <n> reads for DI and IR.
:param change_rate: Rate in % of registers to change for DI and IR.
:return: ModbusServerContext object
"""
data_block = data_block_settings.pop("data_block", DEFAULT_DATA_BLOCK)
if not isinstance(slave, list):
slave = [slave]
slaves = {}
for i in slave:
block = {}
for modbus_entity, block_desc in data_block.items():
start_address = block_desc.get("block_start", 0)
default_count = block_desc.get("block_size", 0)
default_value = block_desc.get("default", 0)
default_values = [default_value] * default_count
sparse = block_desc.get("sparse", False)
db = ModbusSequentialDataBlock if not sparse else ModbusSparseDataBlock
if sparse:
if not (address_map := block_desc.get("address_map")):
address_map = random.sample(
range(start_address + 1, default_count), default_count - 1
)
address_map.insert(0, 0)
address_map.sort()
block[modbus_entity] = db(address_map, default_values)
else:
block[modbus_entity] = db(start_address, default_values)
slave_context = ReactiveModbusSlaveContext(
**block,
randomize=randomize,
change_rate=change_rate,
zero_mode=True,
**data_block_settings,
)
if not single:
slaves[i] = slave_context
else:
slaves[0] = slave_context
server_context = ModbusServerContext(slaves, single=single)
return server_context
@classmethod
def factory( # pylint: disable=dangerous-default-value,too-many-arguments
cls,
server,
framer=None,
context=None,
slave=1,
single=False,
host="localhost",
modbus_port=5020,
web_port=8080,
data_block_settings={"data_block": DEFAULT_DATA_BLOCK},
identity=None,
**kwargs,
):
"""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 slave: Modbus slave 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_settings: Datablock settings (refer DEFAULT_DATA_BLOCK)
:param identity: Modbus identity object
:param kwargs: Other server specific keyword arguments,
: refer corresponding servers documentation
:return: ReactiveServer object
"""
if isinstance(server, Enum):
server = server.value
if server.lower() not in SERVER_MAPPER:
Log.error("Invalid server {}", server)
sys.exit(1)
server = SERVER_MAPPER.get(server)
randomize = kwargs.pop("randomize", 0)
change_rate = kwargs.pop("change_rate", 0)
if not framer:
framer = DEFAULT_FRAMER.get(server)
if not context:
context = cls.create_context(
data_block_settings=data_block_settings,
slave=slave,
single=single,
randomize=randomize,
change_rate=change_rate,
)
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),
**kwargs,
)
return ReactiveServer(host, web_port, server)
# __END__

View File

@@ -0,0 +1 @@
"""Initialize."""

View File

@@ -0,0 +1,10 @@
"""Datastore simulator, custom actions"""
def device_reset(_registers, _inx, _cell):
"""Use example custom action."""
custom_actions_dict = {
"umg804_reset": device_reset,
}

View File

@@ -0,0 +1,668 @@
"""HTTP server for modbus simulator."""
import asyncio
import dataclasses
import importlib
import json
import os
from time import time
from typing import List
try:
from aiohttp import web
except ImportError:
web = None
import contextlib
from pymodbus.datastore import ModbusServerContext, ModbusSimulatorContext
from pymodbus.datastore.simulator import Label
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.factory import ServerDecoder
from pymodbus.logging import Log
from pymodbus.pdu import ExceptionResponse
from pymodbus.server.async_io import (
ModbusSerialServer,
ModbusTcpServer,
ModbusTlsServer,
ModbusUdpServer,
)
from pymodbus.transaction import (
ModbusAsciiFramer,
ModbusBinaryFramer,
ModbusRtuFramer,
ModbusSocketFramer,
ModbusTlsFramer,
)
MAX_FILTER = 1000
RESPONSE_INACTIVE = -1
RESPONSE_NORMAL = 0
RESPONSE_ERROR = 1
RESPONSE_EMPTY = 2
RESPONSE_JUNK = 3
@dataclasses.dataclass()
class CallTracer:
"""Define call/response traces"""
call: bool = False
fc: int = -1
address: int = -1
count: int = -1
data: bytes = b""
@dataclasses.dataclass()
class CallTypeMonitor:
"""Define Request/Response monitor"""
active: bool = False
trace_response: bool = False
range_start: int = -1
range_stop: int = -1
function: int = -1
hex: bool = False
decode: bool = False
@dataclasses.dataclass()
class CallTypeResponse:
"""Define Response manipulation"""
active: int = RESPONSE_INACTIVE
split: int = 0
delay: int = 0
junk_len: int = 10
error_response: int = 0
change_rate: int = 0
clear_after: int = 1
class ModbusSimulatorServer:
"""**ModbusSimulatorServer**.
:param modbus_server: Server name in json file (default: "server")
:param modbus_device: Device name in json file (default: "client")
:param http_host: TCP host for HTTP (default: "localhost")
:param http_port: TCP port for HTTP (default: 8080)
:param json_file: setup file (default: "setup.json")
:param custom_actions_module: python module with custom actions (default: none)
if either http_port or http_host is none, HTTP will not be started.
This class starts a http server, that serves a couple of endpoints:
- **"<addr>/"** static files
- **"<addr>/api/log"** log handling, HTML with GET, REST-API with post
- **"<addr>/api/registers"** register handling, HTML with GET, REST-API with post
- **"<addr>/api/calls"** call (function code / message) handling, HTML with GET, REST-API with post
- **"<addr>/api/server"** server handling, HTML with GET, REST-API with post
Example::
from pymodbus.server import StartAsyncSimulatorServer
async def run():
simulator = StartAsyncSimulatorServer(
modbus_server="my server",
modbus_device="my device",
http_host="localhost",
http_port=8080)
await simulator.start()
...
await simulator.close()
"""
def __init__(
self,
modbus_server: str = "server",
modbus_device: str = "device",
http_host: str = "0.0.0.0",
http_port: int = 8080,
log_file: str = "server.log",
json_file: str = "setup.json",
custom_actions_module: str = None,
):
"""Initialize http interface."""
if not web:
raise RuntimeError("aiohttp not installed!")
with open(json_file, encoding="utf-8") as file:
setup = json.load(file)
comm_class = {
"serial": ModbusSerialServer,
"tcp": ModbusTcpServer,
"tls": ModbusTlsServer,
"udp": ModbusUdpServer,
}
framer_class = {
"ascii": ModbusAsciiFramer,
"binary": ModbusBinaryFramer,
"rtu": ModbusRtuFramer,
"socket": ModbusSocketFramer,
"tls": ModbusTlsFramer,
}
if custom_actions_module:
actions_module = importlib.import_module(custom_actions_module)
custom_actions_dict = actions_module.custom_actions_dict
else:
custom_actions_dict = None
server = setup["server_list"][modbus_server]
if server["comm"] != "serial":
server["address"] = (server["host"], server["port"])
del server["host"]
del server["port"]
device = setup["device_list"][modbus_device]
self.datastore_context = ModbusSimulatorContext(
device, custom_actions_dict or None
)
datastore = ModbusServerContext(slaves=self.datastore_context, single=True)
comm = comm_class[server.pop("comm")]
framer = framer_class[server.pop("framer")]
if "identity" in server:
server["identity"] = ModbusDeviceIdentification(
info_name=server["identity"]
)
self.modbus_server = comm(framer=framer, context=datastore, **server)
self.serving: asyncio.Future = asyncio.Future()
self.log_file = log_file
self.site = None
self.http_host = http_host
self.http_port = http_port
self.web_path = os.path.join(os.path.dirname(__file__), "web")
self.web_app = web.Application()
self.web_app.add_routes(
[
web.get("/api/{tail:[a-z]*}", self.handle_html),
web.post("/api/{tail:[a-z]*}", self.handle_json),
web.get("/{tail:[a-z0-9.]*}", self.handle_html_static),
web.get("/", self.handle_html_static),
]
)
self.web_app.on_startup.append(self.start_modbus_server)
self.web_app.on_shutdown.append(self.stop_modbus_server)
self.generator_html = {
"log": ["", self.build_html_log],
"registers": ["", self.build_html_registers],
"calls": ["", self.build_html_calls],
"server": ["", self.build_html_server],
}
self.generator_json = {
"log_json": [None, self.build_json_log],
"registers_json": [None, self.build_json_registers],
"calls_json": [None, self.build_json_calls],
"server_json": [None, self.build_json_server],
}
self.submit = {
"Clear": self.action_clear,
"Stop": self.action_stop,
"Reset": self.action_reset,
"Add": self.action_add,
"Monitor": self.action_monitor,
"Set": self.action_set,
"Simulate": self.action_simulate,
}
for entry in self.generator_html: # pylint: disable=consider-using-dict-items
html_file = os.path.join(self.web_path, "generator", entry)
with open(html_file, encoding="utf-8") as handle:
self.generator_html[entry][0] = handle.read()
self.refresh_rate = 0
self.register_filter: List[int] = []
self.call_list: List[tuple] = []
self.request_lookup = ServerDecoder.getFCdict()
self.call_monitor = CallTypeMonitor()
self.call_response = CallTypeResponse()
async def start_modbus_server(self, app):
"""Start Modbus server as asyncio task."""
try:
if getattr(self.modbus_server, "start", None):
await self.modbus_server.start()
app["modbus_server"] = asyncio.create_task(
self.modbus_server.serve_forever()
)
except Exception as exc:
Log.error("Error starting modbus server, reason: {}", exc)
raise exc
Log.info(
"Modbus server started on {}", self.modbus_server.comm_params.source_address
)
async def stop_modbus_server(self, app):
"""Stop modbus server."""
Log.info("Stopping modbus server")
await self.modbus_server.shutdown()
app["modbus_server"].cancel()
with contextlib.suppress(asyncio.exceptions.CancelledError):
await app["modbus_server"]
Log.info("Modbus server Stopped")
async def run_forever(self, only_start=False):
"""Start modbus and http servers."""
try:
runner = web.AppRunner(self.web_app)
await runner.setup()
self.site = web.TCPSite(runner, self.http_host, self.http_port)
await self.site.start()
except Exception as exc:
Log.error("Error starting http server, reason: {}", exc)
raise exc
Log.info("HTTP server started on ({}:{})", self.http_host, self.http_port)
if only_start:
return
await self.serving
async def stop(self):
"""Stop modbus and http servers."""
await self.site.stop()
self.site = None
if not self.serving.done():
self.serving.set_result(True)
await asyncio.sleep(0.1)
async def handle_html_static(self, request):
"""Handle static html."""
if not (page := request.path[1:]):
page = "index.html"
file = os.path.join(self.web_path, page)
try:
with open(file, encoding="utf-8"):
return web.FileResponse(file)
except (FileNotFoundError, IsADirectoryError) as exc:
raise web.HTTPNotFound(reason="File not found") from exc
async def handle_html(self, request):
"""Handle html."""
page_type = request.path.split("/")[-1]
params = dict(request.query)
if refresh := params.pop("refresh", None):
self.refresh_rate = int(refresh)
if self.refresh_rate > 0:
html = self.generator_html[page_type][0].replace(
"<!--REFRESH-->",
f'<meta http-equiv="refresh" content="{self.refresh_rate}">',
)
else:
html = self.generator_html[page_type][0].replace("<!--REFRESH-->", "")
new_page = self.generator_html[page_type][1](params, html)
return web.Response(text=new_page, content_type="text/html")
async def handle_json(self, request):
"""Handle api registers."""
page_type = request.path.split("/")[-1]
params = await request.post()
json_dict = self.generator_html[page_type][0].copy()
result = self.generator_json[page_type][1](params, json_dict)
return web.Response(text=f"json build: {page_type} - {params} - {result}")
def build_html_registers(self, params, html):
"""Build html registers page."""
result_txt, foot = self.helper_build_html_submit(params)
if not result_txt:
result_txt = "ok"
if not foot:
if self.register_filter:
foot = f"{len(self.register_filter)} register(s) monitored"
else:
foot = "Nothing selected"
register_types = "".join(
f"<option value={reg_id}>{name}</option>"
for name, reg_id in self.datastore_context.registerType_name_to_id.items()
)
register_actions = "".join(
f"<option value={action_id}>{name}</option>"
for name, action_id in self.datastore_context.action_name_to_id.items()
)
rows = ""
for i in self.register_filter:
inx, reg = self.datastore_context.get_text_register(i)
if reg.type == Label.next:
continue
row = "".join(
f"<td>{entry}</td>"
for entry in (
inx,
reg.type,
reg.access,
reg.action,
reg.value,
reg.count_read,
reg.count_write,
)
)
rows += f"<tr>{row}</tr>"
new_html = (
html.replace("<!--REGISTER_ACTIONS-->", register_actions)
.replace("<!--REGISTER_TYPES-->", register_types)
.replace("<!--REGISTER_FOOT-->", foot)
.replace("<!--REGISTER_ROWS-->", rows)
.replace("<!--RESULT-->", result_txt)
)
return new_html
def build_html_calls(self, params, html):
"""Build html calls page."""
result_txt, foot = self.helper_build_html_submit(params)
if not foot:
foot = "Montitoring active" if self.call_monitor.active else "not active"
if not result_txt:
result_txt = "ok"
function_error = ""
for i, txt in (
(1, "IllegalFunction"),
(2, "IllegalAddress"),
(3, "IllegalValue"),
(4, "SlaveFailure"),
(5, "Acknowledge"),
(6, "SlaveBusy"),
(7, "MemoryParityError"),
(10, "GatewayPathUnavailable"),
(11, "GatewayNoResponse"),
):
selected = "selected" if i == self.call_response.error_response else ""
function_error += f"<option value={i} {selected}>{txt}</option>"
range_start_html = (
str(self.call_monitor.range_start)
if self.call_monitor.range_start != -1
else ""
)
range_stop_html = (
str(self.call_monitor.range_stop)
if self.call_monitor.range_stop != -1
else ""
)
function_codes = ""
for function in self.request_lookup.values():
selected = (
"selected"
if function.function_code == self.call_monitor.function
else ""
)
function_codes += f"<option value={function.function_code} {selected}>{function.function_code_name}</option>"
simulation_action = (
"ACTIVE" if self.call_response.active != RESPONSE_INACTIVE else ""
)
max_len = MAX_FILTER if self.call_monitor.active else 0
while len(self.call_list) > max_len:
del self.call_list[0]
call_rows = ""
for entry in reversed(self.call_list):
# req_obj = self.request_lookup[entry[1]]
call_rows += f"<tr><td>{entry.call} - {entry.fc}</td><td>{entry.address}</td><td>{entry.count}</td><td>{entry.data}</td></tr>"
# line += req_obj.funcion_code_name
new_html = (
html.replace("<!--SIMULATION_ACTIVE-->", simulation_action)
.replace("FUNCTION_RANGE_START", range_start_html)
.replace("FUNCTION_RANGE_STOP", range_stop_html)
.replace("<!--FUNCTION_CODES-->", function_codes)
.replace(
"FUNCTION_SHOW_HEX_CHECKED", "checked" if self.call_monitor.hex else ""
)
.replace(
"FUNCTION_SHOW_DECODED_CHECKED",
"checked" if self.call_monitor.decode else "",
)
.replace(
"FUNCTION_RESPONSE_NORMAL_CHECKED",
"checked" if self.call_response.active == RESPONSE_NORMAL else "",
)
.replace(
"FUNCTION_RESPONSE_ERROR_CHECKED",
"checked" if self.call_response.active == RESPONSE_ERROR else "",
)
.replace(
"FUNCTION_RESPONSE_EMPTY_CHECKED",
"checked" if self.call_response.active == RESPONSE_EMPTY else "",
)
.replace(
"FUNCTION_RESPONSE_JUNK_CHECKED",
"checked" if self.call_response.active == RESPONSE_JUNK else "",
)
.replace(
"FUNCTION_RESPONSE_SPLIT_CHECKED",
"checked" if self.call_response.split > 0 else "",
)
.replace("FUNCTION_RESPONSE_SPLIT_DELAY", str(self.call_response.split))
.replace(
"FUNCTION_RESPONSE_CR_CHECKED",
"checked" if self.call_response.change_rate > 0 else "",
)
.replace("FUNCTION_RESPONSE_CR_PCT", str(self.call_response.change_rate))
.replace("FUNCTION_RESPONSE_DELAY", str(self.call_response.delay))
.replace("FUNCTION_RESPONSE_JUNK", str(self.call_response.junk_len))
.replace("<!--FUNCTION_ERROR-->", function_error)
.replace(
"FUNCTION_RESPONSE_CLEAR_AFTER", str(self.call_response.clear_after)
)
.replace("<!--FC_ROWS-->", call_rows)
.replace("<!--FC_FOOT-->", foot)
)
return new_html
def build_html_log(self, _params, html):
"""Build html log page."""
return html
def build_html_server(self, _params, html):
"""Build html server page."""
return html
def build_json_registers(self, params, json_dict):
"""Build html registers page."""
return f"json build registers: {params} - {json_dict}"
def build_json_calls(self, params, json_dict):
"""Build html calls page."""
return f"json build calls: {params} - {json_dict}"
def build_json_log(self, params, json_dict):
"""Build json log page."""
return f"json build log: {params} - {json_dict}"
def build_json_server(self, params, json_dict):
"""Build html server page."""
return f"json build server: {params} - {json_dict}"
def helper_build_html_submit(self, params):
"""Build html register submit."""
try:
range_start = int(params.get("range_start", -1))
except ValueError:
range_start = -1
try:
range_stop = int(params.get("range_stop", range_start))
except ValueError:
range_stop = -1
if (submit := params["submit"]) not in self.submit:
return None, None
return self.submit[submit](params, range_start, range_stop)
def action_clear(self, _params, _range_start, _range_stop):
"""Clear register filter."""
self.register_filter = []
return None, None
def action_stop(self, _params, _range_start, _range_stop):
"""Stop call monitoring."""
self.call_monitor = CallTypeMonitor()
self.modbus_server.response_manipulator = None
self.modbus_server.request_tracer = None
return None, "Stopped monitoring"
def action_reset(self, _params, _range_start, _range_stop):
"""Reset call simulation."""
self.call_response = CallTypeResponse()
if not self.call_monitor.active:
self.modbus_server.response_manipulator = self.server_response_manipulator
return None, None
def action_add(self, params, range_start, range_stop):
"""Build list of registers matching filter."""
reg_action = int(params.get("action", -1))
reg_writeable = "writeable" in params
reg_type = int(params.get("type", -1))
filter_updated = 0
if range_start != -1:
steps = range(range_start, range_stop + 1)
else:
steps = range(1, self.datastore_context.register_count)
for i in steps:
if range_start != -1 and (i < range_start or i > range_stop):
continue
reg = self.datastore_context.registers[i]
skip_filter = reg_writeable and not reg.access
skip_filter |= reg_type not in (-1, reg.type)
skip_filter |= reg_action not in (-1, reg.action)
skip_filter |= i in self.register_filter
if skip_filter:
continue
self.register_filter.append(i)
filter_updated += 1
if len(self.register_filter) >= MAX_FILTER:
self.register_filter.sort()
return None, f"Max. filter size {MAX_FILTER} exceeded!"
self.register_filter.sort()
return None, None
def action_monitor(self, params, range_start, range_stop):
"""Start monitoring calls."""
self.call_monitor.range_start = range_start
self.call_monitor.range_stop = range_stop
self.call_monitor.function = (
int(params["function"]) if params["function"] else ""
)
self.call_monitor.hex = "show_hex" in params
self.call_monitor.decode = "show_decode" in params
self.call_monitor.active = True
self.modbus_server.response_manipulator = self.server_response_manipulator
self.modbus_server.request_tracer = self.server_request_tracer
return None, None
def action_set(self, params, _range_start, _range_stop):
"""Set register value."""
if not (register := params["register"]):
return "Missing register", None
register = int(register)
if value := params["value"]:
self.datastore_context.registers[register].value = int(value)
if bool(params.get("writeable", False)):
self.datastore_context.registers[register].access = True
return None, None
def action_simulate(self, params, _range_start, _range_stop):
"""Simulate responses."""
self.call_response.active = int(params["response_type"])
if "response_split" in params:
if params["split_delay"]:
self.call_response.split = int(params["split_delay"])
else:
self.call_response.split = 1
else:
self.call_response.split = 0
if "response_cr" in params:
if params["response_cr_pct"]:
self.call_response.change_rate = int(params["response_cr_pct"])
else:
self.call_response.change_rate = 0
else:
self.call_response.change_rate = 0
if params["response_delay"]:
self.call_response.delay = int(params["response_delay"])
else:
self.call_response.delay = 0
if params["response_junk_datalen"]:
self.call_response.junk_len = int(params["response_junk_datalen"])
else:
self.call_response.junk_len = 0
self.call_response.error_response = int(params["response_error"])
if params["response_clear_after"]:
self.call_response.clear_after = int(params["response_clear_after"])
else:
self.call_response.clear_after = 1
self.modbus_server.response_manipulator = self.server_response_manipulator
return None, None
def server_response_manipulator(self, response):
"""Manipulate responses.
All server responses passes this filter before being sent.
The filter returns:
- response, either original or modified
- skip_encoding, signals whether or not to encode the response
"""
if self.call_monitor.trace_response:
tracer = CallTracer(
call=False,
fc=response.function_code,
address=response.address if hasattr(response, "address") else -1,
count=response.count if hasattr(response, "count") else -1,
data="-",
)
self.call_list.append(tracer)
self.call_monitor.trace_response = False
if self.call_response.active != RESPONSE_INACTIVE:
return response, False
skip_encoding = False
if self.call_response.active == RESPONSE_EMPTY:
Log.warning("Sending empty response")
response.should_respond = False
elif self.call_response.active == RESPONSE_NORMAL:
if self.call_response.delay:
Log.warning(
"Delaying response by {}s for all incoming requests",
self.call_response.delay,
)
time.sleep(self.call_response.delay) # change to async
else:
pass
# self.call_response.change_rate
# self.call_response.split
elif self.call_response.active == RESPONSE_ERROR:
Log.warning("Sending error response for all incoming requests")
err_response = ExceptionResponse(
response.function_code, self.call_response.error_response
)
err_response.transaction_id = response.transaction_id
err_response.slave_id = response.slave_id
elif self.call_response.active == RESPONSE_JUNK:
response = os.urandom(self.call_response.junk_len)
skip_encoding = True
self.call_response.clear_after -= 1
if self.call_response.clear_after <= 0:
Log.info("Resetting manipulator due to clear_after")
self.call_response.active = RESPONSE_EMPTY
return response, skip_encoding
def server_request_tracer(self, request, *_addr):
"""Trace requests.
All server requests passes this filter before being handled.
"""
if self.call_monitor.function not in {-1, request.function_code}:
return
address = (request.address if hasattr(request, "address") else -1,)
if self.call_monitor.range_start != -1 and address != -1:
if (
self.call_monitor.range_start > address
or self.call_monitor.range_stop < address
):
return
tracer = CallTracer(
call=True,
fc=request.function_code,
address=address,
count=request.count if hasattr(request, "count") else -1,
data="-",
)
self.call_list.append(tracer)
self.call_monitor.trace_response = True

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""HTTP server for modbus simulator.
The modbus simulator contain 3 distinct parts:
- Datastore simulator, to define registers and their behaviour including actions: (simulator)(../../datastore/simulator.py)
- Modbus server: (server)(./http_server.py)
- HTTP server with REST API and web pages providing an online console in your browser
Multiple setups for different server types and/or devices are prepared in a (json file)(./setup.json), the detailed configuration is explained in (doc)(README.md)
The command line parameters are kept to a minimum:
usage: main.py [-h] [--modbus_server MODBUS_SERVER]
[--modbus_device MODBUS_DEVICE] [--http_host HTTP_HOST]
[--http_port HTTP_PORT]
[--log {critical,error,warning,info,debug}]
[--json_file JSON_FILE]
[--custom_actions_module CUSTOM_ACTIONS_MODULE]
Modbus server with REST-API and web server
options:
-h, --help show this help message and exit
--modbus_server MODBUS_SERVER
use <modbus_server> from server_list in json file
--modbus_device MODBUS_DEVICE
use <modbus_device> from device_list in json file
--http_host HTTP_HOST
use <http_host> as host to bind http listen
--http_port HTTP_PORT
use <http_port> as port to bind http listen
--log {critical,error,warning,info,debug}
set log level, default is info
--log_file LOG_FILE
name of server log file, default is "server.log"
--json_file JSON_FILE
name of json_file, default is "setup.json"
--custom_actions_module CUSTOM_ACTIONS_MODULE
python file with custom actions, default is none
"""
import argparse
import asyncio
import os
from pymodbus import pymodbus_apply_logging_config
from pymodbus.logging import Log
from pymodbus.server.simulator.http_server import ModbusSimulatorServer
def get_commandline(extras=None, cmdline=None):
"""Get command line arguments."""
parser = argparse.ArgumentParser(
description="Modbus server with REST-API and web server"
)
parser.add_argument(
"--modbus_server",
help="use <modbus_server> from server_list in json file",
type=str,
)
parser.add_argument(
"--modbus_device",
help="use <modbus_device> from device_list in json file",
type=str,
)
parser.add_argument(
"--http_host",
help="use <http_host> as host to bind http listen",
type=str,
)
parser.add_argument(
"--http_port",
help="use <http_port> as port to bind http listen",
type=str,
default=8081,
)
parser.add_argument(
"--log",
choices=["critical", "error", "warning", "info", "debug"],
help="set log level, default is info",
default="info",
type=str,
)
parser.add_argument(
"--json_file",
help='name of json file, default is "setup.json"',
type=str,
default=os.path.join(os.path.dirname(__file__), "setup.json"),
)
parser.add_argument(
"--log_file",
help='name of server log file, default is "server.log"',
type=str,
)
parser.add_argument(
"--custom_actions_module",
help="python file with custom actions, default is none",
type=str,
)
if extras:
for extra in extras:
parser.add_argument(extra[0], **extra[1])
args = parser.parse_args(cmdline)
pymodbus_apply_logging_config(args.log.upper())
Log.info("Start simulator")
cmd_args = {}
for argument in args.__dict__:
if argument == "log":
continue
if args.__dict__[argument] is not None:
cmd_args[argument] = args.__dict__[argument]
return cmd_args
async def run_main():
"""Run server async."""
cmd_args = get_commandline()
task = ModbusSimulatorServer(**cmd_args)
await task.run_forever()
def main():
"""Run server."""
asyncio.run(run_main(), debug=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,228 @@
{
"server_list": {
"server": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"ignore_missing_slaves": false,
"framer": "socket",
"identity": {
"VendorName": "pymodbus",
"ProductCode": "PM",
"VendorUrl": "https://github.com/pymodbus-dev/pymodbus/",
"ProductName": "pymodbus Server",
"ModelName": "pymodbus Server",
"MajorMinorRevision": "3.1.0"
}
},
"server_try_serial": {
"comm": "serial",
"port": "/dev/tty0",
"stopbits": 1,
"bytesize": 8,
"parity": "N",
"baudrate": 9600,
"timeout": 3,
"reconnect_delay": 2,
"framer": "rtu",
"identity": {
"VendorName": "pymodbus",
"ProductCode": "PM",
"VendorUrl": "https://github.com/pymodbus-dev/pymodbus/",
"ProductName": "pymodbus Server",
"ModelName": "pymodbus Server",
"MajorMinorRevision": "3.1.0"
}
},
"server_try_tls": {
"comm": "tls",
"host": "0.0.0.0",
"port": 5020,
"certfile": "certificates/pymodbus.crt",
"keyfile": "certificates/pymodbus.key",
"ignore_missing_slaves": false,
"framer": "tls",
"identity": {
"VendorName": "pymodbus",
"ProductCode": "PM",
"VendorUrl": "https://github.com/pymodbus-dev/pymodbus/",
"ProductName": "pymodbus Server",
"ModelName": "pymodbus Server",
"MajorMinorRevision": "3.1.0"
}
},
"server_test_try_udp": {
"comm": "udp",
"host": "0.0.0.0",
"port": 5020,
"ignore_missing_slaves": false,
"framer": "socket",
"identity": {
"VendorName": "pymodbus",
"ProductCode": "PM",
"VendorUrl": "https://github.com/pymodbus-dev/pymodbus/",
"ProductName": "pymodbus Server",
"ModelName": "pymodbus Server",
"MajorMinorRevision": "3.1.0"
}
}
},
"device_list": {
"device": {
"setup": {
"co size": 63000,
"di size": 63000,
"hr size": 63000,
"ir size": 63000,
"shared blocks": true,
"type exception": true,
"defaults": {
"value": {
"bits": 0,
"uint16": 0,
"uint32": 0,
"float32": 0.0,
"string": " "
},
"action": {
"bits": null,
"uint16": "increment",
"uint32": "increment",
"float32": "increment",
"string": null
}
}
},
"invalid": [
1
],
"write": [
3
],
"bits": [
{"addr": 2, "value": 7}
],
"uint16": [
{"addr": 3, "value": 17001, "action": null},
2100
],
"uint32": [
{"addr": [4, 5], "value": 617001, "action": null},
[3037, 3038]
],
"float32": [
{"addr": [6, 7], "value": 404.17},
[4100, 4101]
],
"string": [
5047,
{"addr": [16, 20], "value": "A_B_C_D_E_"}
],
"repeat": [
]
},
"device_try": {
"setup": {
"co size": 63000,
"di size": 63000,
"hr size": 63000,
"ir size": 63000,
"shared blocks": true,
"type exception": true,
"defaults": {
"value": {
"bits": 0,
"uint16": 0,
"uint32": 0,
"float32": 0.0,
"string": " "
},
"action": {
"bits": null,
"uint16": null,
"uint32": null,
"float32": null,
"string": null
}
}
},
"invalid": [
[0, 5],
77
],
"write": [
10
],
"bits": [
10,
1009,
[1116, 1119],
{"addr": 1144, "value": 1},
{"addr": [1148,1149], "value": 32117},
{"addr": [1208, 1306], "action": "random"}
],
"uint16": [
11,
2027,
[2126, 2129],
{"addr": 2164, "value": 1},
{"addr": [2168,2169], "value": 32117},
{"addr": [2208, 2304], "action": "increment"},
{"addr": 2305,
"value": 50,
"action": "increment",
"kwargs": {"minval": 45, "maxval": 155}
},
{"addr": 2306,
"value": 50,
"action": "random",
"kwargs": {"minval": 45, "maxval": 55}
}
],
"uint32": [
[12, 13],
[3037, 3038],
[3136, 3139],
{"addr": [3174, 3175], "value": 1},
{"addr": [3188,3189], "value": 32514},
{"addr": [3308, 3407], "action": null},
{"addr": [3688, 3875], "value": 115, "action": "increment"},
{"addr": [3876, 3877],
"value": 50000,
"action": "increment",
"kwargs": {"minval": 45000, "maxval": 55000}
},
{"addr": [3878, 3879],
"value": 50000,
"action": "random",
"kwargs": {"minval": 45000, "maxval": 55000}
}
],
"float32": [
[14, 15],
[4047, 4048],
[4146, 4149],
{"addr": [4184, 4185], "value": 1},
{"addr": [4188, 4191], "value": 32514.2},
{"addr": [4308, 4407], "action": null},
{"addr": [4688, 4875], "value": 115.7, "action": "increment"},
{"addr": [4876, 4877],
"value": 50000.0,
"action": "increment",
"kwargs": {"minval": 45000.0, "maxval": 55000.0}
},
{"addr": [4878, 48779],
"value": 50000.0,
"action": "random",
"kwargs": {"minval": 45000.0, "maxval": 55000.0}
}
],
"string": [
{"addr": [16, 20], "value": "A_B_C_D_E_"},
{"addr": [529, 544], "value": "Brand name, 32 bytes...........X"}
],
"repeat": [
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple60.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple76.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple152.png">
<link rel="stylesheet" type="text/css" href="/pymodbus.css">
<!--REFRESH-->
</head>
<body>
<h1><center>Calls</center></h1>
<table width="80%" class="listbox">
<thead>
<tr>
<th width="20%">Call/Response</th>
<th width="10%">Address</th>
<th width="10%">Count</th>
<th width="60%">Data</th>
</tr>
</thead>
<tbody>
<!--FC_ROWS-->
</tbody>
<tfoot>
<tr>
<th colspan="4"><!--FC_FOOT--></th>
</tr>
</tfoot>
</table>
<fieldset class="tools_fieldset">
<legend>Monitor</legend>
<form action="/api/calls" method="get">
<table>
<tr>
<td><label>Register range</label></td>
<td>
<input type="number" value="FUNCTION_RANGE_START" name="range_start" />
<input type="number" value="FUNCTION_RANGE_STOP" name="range_stop" />
</td>
</tr>
<tr>
<td><label>Function</label></td>
<td>
<select name="function">
<option value=-1 selected>Any</option>
<!--FUNCTION_CODES-->
</select>
</td>
</tr>
<tr>
<td><label>Show as</label></td>
<td>
<input type="checkbox" FUNCTION_SHOW_HEX_CHECKED name="show_hex">Hex</input>
<input type="checkbox" FUNCTION_SHOW_DECODED_CHECKED name="show_decode">Decoded</input>
</td>
</tr>
</table>
<input type="submit" value="Monitor" name="submit" />
<input type="submit" value="Stop" name="submit" />
</form>
</fieldset>
<fieldset class="tools_fieldset">
<legend>Simulate <b><!--SIMULATION_ACTIVE--></b></legend>
<form action="/api/calls" method="get">
<table>
<tr>
<td>
<input type="radio" name="response_type" value="2" FUNCTION_RESPONSE_EMPTY_CHECKED>Empty</input>
</td>
<td></td>
<td></td>
</tr>
<tr>
<td>
<input type="radio" name="response_type" value="0" FUNCTION_RESPONSE_NORMAL_CHECKED>Normal</input>
</td>
<td><Label>split response</Label></td>
<td>
<input type="checkbox" name="response_split" FUNCTION_RESPONSE_SPLIT_CHECKED/>
<input type="number" name="split_delay" value="FUNCTION_RESPONSE_SPLIT_DELAY"/>seconds delay
</td>
</tr>
<tr>
<td></td>
<td><Label>Change rate</Label></td>
<td>
<input type="checkbox" name="response_cr" FUNCTION_RESPONSE_CR_CHECKED/>
<input type="number" name="response_cr_pct" value="FUNCTION_RESPONSE_CR_PCT"/>%
</td>
</tr>
<tr>
<td></td>
<td><Label>Delay response</Label></td>
<td><input type="number" name="response_delay" value="FUNCTION_RESPONSE_DELAY"/>seconds</td>
</tr>
<tr>
<td>
<input type="radio" name="response_type" value="1" FUNCTION_RESPONSE_ERROR_CHECKED>Error</input>
</td>
<td></td>
<td>
<select name="response_error">
<!--FUNCTION_ERROR-->
</select>
</td>
</tr>
<tr>
<td>
<input type="radio" name="response_type" value="3" FUNCTION_RESPONSE_JUNK_CHECKED>Junk</input>
</td>
<td><Label>Datalength</Label></td>
<td><input type="number" name="response_junk_datalen" value="FUNCTION_RESPONSE_JUNK" />bytes</td>
</tr>
<tr>
<td colspan="2"><Label>Clear after</Label></td>
<td><input type="number" name="response_clear_after" value="FUNCTION_RESPONSE_CLEAR_AFTER" />requests</td>
</tr>
</table>
<input type="submit" value="Simulate" name="submit" />
<input type="submit" value="Reset" name="submit" />
</form>
</fieldset>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator.</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple60.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple76.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple152.png">
<link rel="stylesheet" type="text/css" href="/pymodbus.css">
<!--REFRESH-->
</head>
<body>
<center><h1>Log</h1></center>
<table width="80%" class="listbox">
<thead>
<tr>
<th width="10%">Log entries</th>
</tr>
</thead>
<tbody>
<!--LOG_ROWS-->
</tbody>
<tfoot>
<tr>
<th colspan="7"><!--LOG_FOOT--></th>
</tr>
</tfoot>
</table>
<fieldset class="tools_fieldset" width="30%">
<legend>Log</legend>
<form action="/api/log" method="get">
<input type="submit" value="Download" name="submit" />
<input type="submit" value="Monitor" name="submit" />
<input type="submit" value="Clear" name="submit" />
</form>
</fieldset>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple60.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple76.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple152.png">
<link rel="stylesheet" type="text/css" href="/pymodbus.css">
<!--REFRESH-->
</head>
<body>
<h1><center>Registers</center></h1>
<table width="80%" class="listbox">
<thead>
<tr>
<th width="10%">Register</th>
<th width="10%">Type</th>
<th width="10%">Write</th>
<th width="10%">Action</th>
<th width="10%">Value</th>
<th width="10%"># read</th>
<th width="10%"># write</th>
</tr>
</thead>
<tbody>
<!--REGISTER_ROWS-->
</tbody>
<tfoot>
<tr>
<th colspan="7"><!--REGISTER_FOOT--></th>
</tr>
</tfoot>
</table>
<fieldset class="tools_fieldset" width="40%">
<legend>Filter registers</legend>
<form action="/api/registers" method="get">
<table>
<tr>
<td><label>Start/end</label></td>
<td>
<input type="number" name="range_start" />
<input type="number" name="range_stop" />
</td>
</tr>
<tr>
<td><label>Type</label></td>
<td>
<select name="type">
<option value=-1 selected>Any</option>
<!--REGISTER_TYPES-->
</select>
</td>
</tr>
<tr>
<td><label>Action</label></td>
<td>
<select name="action">
<option value=-1 selected>Any</option>
<!--REGISTER_ACTIONS-->
</select>
</td>
</tr>
<tr>
<td><label>Writeable</label></td>
<td><input type="checkbox" name="writeable" /><br></td>
</tr>
</table>
<input type="submit" value="Add" name="submit" />
<input type="submit" value="Clear" name="submit" />
</form>
</fieldset>
<fieldset class="tools_fieldset" width="20%">
<legend>Set</legend>
<form action="/api/registers" method="get">
<table>
<tr>
<td><label>Register</label></td>
<td><input type="number" name="register" /></td>
</tr>
<tr>
<td><label>Value</label></td>
<td><input type="text" name="value" /></td>
</tr>
</table>
<input type="submit" value="Set" name="submit" />
</form>
</fieldset><br>
<p>Result of last command: <!--RESULT--></p>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator.</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple60.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple76.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple152.png">
<link rel="stylesheet" type="text/css" href="/pymodbus.css">
<!--REFRESH-->
</head>
<body>
<body>
<center><h1>Server</h1></center>
<fieldset class="tools_fieldset" width="30%">
<legend>Status</legend>
Uptime: <!--UPTIME-->
</fieldset>
<fieldset class="tools_fieldset" width="30%">
<legend>Status</legend>
<form action="/api/server" method="get">
<input type="submit" value="Restart" name="submit" />
</form>
</fieldset>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator.</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple60.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple76.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple152.png">
<link rel="stylesheet" type="text/css" href="/pymodbus.css">
<style rel="stylesheet" type="text/css" media="screen">
.sidenav {
height: 100%;
width: 160px;
position: fixed;
z-index: 1;
top: 0;
left: 0;
background-color: gray;
overflow-x: hidden;
padding-top: 5px;
}
.main {
margin-left: 160px;
top: 0;
left: 0;
font-size: 28px;
padding: 0px 0px;
width: 100% - 160px;
height: 100%;
}
.sidenav legend {
color: white
}
</style>
</head>
<body>
<div class="sidenav">
<a href="welcome.html" target="editor">Welcome</a>
<form action="/api" method="get" target="editor">
<fieldset>
<legend>Refresh rate</legend>
<input type="number" style="width: 60%;" value=0 name="refresh">
</fieldset>
<fieldset>
<legend>View</legend>
<input type="submit" formaction="/api/registers" value="Registers" name="submit" /><br>
<input type="submit" formaction="/api/calls" value="Calls" name="submit" /><br>
<input type="submit" formaction="/api/log" value="Log" name="submit" /><br>
<input type="submit" formaction="/api/server" value="Server" name="submit" />
</fieldset>
</form>
<p>Powered by:
<a href="https://github.com/pymodbus-dev/pymodbus"><b>pymodbus</b></a> an open source project, patches are welcome.
</p>
</div>
<div class="main">
<iframe name="editor" title="Simulator" src="welcome.html"/>
</div>
</body>
</html>

View File

@@ -0,0 +1,62 @@
html {
height: 100%;
width: 100%;
}
body {
height: 100%;
width: 100%;
background-color: bisque;
}
table.listbox {
border-collapse: collapse;
border: 1px solid black;
}
table.listbox th {
background-color: lightgray;
border: 1px solid black;
padding: 5px
}
table.listbox td {
border: 1px solid black;
text-align: right;
background-color: #f1f1f1;
padding: 5px
}
legend {
font-size: 18px;
font-weight: bold;
position: relative;
color: black;
padding: 5px 5px;
}
a {
padding: 2px 4px 2px 4px;
text-decoration: none;
font-size: 18px;
display: block;
}
a:hover {
color: #f1f1f1;
}
p {
padding: 2px 4px 6px 4px;
display: block;
}
iframe {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
border: 0;
}
input[type="submit"] {
font-size: 14px;
background-color: lightblue;
margin-top: 10px;
}
.tools_fieldset {
display: inline;
vertical-align:top
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Modbus simulator.</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
</style>
</head>
<body>
<center><h1>Welcome to the pymodbus simulator</h1></center>
<p>Thanks for using pymodbus.</p>
<p>the pymodbus development team</p>
<br><br>
The <b>View</b> to the left, are used to control the simulator.
<ul>
<li><b>Registers</b> are used to monitor and/or change registers in the configuration (non-resistent),</li>
<li><b>Calls</b> are used to show and/or modify call from clients,</li>
<li><b>Log</b> are used to show the server log,</li>
<li><b>Server</b> are used to control the server.</li>
</ul>
</body>
</html>