venv added, updated

This commit is contained in:
Norbert
2024-09-13 09:46:28 +02:00
parent 577596d9f3
commit 82af8c809a
4812 changed files with 640223 additions and 2 deletions

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,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

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.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()

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",
"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

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>