venv added, updated
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
733
myenv/lib/python3.12/site-packages/pymodbus/server/async_io.py
Normal file
733
myenv/lib/python3.12/site-packages/pymodbus/server/async_io.py
Normal file
@@ -0,0 +1,733 @@
|
||||
"""Implementation of a Threaded Modbus Server."""
|
||||
# pylint: disable=missing-type-doc
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
|
||||
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 FRAMER_NAME_TO_CLASS, FramerType, ModbusFramer
|
||||
from pymodbus.logging import Log
|
||||
from pymodbus.pdu import ModbusExceptions as merror
|
||||
from pymodbus.transport import CommParams, CommType, ModbusProtocol
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 = asyncio.Queue()
|
||||
self.handler_task = None # coroutine to be run on asyncio loop
|
||||
self.framer: ModbusFramer
|
||||
self.loop = asyncio.get_running_loop()
|
||||
|
||||
def _log_exception(self):
|
||||
"""Show log exception."""
|
||||
Log.debug(
|
||||
"Handler for stream [{}] has been canceled", self.comm_params.comm_name
|
||||
)
|
||||
|
||||
def callback_new_connection(self) -> ModbusProtocol:
|
||||
"""Call when listener receive new connection request."""
|
||||
Log.debug("callback_new_connection called")
|
||||
return ModbusServerRequestHandler(self)
|
||||
|
||||
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())
|
||||
self.handler_task.set_name("server connection handler")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
Log.error(
|
||||
"Server callback_connected exception: {}; {}",
|
||||
exc,
|
||||
traceback.format_exc(),
|
||||
)
|
||||
|
||||
def callback_disconnected(self, call_exc: Exception | None) -> 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]
|
||||
|
||||
# if broadcast is enabled make sure to
|
||||
# process requests to address 0
|
||||
if self.server.broadcast_enable:
|
||||
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) -> None:
|
||||
"""Coroutine which represents a single master <=> slave conversation.
|
||||
|
||||
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 asyncio.BaseProtocol.data_received
|
||||
or asyncio.DatagramProtocol.datagram_received.
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
Log.error(
|
||||
'Unknown exception "{}" on stream {} forcing disconnect',
|
||||
exc,
|
||||
self.comm_params.comm_name,
|
||||
)
|
||||
self.close()
|
||||
self.callback_disconnected(exc)
|
||||
|
||||
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)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self._async_execute(request, *addr), self.loop)
|
||||
|
||||
async def _async_execute(self, 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 = await request.execute(self.server.context[slave_id])
|
||||
else:
|
||||
context = self.server.context[request.slave_id]
|
||||
response = await 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.server_send(response, *addr, skip_encoding=skip_encoding)
|
||||
|
||||
def server_send(self, message, addr, **kwargs):
|
||||
"""Send message."""
|
||||
if kwargs.get("skip_encoding", False):
|
||||
self.send(message, addr=addr)
|
||||
elif message.should_respond:
|
||||
pdu = self.framer.buildPacket(message)
|
||||
self.send(pdu, addr=addr)
|
||||
else:
|
||||
Log.debug("Skipping sending response!!")
|
||||
|
||||
async def _recv_(self):
|
||||
"""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_NAME_TO_CLASS.get(framer, framer)
|
||||
self.serving: asyncio.Future = asyncio.Future()
|
||||
|
||||
def callback_new_connection(self):
|
||||
"""Handle incoming connect."""
|
||||
return ModbusServerRequestHandler(self)
|
||||
|
||||
async def shutdown(self):
|
||||
"""Close server."""
|
||||
if not self.serving.done():
|
||||
self.serving.set_result(True)
|
||||
self.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.listen()
|
||||
Log.info("Server listening.")
|
||||
await self.serving
|
||||
Log.info("Server graceful shutdown.")
|
||||
|
||||
def callback_connected(self) -> None:
|
||||
"""Call when connection is succcesfull."""
|
||||
|
||||
def callback_disconnected(self, exc: Exception | None) -> None:
|
||||
"""Call when connection is lost."""
|
||||
Log.debug("callback_disconnected called: {}", exc)
|
||||
|
||||
def callback_data(self, data: bytes, addr: tuple | None = None) -> int:
|
||||
"""Handle received data."""
|
||||
Log.debug("callback_data called: {} addr={}", data, ":hex", addr)
|
||||
return 0
|
||||
|
||||
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=FramerType.SOCKET,
|
||||
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=FramerType.TLS,
|
||||
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=FramerType.SOCKET,
|
||||
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=FramerType.RTU, identity=None, **kwargs
|
||||
):
|
||||
"""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__(
|
||||
params=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=context,
|
||||
ignore_missing_slaves=kwargs.get("ignore_missing_slaves", False),
|
||||
broadcast_enable=kwargs.get("broadcast_enable", False),
|
||||
response_manipulator=kwargs.get("response_manipulator", None),
|
||||
request_tracer=kwargs.get("request_tracer", None),
|
||||
identity=kwargs.get("identity", None),
|
||||
framer=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: ModbusTcpServer | ModbusUdpServer | ModbusSerialServer
|
||||
|
||||
def __init__(self, server):
|
||||
"""Register new server."""
|
||||
self.server = server
|
||||
self.loop = asyncio.get_event_loop()
|
||||
|
||||
@classmethod
|
||||
async def run(cls, server, custom_functions) -> None:
|
||||
"""Help starting/stopping server."""
|
||||
for func in custom_functions:
|
||||
server.decoder.register(func)
|
||||
cls.active_server = _serverList(server) # type: ignore[assignment]
|
||||
with suppress(asyncio.exceptions.CancelledError):
|
||||
await server.serve_forever()
|
||||
|
||||
@classmethod
|
||||
async def async_stop(cls) -> None:
|
||||
"""Wait for server stop."""
|
||||
if not cls.active_server:
|
||||
raise RuntimeError("ServerAsyncStop called without server task active.")
|
||||
await cls.active_server.server.shutdown() # type: ignore[union-attr]
|
||||
cls.active_server = None # type: ignore[assignment]
|
||||
|
||||
@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
|
||||
future = asyncio.run_coroutine_threadsafe(cls.async_stop(), cls.active_server.loop)
|
||||
future.result(timeout=10 if os.name == 'nt' else 0.1)
|
||||
|
||||
|
||||
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", FramerType.SOCKET), 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", FramerType.TLS),
|
||||
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", FramerType.SOCKET), 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,
|
||||
):
|
||||
"""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", FramerType.RTU), 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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
"""HTTP server for modbus simulator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
|
||||
AIOHTTP_MISSING = False
|
||||
except ImportError:
|
||||
AIOHTTP_MISSING = True
|
||||
if TYPE_CHECKING: # always False at runtime
|
||||
# type checkers do not understand the Raise RuntimeError in __init__()
|
||||
from aiohttp import web
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 ModbusSimulatorServer
|
||||
|
||||
async def run():
|
||||
simulator = ModbusSimulatorServer(
|
||||
modbus_server="my server",
|
||||
modbus_device="my device",
|
||||
http_host="localhost",
|
||||
http_port=8080)
|
||||
await simulator.run_forever(only_start=True)
|
||||
...
|
||||
await simulator.stop()
|
||||
"""
|
||||
|
||||
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 = None,
|
||||
):
|
||||
"""Initialize http interface."""
|
||||
if AIOHTTP_MISSING:
|
||||
raise RuntimeError(
|
||||
"Simulator server requires aiohttp. "
|
||||
'Please install with "pip install aiohttp" and try again.'
|
||||
)
|
||||
with open(json_file, encoding="utf-8") as file:
|
||||
setup = json.load(file)
|
||||
|
||||
comm_class = {
|
||||
"serial": ModbusSerialServer,
|
||||
"tcp": ModbusTcpServer,
|
||||
"tls": ModbusTlsServer,
|
||||
"udp": ModbusUdpServer,
|
||||
}
|
||||
if custom_actions_module:
|
||||
actions_module = importlib.import_module(custom_actions_module)
|
||||
custom_actions_dict = actions_module.custom_actions_dict
|
||||
else:
|
||||
custom_actions_dict = {}
|
||||
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 {}
|
||||
)
|
||||
datastore = None
|
||||
if "device_id" in server:
|
||||
# Designated ModBus unit address. Will only serve data if the address matches
|
||||
datastore = ModbusServerContext(slaves={int(server["device_id"]): self.datastore_context}, single=False)
|
||||
else:
|
||||
# Will server any request regardless of addressing
|
||||
datastore = ModbusServerContext(slaves=self.datastore_context, single=True)
|
||||
|
||||
comm = comm_class[server.pop("comm")]
|
||||
framer = 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: web.TCPSite | None = None
|
||||
self.runner: web.AppRunner
|
||||
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("/restapi/{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: dict[str, list] = {
|
||||
"log": ["", self.build_html_log],
|
||||
"registers": ["", self.build_html_registers],
|
||||
"calls": ["", self.build_html_calls],
|
||||
"server": ["", self.build_html_server],
|
||||
}
|
||||
self.generator_json = {
|
||||
"log": self.build_json_log,
|
||||
"registers": self.build_json_registers,
|
||||
"calls": self.build_json_calls,
|
||||
"server": self.build_json_server,
|
||||
}
|
||||
self.submit_html = {
|
||||
"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[CallTracer] = []
|
||||
self.request_lookup = ServerDecoder.getFCdict()
|
||||
self.call_monitor = CallTypeMonitor()
|
||||
self.call_response = CallTypeResponse()
|
||||
app_key = getattr(web, 'AppKey', str) # fall back to str for aiohttp < 3.9.0
|
||||
self.api_key = app_key("modbus_server")
|
||||
|
||||
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[self.api_key] = asyncio.create_task(
|
||||
self.modbus_server.serve_forever()
|
||||
)
|
||||
app[self.api_key].set_name("simulator modbus server")
|
||||
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[self.api_key].cancel()
|
||||
with contextlib.suppress(asyncio.exceptions.CancelledError):
|
||||
await app[self.api_key]
|
||||
|
||||
Log.info("Modbus server Stopped")
|
||||
|
||||
async def run_forever(self, only_start=False):
|
||||
"""Start modbus and http servers."""
|
||||
try:
|
||||
self.runner = web.AppRunner(self.web_app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.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.runner.cleanup()
|
||||
self.site = None
|
||||
if not self.serving.done():
|
||||
self.serving.set_result(True)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def handle_html_static(self, request):
|
||||
"""Handle static html."""
|
||||
if not (page := request.path[1:]):
|
||||
page = "index.html"
|
||||
file = os.path.normpath(os.path.join(self.web_path, page))
|
||||
if not file.startswith(self.web_path):
|
||||
raise ValueError(f"File access outside {self.web_path} not permitted.")
|
||||
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."""
|
||||
command = request.path.split("/")[-1]
|
||||
params = await request.json()
|
||||
try:
|
||||
result = self.generator_json[command](params)
|
||||
except (KeyError, ValueError, TypeError, IndexError) as exc:
|
||||
Log.error("Unhandled error during json request: {}", exc)
|
||||
return web.json_response({"result": "error", "error": f"Unhandled error Error: {exc}"})
|
||||
return web.json_response(result)
|
||||
|
||||
def build_html_registers(self, params, html):
|
||||
"""Build html registers page."""
|
||||
result_txt, foot = self.helper_handle_submit(params, self.submit_html)
|
||||
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: dict, html: str) -> str:
|
||||
"""Build html calls page."""
|
||||
result_txt, foot = self.helper_handle_submit(params, self.submit_html)
|
||||
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 #type: ignore[attr-defined]
|
||||
else ""
|
||||
)
|
||||
function_codes += f"<option value={function.function_code} {selected}>{function.function_code_name}</option>" #type: ignore[attr-defined]
|
||||
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.decode()}</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):
|
||||
"""Build json registers response."""
|
||||
# Process params using the helper function
|
||||
result_txt, foot = self.helper_handle_submit(params, {
|
||||
"Set": self.action_set,
|
||||
})
|
||||
|
||||
if not result_txt:
|
||||
result_txt = "ok"
|
||||
if not foot:
|
||||
foot = "Operation completed successfully"
|
||||
|
||||
# Extract necessary parameters
|
||||
try:
|
||||
range_start = int(params.get("range_start", 0))
|
||||
range_stop = int(params.get("range_stop", range_start))
|
||||
except ValueError:
|
||||
return {"result": "error", "error": "Invalid range parameters"}
|
||||
|
||||
# Retrieve register details
|
||||
register_rows = []
|
||||
for i in range(range_start, range_stop + 1):
|
||||
inx, reg = self.datastore_context.get_text_register(i)
|
||||
row = {
|
||||
"index": inx,
|
||||
"type": reg.type,
|
||||
"access": reg.access,
|
||||
"action": reg.action,
|
||||
"value": reg.value,
|
||||
"count_read": reg.count_read,
|
||||
"count_write": reg.count_write
|
||||
}
|
||||
register_rows.append(row)
|
||||
|
||||
# Generate register types and actions (assume these are predefined mappings)
|
||||
register_types = dict(self.datastore_context.registerType_name_to_id)
|
||||
register_actions = dict(self.datastore_context.action_name_to_id)
|
||||
|
||||
# Build the JSON response
|
||||
json_response = {
|
||||
"result": result_txt,
|
||||
"footer": foot,
|
||||
"register_types": register_types,
|
||||
"register_actions": register_actions,
|
||||
"register_rows": register_rows,
|
||||
}
|
||||
|
||||
return json_response
|
||||
|
||||
def build_json_calls(self, params: dict) -> dict:
|
||||
"""Build json calls response."""
|
||||
result_txt, foot = self.helper_handle_submit(params, {
|
||||
"Reset": self.action_reset,
|
||||
"Add": self.action_add,
|
||||
"Simulate": self.action_simulate,
|
||||
})
|
||||
if not foot:
|
||||
foot = "Monitoring 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"),
|
||||
):
|
||||
function_error.append({
|
||||
"value": i,
|
||||
"text": txt,
|
||||
"selected": i == self.call_response.error_response
|
||||
})
|
||||
|
||||
range_start = (
|
||||
self.call_monitor.range_start
|
||||
if self.call_monitor.range_start != -1
|
||||
else None
|
||||
)
|
||||
range_stop = (
|
||||
self.call_monitor.range_stop
|
||||
if self.call_monitor.range_stop != -1
|
||||
else None
|
||||
)
|
||||
|
||||
function_codes = []
|
||||
for function in self.request_lookup.values():
|
||||
function_codes.append({
|
||||
"value": function.function_code, # type: ignore[attr-defined]
|
||||
"text": function.function_code_name, # type: ignore[attr-defined]
|
||||
"selected": function.function_code == self.call_monitor.function # type: ignore[attr-defined]
|
||||
})
|
||||
|
||||
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):
|
||||
call_rows.append({
|
||||
"call": entry.call,
|
||||
"fc": entry.fc,
|
||||
"address": entry.address,
|
||||
"count": entry.count,
|
||||
"data": entry.data.decode()
|
||||
})
|
||||
|
||||
json_response = {
|
||||
"simulation_action": simulation_action,
|
||||
"range_start": range_start,
|
||||
"range_stop": range_stop,
|
||||
"function_codes": function_codes,
|
||||
"function_show_hex_checked": self.call_monitor.hex,
|
||||
"function_show_decoded_checked": self.call_monitor.decode,
|
||||
"function_response_normal_checked": self.call_response.active == RESPONSE_NORMAL,
|
||||
"function_response_error_checked": self.call_response.active == RESPONSE_ERROR,
|
||||
"function_response_empty_checked": self.call_response.active == RESPONSE_EMPTY,
|
||||
"function_response_junk_checked": self.call_response.active == RESPONSE_JUNK,
|
||||
"function_response_split_checked": self.call_response.split > 0,
|
||||
"function_response_split_delay": self.call_response.split,
|
||||
"function_response_cr_checked": self.call_response.change_rate > 0,
|
||||
"function_response_cr_pct": self.call_response.change_rate,
|
||||
"function_response_delay": self.call_response.delay,
|
||||
"function_response_junk": self.call_response.junk_len,
|
||||
"function_error": function_error,
|
||||
"function_response_clear_after": self.call_response.clear_after,
|
||||
"call_rows": call_rows,
|
||||
"foot": foot,
|
||||
"result": result_txt
|
||||
}
|
||||
|
||||
return json_response
|
||||
|
||||
def build_json_log(self, params):
|
||||
"""Build json log page."""
|
||||
return {"result": "error", "error": "log endpoint not implemented", "params": params}
|
||||
|
||||
def build_json_server(self, params):
|
||||
"""Build html server page."""
|
||||
return {"result": "error", "error": "server endpoint not implemented", "params": params}
|
||||
|
||||
def helper_handle_submit(self, params, submit_actions):
|
||||
"""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 submit_actions:
|
||||
return None, None
|
||||
return submit_actions[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 -1
|
||||
)
|
||||
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=b"-",
|
||||
)
|
||||
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,
|
||||
)
|
||||
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=b"-",
|
||||
)
|
||||
self.call_list.append(tracer)
|
||||
self.call_monitor.trace_response = True
|
||||
@@ -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.rst)
|
||||
|
||||
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()
|
||||
@@ -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",
|
||||
"parameters": {"minval": 45, "maxval": 155}
|
||||
},
|
||||
{"addr": 2306,
|
||||
"value": 50,
|
||||
"action": "random",
|
||||
"parameters": {"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",
|
||||
"parameters": {"minval": 45000, "maxval": 55000}
|
||||
},
|
||||
{"addr": [3878, 3879],
|
||||
"value": 50000,
|
||||
"action": "random",
|
||||
"parameters": {"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",
|
||||
"parameters": {"minval": 45000.0, "maxval": 55000.0}
|
||||
},
|
||||
{"addr": [4878, 48779],
|
||||
"value": 50000.0,
|
||||
"action": "random",
|
||||
"parameters": {"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 |
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user