modbus-sim 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modbus_sim/__init__.py +11 -0
- modbus_sim/__main__.py +5 -0
- modbus_sim/bench.py +218 -0
- modbus_sim/cli.py +373 -0
- modbus_sim/client.py +257 -0
- modbus_sim/control.py +207 -0
- modbus_sim/datatypes.py +123 -0
- modbus_sim/device.py +413 -0
- modbus_sim/faults.py +197 -0
- modbus_sim/frame.py +113 -0
- modbus_sim/models.py +431 -0
- modbus_sim/profile.py +346 -0
- modbus_sim/profiles/energy-meter.yaml +57 -0
- modbus_sim/profiles/temperature-controller.yaml +39 -0
- modbus_sim/profiles/vfd.yaml +45 -0
- modbus_sim/pytest_plugin.py +95 -0
- modbus_sim/server.py +368 -0
- modbus_sim/signals.py +31 -0
- modbus_sim-0.1.0.dist-info/METADATA +337 -0
- modbus_sim-0.1.0.dist-info/RECORD +24 -0
- modbus_sim-0.1.0.dist-info/WHEEL +5 -0
- modbus_sim-0.1.0.dist-info/entry_points.txt +5 -0
- modbus_sim-0.1.0.dist-info/licenses/LICENSE +21 -0
- modbus_sim-0.1.0.dist-info/top_level.txt +1 -0
modbus_sim/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Simulated Modbus devices (energy meter, temperature controller, frequency converter)
|
|
2
|
+
with behaviour and fault injection, over Modbus TCP, RTU over TCP and serial RTU."""
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .bench import Bench, BenchError
|
|
6
|
+
from .client import ModbusClient, ModbusError
|
|
7
|
+
from .device import Device
|
|
8
|
+
from .profile import ProfileError, load_profile
|
|
9
|
+
|
|
10
|
+
__all__ = ["Bench", "BenchError", "Device", "ModbusClient", "ModbusError", "ProfileError",
|
|
11
|
+
"load_profile", "__version__"]
|
modbus_sim/__main__.py
ADDED
modbus_sim/bench.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Bench: several simulated devices (unit IDs) behind one server, from a YAML file.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
devices:
|
|
6
|
+
- {profile: energy-meter, unit: 1, name: meter}
|
|
7
|
+
- {profile: temperature-controller, unit: 2, name: oven}
|
|
8
|
+
- {profile: vfd, unit: 3, name: pump, model_params: {accel_time: 5}}
|
|
9
|
+
|
|
10
|
+
Besides ``profile``, ``unit`` and ``name``, an entry takes the profile overrides
|
|
11
|
+
``word_order``, ``gaps``, ``model_params``, ``identification`` and ``values``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .control import CONTROL_PORT, ControlServer
|
|
22
|
+
from .device import Device
|
|
23
|
+
from .profile import ProfileError, bundled_profiles, load_profile
|
|
24
|
+
from .server import TCP_PORT, SerialServer, TcpServer
|
|
25
|
+
|
|
26
|
+
_ENTRY_KEYS = {"profile", "unit", "name"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BenchError(ValueError):
|
|
30
|
+
"""Invalid bench description; the message names the offending entry."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Bench:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.devices: dict[str, Device] = {}
|
|
36
|
+
self.server: TcpServer | SerialServer | None = None
|
|
37
|
+
self.control: ControlServer | None = None
|
|
38
|
+
self.control_port = 0
|
|
39
|
+
|
|
40
|
+
# --- construction ---
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_yaml(cls, path: str | Path, clock: Callable[[], float] = time.monotonic
|
|
44
|
+
) -> "Bench":
|
|
45
|
+
path = Path(path)
|
|
46
|
+
try:
|
|
47
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise BenchError(f"{path}: {exc.strerror or exc}") from None
|
|
50
|
+
except UnicodeDecodeError:
|
|
51
|
+
raise BenchError(f"{path}: not a UTF-8 text file") from None
|
|
52
|
+
except yaml.YAMLError as exc:
|
|
53
|
+
raise BenchError(f"{path}: invalid YAML: {exc}") from None
|
|
54
|
+
return cls.from_dict(data, base=path.parent, clock=clock)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def single(cls, profile: str, unit: int = 1,
|
|
58
|
+
clock: Callable[[], float] = time.monotonic) -> "Bench":
|
|
59
|
+
return cls.from_dict({"devices": [{"profile": profile, "unit": unit}]}, clock=clock)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: Any, base: Path | None = None,
|
|
63
|
+
clock: Callable[[], float] = time.monotonic) -> "Bench":
|
|
64
|
+
if not isinstance(data, dict) or not isinstance(data.get("devices"), list) \
|
|
65
|
+
or not data["devices"]:
|
|
66
|
+
raise BenchError("a bench is a mapping with a non-empty 'devices' list")
|
|
67
|
+
unknown = set(data) - {"devices"}
|
|
68
|
+
if unknown:
|
|
69
|
+
raise BenchError(f"unknown key {sorted(unknown)[0]!r} (allowed: devices)")
|
|
70
|
+
bench = cls()
|
|
71
|
+
units: dict[int, str] = {}
|
|
72
|
+
entries = []
|
|
73
|
+
for index, entry in enumerate(data["devices"], 1):
|
|
74
|
+
where = f"devices[{index}]"
|
|
75
|
+
if not isinstance(entry, dict):
|
|
76
|
+
raise BenchError(f"{where}: expected a mapping")
|
|
77
|
+
ref = entry.get("profile")
|
|
78
|
+
if not isinstance(ref, str):
|
|
79
|
+
raise BenchError(f"{where}: missing 'profile' (a bundled name or a file)")
|
|
80
|
+
unit = entry.get("unit")
|
|
81
|
+
if isinstance(unit, bool) or not isinstance(unit, int) or not 1 <= unit <= 247:
|
|
82
|
+
raise BenchError(f"{where}: unit must be an integer 1-247, got {unit!r}")
|
|
83
|
+
if unit in units:
|
|
84
|
+
raise BenchError(f"{where}: unit {unit} is already used by {units[unit]}")
|
|
85
|
+
name = entry.get("name")
|
|
86
|
+
if name is not None and (not isinstance(name, str) or not name.strip()):
|
|
87
|
+
raise BenchError(f"{where}: name must be a non-empty string")
|
|
88
|
+
if base is not None and ref not in bundled_profiles():
|
|
89
|
+
candidate = base / ref
|
|
90
|
+
if candidate.is_file():
|
|
91
|
+
ref = str(candidate)
|
|
92
|
+
overrides = {k: v for k, v in entry.items() if k not in _ENTRY_KEYS}
|
|
93
|
+
try:
|
|
94
|
+
profile = load_profile(ref, overrides)
|
|
95
|
+
except ProfileError as exc:
|
|
96
|
+
raise BenchError(f"{where}: {exc}") from None
|
|
97
|
+
units[unit] = name or profile.name
|
|
98
|
+
entries.append((where, profile, unit, name))
|
|
99
|
+
names = [p.name for _, p, _, n in entries if n is None]
|
|
100
|
+
for where, profile, unit, name in entries:
|
|
101
|
+
if name is None:
|
|
102
|
+
name = profile.name if names.count(profile.name) == 1 else \
|
|
103
|
+
f"{profile.name}-{unit}"
|
|
104
|
+
if name in bench.devices:
|
|
105
|
+
raise BenchError(f"{where}: duplicate name {name!r}")
|
|
106
|
+
try:
|
|
107
|
+
bench.devices[name] = Device(profile, unit=unit, name=name, clock=clock)
|
|
108
|
+
except ProfileError as exc:
|
|
109
|
+
raise BenchError(f"{where}: {exc}") from None
|
|
110
|
+
return bench
|
|
111
|
+
|
|
112
|
+
def find(self, ref: Any) -> Device:
|
|
113
|
+
"""A device by name or unit ID."""
|
|
114
|
+
if isinstance(ref, str) and ref in self.devices:
|
|
115
|
+
return self.devices[ref]
|
|
116
|
+
if isinstance(ref, str) and ref.isdigit():
|
|
117
|
+
ref = int(ref)
|
|
118
|
+
if isinstance(ref, int) and not isinstance(ref, bool):
|
|
119
|
+
for device in self.devices.values():
|
|
120
|
+
if device.unit == ref:
|
|
121
|
+
return device
|
|
122
|
+
raise ValueError(f"unknown device {ref!r} (bench has: "
|
|
123
|
+
+ ", ".join(f"{n} (unit {d.unit})" for n, d in self.devices.items())
|
|
124
|
+
+ ")")
|
|
125
|
+
|
|
126
|
+
# --- lifecycle ---
|
|
127
|
+
|
|
128
|
+
def start(self, host: str = "127.0.0.1", port: int = TCP_PORT, framing: str = "tcp",
|
|
129
|
+
serial: str | None = None, pty: bool = False, baudrate: int = 19200,
|
|
130
|
+
parity: str = "E", stopbits: int = 1, control_port: int | None = CONTROL_PORT,
|
|
131
|
+
control_host: str = "127.0.0.1") -> None:
|
|
132
|
+
"""Start the Modbus server and the control channel; port 0 picks a free port.
|
|
133
|
+
|
|
134
|
+
`framing` is "tcp" or "rtu" (RTU over TCP). With `pty=True` or a `serial`
|
|
135
|
+
port the devices speak RTU on that line instead of TCP. The control channel
|
|
136
|
+
has no authentication, so it stays on loopback unless `control_host` says
|
|
137
|
+
otherwise; `control_port=None` leaves it off.
|
|
138
|
+
"""
|
|
139
|
+
if self.server is not None:
|
|
140
|
+
raise BenchError("bench is already running")
|
|
141
|
+
devices = list(self.devices.values())
|
|
142
|
+
if pty or serial:
|
|
143
|
+
server: TcpServer | SerialServer = SerialServer(
|
|
144
|
+
devices, port=serial, baudrate=baudrate, parity=parity, stopbits=stopbits)
|
|
145
|
+
else:
|
|
146
|
+
server = TcpServer(devices, host=host, port=port, framing=framing)
|
|
147
|
+
server.start()
|
|
148
|
+
self.server = server
|
|
149
|
+
if control_port is not None:
|
|
150
|
+
control = ControlServer(self, host=control_host, port=control_port)
|
|
151
|
+
try:
|
|
152
|
+
self.control_port = control.start()
|
|
153
|
+
except OSError:
|
|
154
|
+
server.stop()
|
|
155
|
+
self.server = None
|
|
156
|
+
raise
|
|
157
|
+
self.control = control
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
if self.control is not None:
|
|
161
|
+
self.control.stop()
|
|
162
|
+
self.control = None
|
|
163
|
+
if self.server is not None:
|
|
164
|
+
self.server.stop()
|
|
165
|
+
self.server = None
|
|
166
|
+
|
|
167
|
+
def __enter__(self) -> "Bench":
|
|
168
|
+
if self.server is None:
|
|
169
|
+
self.start(port=0, control_port=0)
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *exc) -> None:
|
|
173
|
+
self.stop()
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def port(self) -> int | None:
|
|
177
|
+
return getattr(self.server, "port", None)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def path(self) -> str | None:
|
|
181
|
+
"""The serial device of an RTU bench (e.g. /dev/pts/7)."""
|
|
182
|
+
return getattr(self.server, "path", None)
|
|
183
|
+
|
|
184
|
+
def client(self, timeout: float = 2.0):
|
|
185
|
+
"""A connected :class:`~modbus_sim.client.ModbusClient` for this bench."""
|
|
186
|
+
from .client import ModbusClient
|
|
187
|
+
if isinstance(self.server, SerialServer):
|
|
188
|
+
return ModbusClient.serial(self.server.path, timeout=timeout).connect()
|
|
189
|
+
if self.server is None:
|
|
190
|
+
raise BenchError("bench is not running")
|
|
191
|
+
host = self.server.host
|
|
192
|
+
if host in ("0.0.0.0", ""):
|
|
193
|
+
host = "127.0.0.1"
|
|
194
|
+
elif host == "::":
|
|
195
|
+
host = "::1"
|
|
196
|
+
return ModbusClient(host, self.server.port, timeout=timeout,
|
|
197
|
+
framing=self.server.framing).connect()
|
|
198
|
+
|
|
199
|
+
def inject(self, device: str | int, kind: str, **params: Any) -> None:
|
|
200
|
+
self.find(device).faults.inject(kind, **params)
|
|
201
|
+
|
|
202
|
+
def clear(self, device: str | int, kind: str | None = None) -> None:
|
|
203
|
+
self.find(device).faults.clear(kind)
|
|
204
|
+
|
|
205
|
+
def value(self, device: str | int, point: str) -> Any:
|
|
206
|
+
dev = self.find(device)
|
|
207
|
+
with dev.lock:
|
|
208
|
+
return ControlServer._point(dev, point).value()
|
|
209
|
+
|
|
210
|
+
def describe(self) -> str:
|
|
211
|
+
units = ", ".join(str(d.unit) for d in self.devices.values())
|
|
212
|
+
n = len(self.devices)
|
|
213
|
+
text = f"{n} device{'s' if n != 1 else ''} (unit{'s' if n != 1 else ''} {units})"
|
|
214
|
+
if self.server is not None:
|
|
215
|
+
text += f" on {self.server.describe()}"
|
|
216
|
+
if self.control is not None:
|
|
217
|
+
text += f", control on :{self.control_port}"
|
|
218
|
+
return text
|
modbus_sim/cli.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Command line: serve devices, talk to them, inject faults."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from . import datatypes as dt
|
|
13
|
+
from .bench import Bench, BenchError
|
|
14
|
+
from .client import ModbusClient, ModbusError, ProtocolError
|
|
15
|
+
from .control import CONTROL_PORT, send_control
|
|
16
|
+
from .profile import ProfileError, bundled_profiles, load_profile
|
|
17
|
+
from .server import TCP_PORT
|
|
18
|
+
|
|
19
|
+
_TABLE_REF = {"coil": 0, "discrete": 100000, "input": 300000, "holding": 400000}
|
|
20
|
+
_TABLE_ALIASES = {"coil": "coil", "coils": "coil", "co": "coil",
|
|
21
|
+
"discrete": "discrete", "di": "discrete", "discrete-inputs": "discrete",
|
|
22
|
+
"holding": "holding", "hr": "holding", "holding-registers": "holding",
|
|
23
|
+
"input": "input", "ir": "input", "input-registers": "input"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
"""A usage or input error; printed without a traceback, exit code 2."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _int(text: str) -> int:
|
|
31
|
+
return int(text, 0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _table(text: str) -> str:
|
|
35
|
+
try:
|
|
36
|
+
return _TABLE_ALIASES[text.lower()]
|
|
37
|
+
except KeyError:
|
|
38
|
+
raise argparse.ArgumentTypeError(
|
|
39
|
+
"table is coil, discrete, holding or input (or co, di, hr, ir)") from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _value(text: str) -> Any:
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(text)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pairs(items: list[str]) -> dict[str, Any]:
|
|
50
|
+
result = {}
|
|
51
|
+
for item in items:
|
|
52
|
+
key, sep, value = item.partition("=")
|
|
53
|
+
if not sep or not key:
|
|
54
|
+
raise CliError(f"modbus-sim: expected name=value, got {item!r}")
|
|
55
|
+
result[key] = _value(value)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_connection(p: argparse.ArgumentParser) -> None:
|
|
60
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
61
|
+
p.add_argument("--port", type=int, default=TCP_PORT)
|
|
62
|
+
p.add_argument("--rtu-over-tcp", action="store_true", help="RTU framing over TCP")
|
|
63
|
+
p.add_argument("--serial", metavar="DEVICE", help="RTU on a serial port or pty instead")
|
|
64
|
+
p.add_argument("--baud", type=int, default=19200)
|
|
65
|
+
p.add_argument("--parity", choices=["N", "E", "O"], default="E")
|
|
66
|
+
p.add_argument("--unit", "-u", type=int, default=1, help="unit ID (default 1)")
|
|
67
|
+
p.add_argument("--timeout", type=float, default=2.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _add_value_format(p: argparse.ArgumentParser) -> None:
|
|
71
|
+
p.add_argument("--type", "-t", default="uint16", choices=dt.REGISTER_TYPES,
|
|
72
|
+
help="value type for registers (default uint16)")
|
|
73
|
+
p.add_argument("--order", "-o", default="ABCD", choices=dt.WORD_ORDERS,
|
|
74
|
+
help="word order of 32/64-bit values (default ABCD)")
|
|
75
|
+
p.add_argument("--scale", type=float, default=1.0, help="engineering = raw * scale")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_control(p: argparse.ArgumentParser) -> None:
|
|
79
|
+
p.add_argument("--control-host", default="127.0.0.1")
|
|
80
|
+
p.add_argument("--control-port", type=int, default=CONTROL_PORT)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
prog="modbus-sim", description="Simulated Modbus devices with behaviour and faults.")
|
|
86
|
+
parser.add_argument("--version", action="version", version=f"modbus-sim {__version__}")
|
|
87
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
88
|
+
|
|
89
|
+
p = sub.add_parser("serve", help="run simulated devices")
|
|
90
|
+
p.add_argument("profile", nargs="?", help="bundled profile or profile file (one device)")
|
|
91
|
+
p.add_argument("--bench", metavar="FILE", help="bench file with several devices")
|
|
92
|
+
p.add_argument("--unit", "-u", type=int, default=1, help="unit ID for a single profile")
|
|
93
|
+
p.add_argument("--host", default="127.0.0.1",
|
|
94
|
+
help="address to listen on (0.0.0.0 for the network)")
|
|
95
|
+
p.add_argument("--port", type=int, default=TCP_PORT, help=f"TCP port (default {TCP_PORT})")
|
|
96
|
+
p.add_argument("--rtu-over-tcp", action="store_true", help="RTU framing over TCP")
|
|
97
|
+
p.add_argument("--pty", action="store_true", help="RTU on a new virtual serial port")
|
|
98
|
+
p.add_argument("--serial", metavar="DEVICE", help="RTU on a serial port (needs pyserial)")
|
|
99
|
+
p.add_argument("--baud", type=int, default=19200)
|
|
100
|
+
p.add_argument("--parity", choices=["N", "E", "O"], default="E")
|
|
101
|
+
p.add_argument("--stopbits", type=int, choices=[1, 2], default=1)
|
|
102
|
+
p.add_argument("--control-port", type=int, default=CONTROL_PORT)
|
|
103
|
+
p.add_argument("--control-host", default="127.0.0.1",
|
|
104
|
+
help="the control channel has no authentication; keep it on loopback")
|
|
105
|
+
p.add_argument("--no-control", action="store_true", help="no control channel")
|
|
106
|
+
|
|
107
|
+
sub.add_parser("profiles", help="list the bundled profiles")
|
|
108
|
+
|
|
109
|
+
p = sub.add_parser("map", help="print the register map of a profile")
|
|
110
|
+
p.add_argument("profile")
|
|
111
|
+
p.add_argument("--markdown", action="store_true", help="as a Markdown table")
|
|
112
|
+
|
|
113
|
+
p = sub.add_parser("read", help="read from a device")
|
|
114
|
+
p.add_argument("table", type=_table, help="coil, discrete, holding or input")
|
|
115
|
+
p.add_argument("address", type=_int, help="0-based protocol address (0x.. ok)")
|
|
116
|
+
p.add_argument("--count", "-c", type=int, default=1, help="number of values")
|
|
117
|
+
_add_connection(p)
|
|
118
|
+
_add_value_format(p)
|
|
119
|
+
|
|
120
|
+
p = sub.add_parser("write", help="write to a device (coil or holding)")
|
|
121
|
+
p.add_argument("table", type=_table)
|
|
122
|
+
p.add_argument("address", type=_int)
|
|
123
|
+
p.add_argument("values", nargs="+", help="values (numbers; 0/1 for coils)")
|
|
124
|
+
_add_connection(p)
|
|
125
|
+
_add_value_format(p)
|
|
126
|
+
|
|
127
|
+
p = sub.add_parser("ident", help="read the device identification (function 43/14)")
|
|
128
|
+
_add_connection(p)
|
|
129
|
+
|
|
130
|
+
p = sub.add_parser("inject", help="inject a fault into a running bench")
|
|
131
|
+
p.add_argument("device", help="device name or unit ID")
|
|
132
|
+
p.add_argument("fault")
|
|
133
|
+
p.add_argument("params", nargs="*", help="name=value, e.g. code=4 count=3")
|
|
134
|
+
_add_control(p)
|
|
135
|
+
|
|
136
|
+
p = sub.add_parser("clear", help="clear faults (all, or one kind)")
|
|
137
|
+
p.add_argument("device")
|
|
138
|
+
p.add_argument("fault", nargs="?")
|
|
139
|
+
_add_control(p)
|
|
140
|
+
|
|
141
|
+
p = sub.add_parser("get", help="show point values of a running device")
|
|
142
|
+
p.add_argument("device")
|
|
143
|
+
p.add_argument("points", nargs="*", help="point names (default: all)")
|
|
144
|
+
_add_control(p)
|
|
145
|
+
|
|
146
|
+
p = sub.add_parser("set", help="override point values (name=value, null = release)")
|
|
147
|
+
p.add_argument("device")
|
|
148
|
+
p.add_argument("values", nargs="+")
|
|
149
|
+
_add_control(p)
|
|
150
|
+
|
|
151
|
+
p = sub.add_parser("list", help="devices and active faults of a running bench")
|
|
152
|
+
_add_control(p)
|
|
153
|
+
return parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- commands -----------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
159
|
+
if bool(args.profile) == bool(args.bench):
|
|
160
|
+
raise CliError("modbus-sim serve: give a profile or --bench FILE")
|
|
161
|
+
if sum(bool(x) for x in (args.pty, args.serial, args.rtu_over_tcp)) > 1:
|
|
162
|
+
raise CliError("modbus-sim serve: choose one of --pty, --serial, --rtu-over-tcp")
|
|
163
|
+
bench = Bench.from_yaml(args.bench) if args.bench else Bench.single(args.profile, args.unit)
|
|
164
|
+
bench.start(host=args.host, port=args.port,
|
|
165
|
+
framing="rtu" if args.rtu_over_tcp else "tcp", serial=args.serial,
|
|
166
|
+
pty=args.pty, baudrate=args.baud, parity=args.parity, stopbits=args.stopbits,
|
|
167
|
+
control_port=None if args.no_control else args.control_port,
|
|
168
|
+
control_host=args.control_host)
|
|
169
|
+
print(f"modbus-sim: {bench.describe()}", flush=True)
|
|
170
|
+
for name, device in bench.devices.items():
|
|
171
|
+
print(f" unit {device.unit:3} {name} ({device.profile.name})", flush=True)
|
|
172
|
+
stop = threading.Event()
|
|
173
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
174
|
+
try:
|
|
175
|
+
signal.signal(sig, lambda *_: stop.set())
|
|
176
|
+
except (ValueError, OSError): # not in the main thread / not on this platform
|
|
177
|
+
pass
|
|
178
|
+
try:
|
|
179
|
+
while not stop.wait(0.5):
|
|
180
|
+
pass
|
|
181
|
+
finally:
|
|
182
|
+
bench.stop()
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _profiles(_args) -> int:
|
|
187
|
+
for name in bundled_profiles():
|
|
188
|
+
print(f"{name:24} {load_profile(name).description}")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _map(args) -> int:
|
|
193
|
+
profile = load_profile(args.profile)
|
|
194
|
+
rows = []
|
|
195
|
+
for s in sorted(profile.points, key=lambda s: (list(_TABLE_REF).index(s.table), s.address)):
|
|
196
|
+
kind = s.type + (f"[{s.length}]" if s.length else "")
|
|
197
|
+
if s.count > 1 and s.type != "string":
|
|
198
|
+
kind += f" {s.word_order}"
|
|
199
|
+
limits = ""
|
|
200
|
+
if s.min is not None or s.max is not None:
|
|
201
|
+
limits = f"{'' if s.min is None else f'{s.min:g}'}..{'' if s.max is None else f'{s.max:g}'}"
|
|
202
|
+
note = s.description
|
|
203
|
+
if s.bits:
|
|
204
|
+
note = ", ".join(f"{bit}: {text}" for bit, text in sorted(s.bits.items()))
|
|
205
|
+
rows.append([s.table, str(s.address), f"{_TABLE_REF[s.table] + s.address + 1:06d}",
|
|
206
|
+
s.name, kind, "" if s.scale == 1 else f"{s.scale:g}", s.unit, s.access,
|
|
207
|
+
limits, note])
|
|
208
|
+
header = ["table", "address", "ref", "point", "type", "scale", "unit", "access",
|
|
209
|
+
"limits", "notes"]
|
|
210
|
+
print(f"{profile.name}: {profile.description}")
|
|
211
|
+
print(f"identification: {profile.identification.get('vendor')} "
|
|
212
|
+
f"{profile.identification.get('product')} {profile.identification.get('revision')}"
|
|
213
|
+
f"; unmapped addresses: {'exception 02' if profile.gaps == 'error' else 'read 0'}")
|
|
214
|
+
print()
|
|
215
|
+
if args.markdown:
|
|
216
|
+
print("| " + " | ".join(header) + " |")
|
|
217
|
+
print("|" + "---|" * len(header))
|
|
218
|
+
for row in rows:
|
|
219
|
+
print("| " + " | ".join(c.replace("|", "\\|") for c in row) + " |")
|
|
220
|
+
else:
|
|
221
|
+
widths = [max(len(r[i]) for r in rows + [header]) for i in range(len(header))]
|
|
222
|
+
for row in [header] + rows:
|
|
223
|
+
print(" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip())
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _client(args) -> ModbusClient:
|
|
228
|
+
if args.serial:
|
|
229
|
+
return ModbusClient.serial(args.serial, args.baud, args.parity, timeout=args.timeout)
|
|
230
|
+
return ModbusClient(args.host, args.port, timeout=args.timeout,
|
|
231
|
+
framing="rtu" if args.rtu_over_tcp else "tcp")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read(args) -> int:
|
|
235
|
+
with _client(args) as c:
|
|
236
|
+
if args.table in ("coil", "discrete"):
|
|
237
|
+
read = c.read_coils if args.table == "coil" else c.read_discrete_inputs
|
|
238
|
+
for i, bit in enumerate(read(args.address, args.count, args.unit)):
|
|
239
|
+
print(f"{args.table} {args.address + i}: {int(bit)}")
|
|
240
|
+
return 0
|
|
241
|
+
size = dt.registers(args.type, 1 if args.type == "string" else None)
|
|
242
|
+
total = args.count if args.type == "string" else args.count * size
|
|
243
|
+
read = c.read_holding_registers if args.table == "holding" else c.read_input_registers
|
|
244
|
+
words = read(args.address, total, args.unit)
|
|
245
|
+
if args.type == "string":
|
|
246
|
+
print(f"{args.table} {args.address}: {dt.decode(words, 'string')!r}")
|
|
247
|
+
return 0
|
|
248
|
+
for i in range(args.count):
|
|
249
|
+
part = words[i * size:(i + 1) * size]
|
|
250
|
+
value = dt.decode(part, args.type, args.order, args.scale)
|
|
251
|
+
raw = " ".join(f"{w:04x}" for w in part)
|
|
252
|
+
shown = f"{value:g}" if isinstance(value, float) else str(value)
|
|
253
|
+
print(f"{args.table} {args.address + i * size}: {shown} [{raw}]")
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _write(args) -> int:
|
|
258
|
+
if args.table not in ("coil", "holding"):
|
|
259
|
+
raise CliError("modbus-sim write: only coil and holding can be written")
|
|
260
|
+
with _client(args) as c:
|
|
261
|
+
if args.table == "coil":
|
|
262
|
+
bits = []
|
|
263
|
+
for v in args.values:
|
|
264
|
+
if v not in ("0", "1", "true", "false", "on", "off"):
|
|
265
|
+
raise CliError(f"modbus-sim write: coil value {v!r} is not 0/1")
|
|
266
|
+
bits.append(v in ("1", "true", "on"))
|
|
267
|
+
if len(bits) == 1:
|
|
268
|
+
c.write_coil(args.address, bits[0], args.unit)
|
|
269
|
+
else:
|
|
270
|
+
c.write_coils(args.address, bits, args.unit)
|
|
271
|
+
return 0
|
|
272
|
+
words: list[int] = []
|
|
273
|
+
for v in args.values:
|
|
274
|
+
if args.type == "string":
|
|
275
|
+
words += dt.encode(v, "string", length=(len(v.encode("latin-1", "replace"))
|
|
276
|
+
+ 1) // 2)
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
number = float(int(v, 0)) if v.lower().startswith(("0x", "-0x")) else float(v)
|
|
280
|
+
except ValueError:
|
|
281
|
+
raise CliError(f"modbus-sim write: {v!r} is not a number") from None
|
|
282
|
+
if dt.is_integer(args.type) and not dt.fits(number, args.type, args.scale):
|
|
283
|
+
raise CliError(f"modbus-sim write: {v} does not fit {args.type}")
|
|
284
|
+
words += dt.encode(number, args.type, args.order, args.scale)
|
|
285
|
+
if len(words) == 1:
|
|
286
|
+
c.write_register(args.address, words[0], args.unit)
|
|
287
|
+
else:
|
|
288
|
+
c.write_registers(args.address, words, args.unit)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
_ID_NAMES = ["vendor", "product code", "revision", "vendor URL", "product name", "model name",
|
|
293
|
+
"user application name"]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _ident(args) -> int:
|
|
297
|
+
with _client(args) as c:
|
|
298
|
+
for oid, text in sorted(c.read_device_identification(args.unit).items()):
|
|
299
|
+
name = _ID_NAMES[oid] if oid < len(_ID_NAMES) else f"object {oid:#04x}"
|
|
300
|
+
print(f"{name}: {text}")
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _control(args, request: dict) -> dict:
|
|
305
|
+
answer = send_control(request, args.control_host, args.control_port)
|
|
306
|
+
if not answer.get("ok"):
|
|
307
|
+
raise CliError(f"modbus-sim: {answer.get('error')}")
|
|
308
|
+
return answer
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _device_ref(text: str) -> Any:
|
|
312
|
+
return int(text) if text.isdigit() else text
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _inject(args) -> int:
|
|
316
|
+
_control(args, {"device": _device_ref(args.device), "inject": args.fault,
|
|
317
|
+
"params": _pairs(args.params)})
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _clear(args) -> int:
|
|
322
|
+
_control(args, {"device": _device_ref(args.device), "clear": args.fault or True})
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get(args) -> int:
|
|
327
|
+
answer = _control(args, {"device": _device_ref(args.device), "get": args.points or True})
|
|
328
|
+
for name, value in answer["values"].items():
|
|
329
|
+
print(f"{name}: {value:g}" if isinstance(value, float) else f"{name}: {value}")
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _set(args) -> int:
|
|
334
|
+
_control(args, {"device": _device_ref(args.device), "set": _pairs(args.values)})
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _list(args) -> int:
|
|
339
|
+
answer = _control(args, {"list": True})
|
|
340
|
+
for name, info in answer["devices"].items():
|
|
341
|
+
faults = ", ".join(f"{k}{'' if not v else ' ' + json.dumps(v)}"
|
|
342
|
+
for k, v in info["faults"].items()) or "no faults"
|
|
343
|
+
print(f"unit {info['unit']:3} {name} ({info['profile']}) "
|
|
344
|
+
f"{info['requests']} requests {faults}")
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_COMMANDS = {"serve": _serve, "profiles": _profiles, "map": _map, "read": _read,
|
|
349
|
+
"write": _write, "ident": _ident, "inject": _inject, "clear": _clear,
|
|
350
|
+
"get": _get, "set": _set, "list": _list}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main(argv: list[str] | None = None) -> int:
|
|
354
|
+
args = build_parser().parse_args(argv)
|
|
355
|
+
try:
|
|
356
|
+
return _COMMANDS[args.command](args)
|
|
357
|
+
except CliError as exc:
|
|
358
|
+
message = str(exc)
|
|
359
|
+
print(message if message.startswith("modbus-sim") else f"modbus-sim: {message}",
|
|
360
|
+
file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
except (BenchError, ProfileError) as exc:
|
|
363
|
+
print(f"modbus-sim: {exc}", file=sys.stderr)
|
|
364
|
+
return 2
|
|
365
|
+
except ModbusError as exc:
|
|
366
|
+
print(f"modbus-sim: {exc}", file=sys.stderr)
|
|
367
|
+
return 1
|
|
368
|
+
except (ProtocolError, TimeoutError) as exc:
|
|
369
|
+
print(f"modbus-sim: {exc}", file=sys.stderr)
|
|
370
|
+
return 1
|
|
371
|
+
except OSError as exc:
|
|
372
|
+
print(f"modbus-sim: {exc}", file=sys.stderr)
|
|
373
|
+
return 1
|